diff --git a/.env.example b/.env.example index 7178490..1f53db8 100644 --- a/.env.example +++ b/.env.example @@ -88,9 +88,17 @@ AUTO_EXCHANGE_ENABLED=true # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。 -# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",用于验签,从后台取到后填这里; -# 配齐并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。 +# 穿山甲"奖励校验密钥"(m-key),验签用,从 GroMore 后台各广告位取到后填这里。 +# 配齐(任一非空)并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。 +# 每个激励位 m-key 不同但共用同一回调 URL → 各位分开一行配(留空的忽略);验签逐个试、任一通过即接受。 PANGLE_CALLBACK_ENABLED=false +# 测试应用 激励位 104099649 +PANGLE_REWARD_SECRET_TEST= +# 测试应用 专属激励位 104127529 +PANGLE_REWARD_SECRET_TEST_DEDICATED= +# 正式应用 激励位 104099389 +PANGLE_REWARD_SECRET_PROD= +# (旧用法,仍兼容:单个或逗号分隔的多个 m-key,会与上面三个合并去重) PANGLE_REWARD_SECRET= # ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖, # 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。 diff --git a/.gitignore b/.gitignore index bd48023..d473d6b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ data/* !data/media/ data/media/* !data/media/dl.html +!data/media/taobao_landing.jpg secrets/* !secrets/.gitkeep diff --git a/alembic/versions/45047b5a884c_merge_store_mapping_jd_dl_invalid_and_.py b/alembic/versions/45047b5a884c_merge_store_mapping_jd_dl_invalid_and_.py new file mode 100644 index 0000000..58c3271 --- /dev/null +++ b/alembic/versions/45047b5a884c_merge_store_mapping_jd_dl_invalid_and_.py @@ -0,0 +1,26 @@ +"""merge store_mapping_jd_dl_invalid and ad_revenue heads (0.1.2) + +Revision ID: 45047b5a884c +Revises: d4e68464761d, store_mapping_jd_dl_invalid +Create Date: 2026-06-16 01:38:26.273226 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '45047b5a884c' +down_revision: Union[str, Sequence[str], None] = ('d4e68464761d', 'store_mapping_jd_dl_invalid') +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/ad_revenue_report_cols.py b/alembic/versions/ad_revenue_report_cols.py new file mode 100644 index 0000000..afbc8b4 --- /dev/null +++ b/alembic/versions/ad_revenue_report_cols.py @@ -0,0 +1,39 @@ +"""ad revenue report columns: app_env + our_code_id + +给 ad_ecpm_record / ad_reward_record / ad_feed_reward_record 各加两列: +- app_env:我们的穿山甲应用环境(prod=傻瓜比价正式 / test=测试应用) +- our_code_id:我们在穿山甲后台配置的代码位 ID(104xxx,非底层 mediation rit) + +供「广告收益报表」按 用户/日期/广告类型/应用/代码位 聚合 展示条数/收益/金币。 +旧数据这两列为 NULL(报表里来源列留空),新数据由客户端上报/发奖时回填。 + +Revision ID: ad_revenue_report_cols +Revises: coupon_engage_per_package +Create Date: 2026-06-15 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "ad_revenue_report_cols" +down_revision = "coupon_engage_per_package" +branch_labels = None +depends_on = None + + +_TABLES = ("ad_ecpm_record", "ad_reward_record", "ad_feed_reward_record") + + +def upgrade() -> None: + for table in _TABLES: + op.add_column(table, sa.Column("app_env", sa.String(length=16), nullable=True)) + op.add_column(table, sa.Column("our_code_id", sa.String(length=64), nullable=True)) + + +def downgrade() -> None: + for table in _TABLES: + op.drop_column(table, "our_code_id") + op.drop_column(table, "app_env") diff --git a/alembic/versions/b3f1a2c4d5e6_user_username_and_default_nickname.py b/alembic/versions/b3f1a2c4d5e6_user_username_and_default_nickname.py new file mode 100644 index 0000000..ba76a60 --- /dev/null +++ b/alembic/versions/b3f1a2c4d5e6_user_username_and_default_nickname.py @@ -0,0 +1,79 @@ +"""user.username(对外展示账号 ID)+ 默认昵称,并回填存量用户 + +Revision ID: b3f1a2c4d5e6 +Revises: 45047b5a884c +Create Date: 2026-06-16 12:00:00.000000 + +加 user.username(11 位纯数字、首位非 1 与手机号天然区分、全局唯一)。存量用户: +生成唯一 username,昵称为空的补 9 位字母数字随机昵称。 +迁移自包含(不 import app 业务代码,逻辑冻结为当时快照)。 +""" +import secrets +import string +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b3f1a2c4d5e6' +down_revision: Union[str, Sequence[str], None] = '45047b5a884c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +_USERNAME_FIRST = "23456789" # 首位避前导 0、避 1(手机号都以 1 开头) +_USERNAME_DIGITS = "0123456789" +_NICKNAME_ALPHABET = string.ascii_letters + string.digits + + +def _gen_username() -> str: + return secrets.choice(_USERNAME_FIRST) + "".join( + secrets.choice(_USERNAME_DIGITS) for _ in range(10) + ) + + +def _gen_nickname() -> str: + return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(9)) + + +def upgrade() -> None: + # 1. 先加可空列(存量行此刻还没值) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('username', sa.String(length=11), nullable=True)) + + # 2. 回填存量:每人一个唯一 username;昵称为空的补随机昵称。 + # 表里 username 原本全空,本批用 used 防互撞即可(无既有非空值需规避)。 + # "user" 是 PostgreSQL 保留字,必须加双引号(SQLite 也接受双引号标识符)。 + bind = op.get_bind() + rows = bind.execute(sa.text('SELECT id, nickname FROM "user"')).fetchall() + used = set() + for row in rows: + uid, nick = row[0], row[1] + while True: + uname = _gen_username() + if uname not in used: + used.add(uname) + break + if nick is None or not str(nick).strip(): + bind.execute( + sa.text('UPDATE "user" SET username = :u, nickname = :n WHERE id = :i'), + {"u": uname, "n": _gen_nickname(), "i": uid}, + ) + else: + bind.execute( + sa.text('UPDATE "user" SET username = :u WHERE id = :i'), + {"u": uname, "i": uid}, + ) + + # 3. 收紧:NOT NULL + 唯一索引(对齐模型 unique=True, index=True) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('username', existing_type=sa.String(length=11), nullable=False) + batch_op.create_index('ix_user_username', ['username'], unique=True) + + +def downgrade() -> None: + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_index('ix_user_username') + batch_op.drop_column('username') diff --git a/alembic/versions/ceb286289426_merge_add_feed_scene_and_launch_confirm_.py b/alembic/versions/ceb286289426_merge_add_feed_scene_and_launch_confirm_.py new file mode 100644 index 0000000..7946070 --- /dev/null +++ b/alembic/versions/ceb286289426_merge_add_feed_scene_and_launch_confirm_.py @@ -0,0 +1,26 @@ +"""merge add_feed_scene and launch_confirm_sample heads + +Revision ID: ceb286289426 +Revises: add_feed_scene, launch_confirm_sample_table +Create Date: 2026-06-20 23:52:57.853400 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ceb286289426' +down_revision: Union[str, Sequence[str], None] = ('add_feed_scene', 'launch_confirm_sample_table') +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_transaction_task_ref_unique.py b/alembic/versions/coin_transaction_task_ref_unique.py new file mode 100644 index 0000000..11b8067 --- /dev/null +++ b/alembic/versions/coin_transaction_task_ref_unique.py @@ -0,0 +1,40 @@ +"""add coin_transaction task ref unique index + +可重复任务(task_enable_notification「打开消息提醒」逐次减半领取)的发放路径是无锁的 +读-算-写:并发/连点的两个 claim 会都读到同一已领次数、算出同一 ref_id(task_key:N)、 +各发一笔,导致双倍发钱。给 coin_transaction 加 (user_id, biz_type, ref_id) 部分唯一索引 +(仅 biz_type LIKE 'task%' 且 ref_id 非空),第二笔撞唯一约束 → IntegrityError,被 +task.claim_task 兜底成 AlreadyClaimedError(409)。与 cash_transaction 提现退款、 +withdraw_order 在途单的「唯一约束 + IntegrityError 兜底」同模式。 + +Revision ID: coin_txn_task_ref_uq +Revises: drop_force_onboarding +Create Date: 2026-06-12 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "coin_txn_task_ref_uq" +down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_index( + "ux_coin_transaction_task_ref", + "coin_transaction", + ["user_id", "biz_type", "ref_id"], + unique=True, + sqlite_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"), + postgresql_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index("ux_coin_transaction_task_ref", table_name="coin_transaction") diff --git a/alembic/versions/comparison_debug_fields.py b/alembic/versions/comparison_debug_fields.py new file mode 100644 index 0000000..2d472d9 --- /dev/null +++ b/alembic/versions/comparison_debug_fields.py @@ -0,0 +1,54 @@ +"""comparison_record: 客户端环境 / 性能统计 / LLM 明细 debug 字段 + +Revision ID: comparison_debug_fields +Revises: cps_activity_image +Create Date: 2026-06-17 22:00:00.000000 + +给 admin 比价记录 debug 页新增采集列,全部 nullable(旧记录 / 未发版客户端为 NULL,向后兼容): +- 客户端环境: device_*/rom_*/android_*/app_version*/source_app_version/经纬度 +- 过程统计: total_ms/step_count/llm_call_count/retry_count +- LLM 明细: llm_calls(JSONB,server 按 trace_id 同机拉 pricebot 落库) +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "comparison_debug_fields" +down_revision: Union[str, Sequence[str], None] = "cps_activity_image" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + + +def upgrade() -> None: + op.add_column("comparison_record", sa.Column("device_model", sa.String(64), nullable=True)) + op.add_column("comparison_record", sa.Column("device_manufacturer", sa.String(64), nullable=True)) + op.add_column("comparison_record", sa.Column("rom_vendor", sa.String(32), nullable=True)) + op.add_column("comparison_record", sa.Column("rom_name", sa.String(32), nullable=True)) + op.add_column("comparison_record", sa.Column("rom_version", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("android_version", sa.String(16), nullable=True)) + op.add_column("comparison_record", sa.Column("android_sdk", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("app_version", sa.String(32), nullable=True)) + op.add_column("comparison_record", sa.Column("app_version_code", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("source_app_version", sa.String(32), nullable=True)) + op.add_column("comparison_record", sa.Column("longitude", sa.Float(), nullable=True)) + op.add_column("comparison_record", sa.Column("latitude", sa.Float(), nullable=True)) + op.add_column("comparison_record", sa.Column("total_ms", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("step_count", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("llm_call_count", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("retry_count", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("llm_calls", _JSONB, nullable=True)) + + +def downgrade() -> None: + for col in ( + "llm_calls", "retry_count", "llm_call_count", "step_count", "total_ms", + "latitude", "longitude", "source_app_version", "app_version_code", + "app_version", "android_sdk", "android_version", "rom_version", + "rom_name", "rom_vendor", "device_manufacturer", "device_model", + ): + op.drop_column("comparison_record", col) diff --git a/alembic/versions/coupon_engage_per_package.py b/alembic/versions/coupon_engage_per_package.py new file mode 100644 index 0000000..58d1e70 --- /dev/null +++ b/alembic/versions/coupon_engage_per_package.py @@ -0,0 +1,53 @@ +"""coupon_prompt_engagement 频控加 package 维度(美团/淘宝/京东各自独立弹) + +Revision ID: coupon_engage_per_package +Revises: store_mapping_jd_cols +Create Date: 2026-06-14 11:00:00.000000 + +需求:领券引导窗按 (device, App, 自然日) 频控——在美团弹过/领过,不影响淘宝、京东今天 +仍各弹一次。原表唯一键是 (device_id, engage_date),缺 package → 任一 App 弹过就把整台 +设备当天标记 engage,其余 App 被压住不弹(bug)。 + +本迁移: + 1. 加 package 列(NOT NULL,旧行用 server_default "" 填占位,不影响新逻辑判断)。 + 2. 旧唯一约束 (device_id, engage_date) → 新 (device_id, package, engage_date)。 + +SQLite 不支持直接 drop/add 约束,用 batch_alter_table(建临时表 + 拷数据 + 换名, +与 store_mapping_* 同款)。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'coupon_engage_per_package' +down_revision: Union[str, Sequence[str], None] = 'store_mapping_jd_cols' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op: + # 加 package 列。旧行(改造前的全局记录)填 "" 占位:它们对应的是"老的全局态", + # 新逻辑按 (device, package, 日) 判,占位 "" 不会与真实包名(com.xxx)碰撞。 + batch_op.add_column( + sa.Column('package', sa.String(length=64), nullable=False, server_default='') + ) + # 旧唯一约束 (device_id, engage_date) → 新三元组 (device_id, package, engage_date)。 + batch_op.drop_constraint('uq_coupon_engage_device_date', type_='unique') + batch_op.create_unique_constraint( + 'uq_coupon_engage_device_pkg_date', + ['device_id', 'package', 'engage_date'], + ) + + +def downgrade() -> None: + with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op: + batch_op.drop_constraint('uq_coupon_engage_device_pkg_date', type_='unique') + batch_op.create_unique_constraint( + 'uq_coupon_engage_device_date', + ['device_id', 'engage_date'], + ) + batch_op.drop_column('package') diff --git a/alembic/versions/cps_activity_image.py b/alembic/versions/cps_activity_image.py new file mode 100644 index 0000000..e677950 --- /dev/null +++ b/alembic/versions/cps_activity_image.py @@ -0,0 +1,38 @@ +"""cps activity 落地页图: cps_activity.image_url + 回填存量淘宝活动 + +新建淘宝活动时运营提供落地页主视觉图(上传或选已有),落地页按活动展示对应图。 +存量淘宝活动回填为此前写死的那张图,无缝衔接。 + +Revision ID: cps_activity_image +Revises: cps_v2_platforms +Create Date: 2026-06-17 12:00:00.000000 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +from app.core.config import settings + + +revision: str = "cps_activity_image" +down_revision: Union[str, Sequence[str], None] = "cps_v2_platforms" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("cps_activity", sa.Column("image_url", sa.String(512), nullable=True)) + # 回填存量淘宝活动 = 此前写死的那张图(绝对 URL,基于落地页域名 CPS_REDIRECT_BASE; + # dev 未配则相对路径)。新活动由运营自传/选,不回填。 + base = settings.CPS_REDIRECT_BASE.rstrip("/") + default_img = f"{base}/media/taobao_landing.jpg" if base else "/media/taobao_landing.jpg" + op.execute( + sa.text( + "UPDATE cps_activity SET image_url = :img WHERE platform = 'taobao'" + ).bindparams(img=default_img) + ) + + +def downgrade() -> None: + op.drop_column("cps_activity", "image_url") diff --git a/alembic/versions/cps_link_tables.py b/alembic/versions/cps_link_tables.py new file mode 100644 index 0000000..7c42d4f --- /dev/null +++ b/alembic/versions/cps_link_tables.py @@ -0,0 +1,61 @@ +"""cps_link / cps_click tables (群发短链 + 点击统计) + +Revision ID: cps_link_tables +Revises: cps_tables +Create Date: 2026-06-16 13:20:00.000000 + +群发券链接套一层 /c/{code} 做点击统计:cps_link(短码→美团目标) + cps_click(点击事件)。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "cps_link_tables" +down_revision: Union[str, Sequence[str], None] = "cps_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_NOW = sa.text("CURRENT_TIMESTAMP") + + +def upgrade() -> None: + op.create_table( + "cps_link", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("code", sa.String(length=16), nullable=False), + sa.Column("group_id", sa.Integer(), nullable=False), + sa.Column("activity_id", sa.Integer(), nullable=False), + sa.Column("sid", sa.String(length=64), nullable=False), + sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan"), + sa.Column("target_url", sa.String(length=1024), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("code", name="uq_cps_link_code"), + ) + op.create_index("ix_cps_link_code", "cps_link", ["code"]) + op.create_index("ix_cps_link_group_id", "cps_link", ["group_id"]) + op.create_index("ix_cps_link_activity_id", "cps_link", ["activity_id"]) + op.create_index("ix_cps_link_sid", "cps_link", ["sid"]) + + op.create_table( + "cps_click", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("link_id", sa.Integer(), nullable=False), + sa.Column("group_id", sa.Integer(), nullable=False), + sa.Column("sid", sa.String(length=64), nullable=False), + sa.Column("ip", sa.String(length=64), nullable=True), + sa.Column("ua", sa.String(length=512), nullable=True), + sa.Column("clicked_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_cps_click_link_id", "cps_click", ["link_id"]) + op.create_index("ix_cps_click_group_id", "cps_click", ["group_id"]) + op.create_index("ix_cps_click_sid", "cps_click", ["sid"]) + op.create_index("ix_cps_click_clicked_at", "cps_click", ["clicked_at"]) + + +def downgrade() -> None: + op.drop_table("cps_click") + op.drop_table("cps_link") diff --git a/alembic/versions/cps_tables.py b/alembic/versions/cps_tables.py new file mode 100644 index 0000000..8180e35 --- /dev/null +++ b/alembic/versions/cps_tables.py @@ -0,0 +1,92 @@ +"""cps_group / cps_activity / cps_order tables (CPS 分发与对账) + +Revision ID: cps_tables +Revises: b3f1a2c4d5e6 +Create Date: 2026-06-16 12:40:00.000000 + +群发 CPS 优惠券的分发与对账:cps_group(群=sid) + cps_activity(可推广活动池) + +cps_order(美团 query_order 按 sid 拉回的订单明细)。金额统一存「分」。 +order_id 全局唯一(reconcile 按它 upsert,订单状态会变则更新)。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "cps_tables" +down_revision: Union[str, Sequence[str], None] = "b3f1a2c4d5e6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_NOW = sa.text("CURRENT_TIMESTAMP") + + +def upgrade() -> None: + op.create_table( + "cps_group", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("sid", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("member_count", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="active"), + sa.Column("remark", sa.String(length=256), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("sid", name="uq_cps_group_sid"), + ) + op.create_index("ix_cps_group_sid", "cps_group", ["sid"]) + + op.create_table( + "cps_activity", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan"), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("act_id", sa.String(length=64), nullable=True), + sa.Column("product_view_sign", sa.String(length=128), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="active"), + sa.Column("remark", sa.String(length=256), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "cps_order", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("order_id", sa.String(length=64), nullable=False), + sa.Column("sid", sa.String(length=64), nullable=True), + sa.Column("act_id", sa.String(length=64), nullable=True), + sa.Column("biz_line", sa.Integer(), nullable=True), + sa.Column("trade_type", sa.Integer(), nullable=True), + sa.Column("pay_price_cents", sa.Integer(), nullable=True), + sa.Column("commission_cents", sa.Integer(), nullable=True), + sa.Column("commission_rate", sa.String(length=16), nullable=True), + sa.Column("refund_price_cents", sa.Integer(), nullable=True), + sa.Column("refund_profit_cents", sa.Integer(), nullable=True), + sa.Column("mt_status", sa.String(length=8), nullable=True), + sa.Column("invalid_reason", sa.String(length=128), nullable=True), + sa.Column("product_name", sa.String(length=512), nullable=True), + sa.Column("pay_time", sa.DateTime(timezone=True), nullable=True), + sa.Column("mt_update_time", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "raw", + sa.JSON().with_variant(postgresql.JSONB(), "postgresql"), + nullable=False, + ), + sa.Column("first_seen", sa.DateTime(timezone=True), server_default=_NOW, nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("order_id", name="uq_cps_order_order_id"), + ) + op.create_index("ix_cps_order_order_id", "cps_order", ["order_id"]) + op.create_index("ix_cps_order_sid", "cps_order", ["sid"]) + op.create_index("ix_cps_order_act_id", "cps_order", ["act_id"]) + op.create_index("ix_cps_order_mt_status", "cps_order", ["mt_status"]) + op.create_index("ix_cps_order_pay_time", "cps_order", ["pay_time"]) + + +def downgrade() -> None: + op.drop_table("cps_order") + op.drop_table("cps_activity") + op.drop_table("cps_group") diff --git a/alembic/versions/cps_v2_platforms.py b/alembic/versions/cps_v2_platforms.py new file mode 100644 index 0000000..e80c1a6 --- /dev/null +++ b/alembic/versions/cps_v2_platforms.py @@ -0,0 +1,61 @@ +"""cps v2: 活动 payload(淘口令/京东链接) + 群多平台/sid 可空 + 点击 event_type + +Revision ID: cps_v2_platforms +Revises: cps_link_tables +Create Date: 2026-06-17 11:00:00.000000 + +接入淘宝/京东: +- cps_activity.payload 淘宝整段淘口令文本 / 京东推广链接(美团仍用 act_id) +- cps_group.platforms 该群发哪些平台(多选);sid 改可空(纯淘宝/京东无 sid) +- cps_link.sid 可空 + target_url 加长(淘口令较长) +- cps_click.event_type visit / copy(淘宝落地页"复制口令") +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "cps_v2_platforms" +down_revision: Union[str, Sequence[str], None] = "cps_link_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 活动:淘宝淘口令 / 京东链接 + op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True)) + + # 群:多平台(现有群都是美团,server_default 回填)+ sid 可空 + op.add_column( + "cps_group", + sa.Column( + "platforms", + sa.JSON().with_variant(postgresql.JSONB(), "postgresql"), + nullable=False, + server_default=sa.text("'[\"meituan\"]'::jsonb"), + ), + ) + op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True) + + # link:sid 可空 + target 加长 + op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True) + op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048)) + + # click:sid 可空 + 事件类型 + op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True) + op.add_column( + "cps_click", + sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"), + ) + + +def downgrade() -> None: + op.drop_column("cps_click", "event_type") + op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False) + op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024)) + op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False) + op.drop_column("cps_group", "platforms") + op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False) + op.drop_column("cps_activity", "payload") diff --git a/alembic/versions/cps_wx_user.py b/alembic/versions/cps_wx_user.py new file mode 100644 index 0000000..0e0db37 --- /dev/null +++ b/alembic/versions/cps_wx_user.py @@ -0,0 +1,48 @@ +"""cps 微信用户表 cps_wx_user + cps_click 加 openid + +CPS 落地页微信网页授权拿 openid/昵称头像,做用户级群统计。 + +Revision ID: cps_wx_user +Revises: comparison_debug_fields +Create Date: 2026-06-19 12:00:00.000000 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "cps_wx_user" +down_revision: Union[str, Sequence[str], None] = "comparison_debug_fields" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "cps_wx_user", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("openid", sa.String(64), nullable=False), + sa.Column("unionid", sa.String(64), nullable=True), + sa.Column("nickname", sa.String(128), nullable=True), + sa.Column("headimgurl", sa.String(512), nullable=True), + sa.Column("first_code", sa.String(16), nullable=True), + sa.Column("first_group_id", sa.Integer(), nullable=True), + sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_cps_wx_user_openid", "cps_wx_user", ["openid"], unique=True) + op.create_index("ix_cps_wx_user_unionid", "cps_wx_user", ["unionid"]) + op.create_index("ix_cps_wx_user_first_group_id", "cps_wx_user", ["first_group_id"]) + + op.add_column("cps_click", sa.Column("openid", sa.String(64), nullable=True)) + op.create_index("ix_cps_click_openid", "cps_click", ["openid"]) + + +def downgrade() -> None: + op.drop_index("ix_cps_click_openid", table_name="cps_click") + op.drop_column("cps_click", "openid") + op.drop_index("ix_cps_wx_user_first_group_id", table_name="cps_wx_user") + op.drop_index("ix_cps_wx_user_unionid", table_name="cps_wx_user") + op.drop_index("ix_cps_wx_user_openid", table_name="cps_wx_user") + op.drop_table("cps_wx_user") diff --git a/alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py b/alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py new file mode 100644 index 0000000..16fc94c --- /dev/null +++ b/alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py @@ -0,0 +1,26 @@ +"""merge ad_revenue_report_cols and store_mapping_tb_dl_invalid heads + +Revision ID: d4e68464761d +Revises: ad_revenue_report_cols, store_mapping_tb_dl_invalid +Create Date: 2026-06-15 21:55:58.115692 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd4e68464761d' +down_revision: Union[str, Sequence[str], None] = ('ad_revenue_report_cols', 'store_mapping_tb_dl_invalid') +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/launch_confirm_sample_table.py b/alembic/versions/launch_confirm_sample_table.py new file mode 100644 index 0000000..d9ff6fe --- /dev/null +++ b/alembic/versions/launch_confirm_sample_table.py @@ -0,0 +1,53 @@ +"""launch_confirm_sample table (启动确认窗 agent 兜底样本: LLM 放行的弹窗样本沉淀) + +Revision ID: launch_confirm_sample_table +Revises: cps_wx_user +Create Date: 2026-06-20 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "launch_confirm_sample_table" +down_revision: Union[str, Sequence[str], None] = "cps_wx_user" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "launch_confirm_sample", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), + sa.Column("trace_id", sa.String(length=64), nullable=True), + sa.Column("device_id", sa.String(length=64), nullable=True), + sa.Column("host_package", sa.String(length=128), nullable=True), + sa.Column("target_app", sa.String(length=128), nullable=True), + sa.Column("system_locale", sa.String(length=32), nullable=True), + sa.Column("exec_success", sa.Boolean(), nullable=False), + sa.Column("dialog_title", sa.String(length=256), nullable=True), + # PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐 + sa.Column("payload", sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), "postgresql"), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_created_at"), ["created_at"], unique=False) + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_trace_id"), ["trace_id"], unique=False) + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_device_id"), ["device_id"], unique=False) + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_host_package"), ["host_package"], unique=False) + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_system_locale"), ["system_locale"], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_system_locale")) + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_host_package")) + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_device_id")) + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_trace_id")) + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_created_at")) + + op.drop_table("launch_confirm_sample") diff --git a/alembic/versions/store_mapping_jd_cols.py b/alembic/versions/store_mapping_jd_cols.py new file mode 100644 index 0000000..02acf32 --- /dev/null +++ b/alembic/versions/store_mapping_jd_cols.py @@ -0,0 +1,40 @@ +"""store_mapping 加京东原料列(jd_vender_id + 分享链/反查 URL/deeplink) + +Revision ID: store_mapping_jd_cols +Revises: store_mapping_meituan_cols +Create Date: 2026-06-13 00:00:00.000000 + +京东秒送接入店内搜索 deeplink 链路: 3.cn 短链 → 跟随重定向反查 venderId+storeId → +openapp.jdmobile:// deeplink。京东店铺身份是**两个**稳定数字 id: storeId 进 id_jd(稳定 +店主键, 同 taobao 的 shopId→id_taobao), venderId 进单列 jd_vender_id(deeplink 还需它)。 +其余三列与 taobao_*/meituan_* 原料列平行。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'store_mapping_jd_cols' +down_revision: Union[str, Sequence[str], None] = 'store_mapping_meituan_cols' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.add_column(sa.Column('jd_vender_id', sa.String(length=64), nullable=True)) + batch_op.add_column(sa.Column('jd_share_url', sa.String(length=256), nullable=True)) + batch_op.add_column(sa.Column('jd_resolved_url', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('jd_deeplink', sa.Text(), nullable=True)) + batch_op.create_index(batch_op.f('ix_store_mapping_jd_vender_id'), ['jd_vender_id'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_store_mapping_jd_vender_id')) + batch_op.drop_column('jd_deeplink') + batch_op.drop_column('jd_resolved_url') + batch_op.drop_column('jd_share_url') + batch_op.drop_column('jd_vender_id') diff --git a/alembic/versions/store_mapping_jd_dl_invalid.py b/alembic/versions/store_mapping_jd_dl_invalid.py new file mode 100644 index 0000000..09a1af2 --- /dev/null +++ b/alembic/versions/store_mapping_jd_dl_invalid.py @@ -0,0 +1,32 @@ +"""store_mapping 加京东 deeplink 失效标记列 jd_deeplink_invalid_at + +Revision ID: store_mapping_jd_dl_invalid +Revises: store_mapping_tb_dl_invalid +Create Date: 2026-06-15 00:00:00.000000 + +京东同淘宝: 缓存的店内搜索 deeplink 会失效(打开是"当前门店超出配送范围"页)。pricebot 比价撞到 +失效页时回退正常搜店, 并 server→server 通知把该 storeId 的 deeplink 标记失效。本列记失效时刻 +(NULL=有效); lookup 反查时过滤掉已失效的京东候选, 不再返回坏 deeplink。 +与淘宝 taobao_deeplink_invalid_at 对称; 用时间戳而非布尔: 留痕可审计、可统计失效率, 不销毁原 deeplink。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'store_mapping_jd_dl_invalid' +down_revision: Union[str, Sequence[str], None] = 'store_mapping_tb_dl_invalid' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.add_column(sa.Column('jd_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.drop_column('jd_deeplink_invalid_at') diff --git a/alembic/versions/store_mapping_meituan_cols.py b/alembic/versions/store_mapping_meituan_cols.py new file mode 100644 index 0000000..e9494a2 --- /dev/null +++ b/alembic/versions/store_mapping_meituan_cols.py @@ -0,0 +1,39 @@ +"""store_mapping 加美团原料列(poi_id_str + 分享链/反查 URL/deeplink) + +Revision ID: store_mapping_meituan_cols +Revises: store_mapping_table +Create Date: 2026-06-13 00:00:00.000000 + +美团接入店内搜索 deeplink 链路: dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink。 +poi_id_str 每次分享重新加密、非稳定主键, 单列 meituan_poi_id_str 存(不占 id_meituan, +后者留给将来 CPS API 的稳定数字 poi_id)。其余三列与 taobao_* 原料列平行。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'store_mapping_meituan_cols' +down_revision: Union[str, Sequence[str], None] = 'store_mapping_table' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.add_column(sa.Column('meituan_poi_id_str', sa.String(length=64), nullable=True)) + batch_op.add_column(sa.Column('meituan_share_url', sa.String(length=256), nullable=True)) + batch_op.add_column(sa.Column('meituan_resolved_url', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('meituan_deeplink', sa.Text(), nullable=True)) + batch_op.create_index(batch_op.f('ix_store_mapping_meituan_poi_id_str'), ['meituan_poi_id_str'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_store_mapping_meituan_poi_id_str')) + batch_op.drop_column('meituan_deeplink') + batch_op.drop_column('meituan_resolved_url') + batch_op.drop_column('meituan_share_url') + batch_op.drop_column('meituan_poi_id_str') diff --git a/alembic/versions/store_mapping_table.py b/alembic/versions/store_mapping_table.py new file mode 100644 index 0000000..7e3e709 --- /dev/null +++ b/alembic/versions/store_mapping_table.py @@ -0,0 +1,77 @@ +"""store_mapping table (平台店铺表:跨平台同店 id/名 映射,server 侧无条件落库) + +Revision ID: store_mapping_table +Revises: coin_txn_task_ref_uq +Create Date: 2026-06-13 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'store_mapping_table' +down_revision: Union[str, Sequence[str], None] = 'coin_txn_task_ref_uq' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'store_mapping', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + # 跨平台身份 + sa.Column('id_taobao', sa.String(length=64), nullable=True), + sa.Column('name_taobao', sa.String(length=128), nullable=True), + sa.Column('id_meituan', sa.String(length=64), nullable=True), + sa.Column('name_meituan', sa.String(length=128), nullable=True), + sa.Column('id_jd', sa.String(length=64), nullable=True), + sa.Column('name_jd', 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('taobao_address', sa.String(length=256), nullable=True), + # 溯源 + sa.Column('source_platform', sa.String(length=32), nullable=True), + sa.Column('business_type', sa.String(length=16), nullable=False), + sa.Column('trace_id', sa.String(length=64), nullable=False), + sa.Column('source_device_id', sa.String(length=64), nullable=True), + sa.Column('source_user_id', sa.Integer(), nullable=True), + # 淘宝原料(URL 可能很长 → Text) + sa.Column('taobao_share_url', sa.String(length=256), nullable=True), + sa.Column('taobao_resolved_url', sa.Text(), nullable=True), + sa.Column('taobao_deeplink', sa.Text(), nullable=True), + # PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐 + sa.Column('attrs', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + # 一次比价一行,trace_id 幂等去重(防 pricebot 重试 / replay 重复写) + sa.UniqueConstraint('trace_id', name='uq_store_mapping_trace'), + ) + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_store_mapping_id_taobao'), ['id_taobao'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_id_meituan'), ['id_meituan'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_id_jd'), ['id_jd'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_geohash'), ['geohash'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_source_platform'), ['source_platform'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_source_device_id'), ['source_device_id'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_source_user_id'), ['source_user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_created_at'), ['created_at'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_store_mapping_created_at')) + batch_op.drop_index(batch_op.f('ix_store_mapping_source_user_id')) + batch_op.drop_index(batch_op.f('ix_store_mapping_source_device_id')) + batch_op.drop_index(batch_op.f('ix_store_mapping_source_platform')) + batch_op.drop_index(batch_op.f('ix_store_mapping_geohash')) + batch_op.drop_index(batch_op.f('ix_store_mapping_id_jd')) + batch_op.drop_index(batch_op.f('ix_store_mapping_id_meituan')) + batch_op.drop_index(batch_op.f('ix_store_mapping_id_taobao')) + + op.drop_table('store_mapping') diff --git a/alembic/versions/store_mapping_tb_dl_invalid.py b/alembic/versions/store_mapping_tb_dl_invalid.py new file mode 100644 index 0000000..cea52a4 --- /dev/null +++ b/alembic/versions/store_mapping_tb_dl_invalid.py @@ -0,0 +1,33 @@ +"""store_mapping 加淘宝 deeplink 失效标记列 taobao_deeplink_invalid_at + +Revision ID: store_mapping_tb_dl_invalid +Revises: coupon_engage_per_package +Create Date: 2026-06-15 00:00:00.000000 + +缓存的淘宝店内搜索 deeplink 会失效(打开是"页面出错了"降级页)。pricebot 比价撞到错误页时 +回退正常搜店, 并 server→server 通知把该 shopId 的 deeplink 标记失效。本列记失效时刻 +(NULL=有效); lookup 反查时过滤掉已失效的淘宝候选, 不再返回坏 deeplink。 +只加淘宝一列(当前只接淘宝); 用时间戳而非布尔: 留痕可审计、可统计失效率, 且不销毁原 deeplink。 +无需索引: 过滤总叠在 name_taobao== 之后, 候选集已小。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'store_mapping_tb_dl_invalid' +down_revision: Union[str, Sequence[str], None] = 'coupon_engage_per_package' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.add_column(sa.Column('taobao_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.drop_column('taobao_deeplink_invalid_at') diff --git a/app/admin/main.py b/app/admin/main.py index f070ad8..8dfdf77 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -14,10 +14,14 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.admin.routers.ad_audit import router as ad_audit_router +from app.admin.routers.ad_config import router as ad_config_router +from app.admin.routers.ad_revenue import router as ad_revenue_router from app.admin.routers.admins import router as admins_router from app.admin.routers.audit import router as audit_router from app.admin.routers.auth import router as auth_router +from app.admin.routers.comparison import router as comparison_router from app.admin.routers.config import router as config_router +from app.admin.routers.cps import router as cps_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 @@ -91,4 +95,8 @@ admin_app.include_router(feedback_qr_router) admin_app.include_router(admins_router) admin_app.include_router(audit_router) admin_app.include_router(config_router) +admin_app.include_router(comparison_router) +admin_app.include_router(cps_router) admin_app.include_router(ad_audit_router) +admin_app.include_router(ad_config_router) +admin_app.include_router(ad_revenue_router) diff --git a/app/admin/repositories/ad_audit.py b/app/admin/repositories/ad_audit.py index c882b12..7cb2941 100644 --- a/app/admin/repositories/ad_audit.py +++ b/app/admin/repositories/ad_audit.py @@ -1,16 +1,18 @@ """看广告金币审计:复算 expected_coin 并与实发对比。 只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移): -- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号 - (与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。 -- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计 - (与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。 +- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号 + (与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置, + 故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。 +- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计** + (与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置, + 复算需叠加本日之前的累计份数)。 非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。 """ from __future__ import annotations -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core import rewards @@ -19,22 +21,42 @@ from app.models.ad_reward import AdRewardRecord from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS +def _prior_granted_counts( + db: Session, *, date: str, user_id: int | None +) -> dict[int, int]: + """各用户在 date **之前**已发奖的 reward_video 累计份数,作为当日复算的 LT 序号起点。 + LT 因子改账号累计后,当日第 1 份并非全局第 1 份,需叠加历史累计。""" + stmt = ( + select(AdRewardRecord.user_id, func.count()) + .where( + AdRewardRecord.reward_date < date, + AdRewardRecord.reward_scene == "reward_video", + AdRewardRecord.status == "granted", + ) + .group_by(AdRewardRecord.user_id) + ) + if user_id is not None: + stmt = stmt.where(AdRewardRecord.user_id == user_id) + return {uid: n for uid, n in db.execute(stmt).all()} + + def _reward_video_rows( db: Session, *, date: str, user_id: int | None ) -> list[dict]: - """看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。""" + """看视频记录复算。按 (user_id, created_at) 升序还原账号累计第 N 份(含本日之前的累计)。""" stmt = ( select(AdRewardRecord) .where( AdRewardRecord.reward_date == date, AdRewardRecord.reward_scene == "reward_video", ) - .order_by(AdRewardRecord.user_id, AdRewardRecord.created_at) + .order_by(AdRewardRecord.user_id, AdRewardRecord.created_at, AdRewardRecord.id) ) if user_id is not None: stmt = stmt.where(AdRewardRecord.user_id == user_id) - granted_n: dict[int, int] = {} # user_id -> 已 granted 份数 + # 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐 + granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id) rows: list[dict] = [] for rec in db.execute(stmt).scalars(): if rec.status == "granted": @@ -45,6 +67,8 @@ def _reward_video_rows( "scene": "reward_video", "record_id": rec.id, "user_id": rec.user_id, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, "status": rec.status, "ecpm": rec.ecpm_raw, @@ -64,6 +88,8 @@ def _reward_video_rows( "scene": "reward_video", "record_id": rec.id, "user_id": rec.user_id, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, "status": rec.status, "ecpm": rec.ecpm_raw, @@ -80,17 +106,38 @@ def _reward_video_rows( return rows +def _feed_prior_granted_units( + db: Session, *, date: str, user_id: int | None +) -> dict[int, int]: + """各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。""" + stmt = ( + select( + AdFeedRewardRecord.user_id, + func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0), + ) + .where( + AdFeedRewardRecord.reward_date < date, + AdFeedRewardRecord.status == "granted", + ) + .group_by(AdFeedRewardRecord.user_id) + ) + if user_id is not None: + stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) + return {uid: int(n) for uid, n in db.execute(stmt).all()} + + def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: - """信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。""" + """信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。""" stmt = ( select(AdFeedRewardRecord) .where(AdFeedRewardRecord.reward_date == date) - .order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at) + .order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at, AdFeedRewardRecord.id) ) if user_id is not None: stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) - granted_units: dict[int, int] = {} # user_id -> 已 granted 份数累计 + # 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐 + granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id) rows: list[dict] = [] for rec in db.execute(stmt).scalars(): if rec.status == "granted": @@ -107,6 +154,8 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: "scene": "feed", "record_id": rec.id, "user_id": rec.user_id, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, "status": rec.status, "ecpm": rec.ecpm_raw, @@ -125,6 +174,8 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: "scene": "feed", "record_id": rec.id, "user_id": rec.user_id, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, "status": rec.status, "ecpm": rec.ecpm_raw, @@ -141,21 +192,50 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: return rows -def ad_coin_audit( - db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int +def audit_rows( + db: Session, *, date: str, user_id: int | None, scene: str | None = None ) -> list[dict]: - """返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。 + """当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"。 - scene: None=两类都要 / "reward_video" / "feed"。 - 份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。 + 每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账, + 也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。 """ rows: list[dict] = [] if scene in (None, "reward_video"): rows.extend(_reward_video_rows(db, date=date, user_id=user_id)) if scene in (None, "feed"): rows.extend(_feed_rows(db, date=date, user_id=user_id)) - rows.sort(key=lambda r: r["created_at"], reverse=True) - return rows[:limit] + return rows + + +def ad_coin_audit( + db: Session, + *, + date: str, + user_id: int | None, + scene: str | None, + limit: int, + only_mismatch: bool = False, +) -> dict: + """当日发奖复算。返回 {total, mismatch_count, truncated, items}。 + + scene: None=两类都要 / "reward_video" / "feed";only_mismatch=True 只展示不一致(✗)行。 + 关键:`total` 与 `mismatch_count` 在**全量**(截断前)上统计,故对账数字始终可信,不受 limit + 影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。 + 份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。 + """ + rows = audit_rows(db, date=date, user_id=user_id, scene=scene) + rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True) + + total = len(rows) + mismatch_count = sum(1 for r in rows if not r["matched"]) + display = [r for r in rows if not r["matched"]] if only_mismatch else rows + return { + "total": total, + "mismatch_count": mismatch_count, + "truncated": len(display) > limit, + "items": display[:limit], + } def formula_snapshot() -> dict: diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py new file mode 100644 index 0000000..206726b --- /dev/null +++ b/app/admin/repositories/ad_revenue.py @@ -0,0 +1,239 @@ +"""admin 广告收益报表:按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合(单表含发奖对账)。 + +只读。聚合键 = user_id × ad_type × app_env × our_code_id;每组一行同时给出: +- 展示条数 + 收益:`ad_ecpm_record`(每行 = 客户端一次广告展示;收益 = Σ eCPM元 ÷ 1000)。 + 激励视频每次展示上报一行;信息流轮播每条展示各上报一行(每条独立 id,不复用会话)。 +- 应发金币 / 实发金币:复用金币审计的**逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径, + 不另写公式),把每条发奖记录的 expected/actual 按同维度求和;`matched` = 组内**逐条**全部一致 + (任一条不符该组即不符,不用「应发和==实发和」以免互相抵消掩盖错误)。**不改发奖逻辑**,只读复算。 + +展示与发奖来自不同表,做并集:有展示无发奖(用户中途关 / 未达发奖)、有发奖无展示 +(未上报 eCPM)都各自成行。app_env/our_code_id 旧数据为 NULL → 归到「来源未知」组。 + +⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 `feed`(迁移后 Draw +不再产生新数据)。② 聚合级只能看出「某组应发≠实发」,定位到具体哪条仍需逐条审计接口(ad-coin-audit)。 +""" +from __future__ import annotations + +from datetime import date as _date, datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.admin.repositories import ad_audit +from app.core import rewards +from app.models.ad_ecpm import AdEcpmRecord + + +def _cn_hour(dt: datetime) -> int: + """created_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(rewards.CN_TZ).hour + + +def _key( + report_date: str, + user_id: int, + ad_type: str, + app_env: str | None, + our_code_id: str | None, + hour: int | None, +) -> tuple: + return (report_date, user_id, ad_type, app_env or None, our_code_id or None, hour) + + +def _date_range(date_from: str, date_to: str) -> list[str]: + """闭区间内逐日 'YYYY-MM-DD' 串(含首尾)。date_from > date_to 时返回空。""" + d0 = _date.fromisoformat(date_from) + d1 = _date.fromisoformat(date_to) + out: list[str] = [] + d = d0 + while d <= d1: + out.append(d.isoformat()) + d += timedelta(days=1) + return out + + +# 审计行的 scene 与报表 ad_type 一一对应 +_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"} + + +def ad_revenue_report( + db: Session, + *, + date_from: str, + date_to: str, + user_id: int | None = None, + ad_type: str | None = None, + granularity: str = "day", + limit: int = 500, +) -> dict: + """日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 date_from==date_to。 + + 聚合键含**日期**:report_date × user × ad_type × app_env × our_code_id(× 北京小时,granularity=hour)。 + ad_type: None=全部 / reward_video / feed / draw。 + granularity: "day"=按天 / "hour"=按小时(聚合键再加北京小时 0–23,每组一行)。 + limit 只截断展示明细,total 与 total_* / daily 在全量上统计(不受 limit 影响),数字始终可信。 + + 返回额外含 `daily`(按日期汇总的展示/收益/应发/实发,供前端按天趋势图;不受 limit 影响)。 + + 注:按小时下,展示按 ecpm 记录的小时、金币按发奖记录的小时各自归桶——S2S 回调可能比展示晚 + 一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。 + """ + by_hour = granularity == "hour" + groups: dict[tuple, dict] = {} + + def _grp(key: tuple) -> dict: + g = groups.get(key) + if g is None: + rdate, uid, atype, app_env, code_id, hour = key + g = { + "report_date": rdate, + "user_id": uid, + "ad_type": atype, + "app_env": app_env, + "our_code_id": code_id, + "hour": hour, + "impressions": 0, + "revenue_yuan": 0.0, + "expected_coin": 0, + "actual_coin": 0, + "adns": set(), + "impression_records": [], # 该组逐条展示明细(展开下钻用) + "records": [], # 该组逐条发奖复算明细(展开下钻用) + } + groups[key] = g + return g + + # 1) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序) + stmt = select(AdEcpmRecord).where( + AdEcpmRecord.report_date >= date_from, + AdEcpmRecord.report_date <= date_to, + ) + if user_id is not None: + stmt = stmt.where(AdEcpmRecord.user_id == user_id) + if ad_type is not None: + stmt = stmt.where(AdEcpmRecord.ad_type == ad_type) + for rec in db.execute(stmt).scalars(): + hour = _cn_hour(rec.created_at) if by_hour else None + g = _grp(_key(rec.report_date, rec.user_id, rec.ad_type, rec.app_env, rec.our_code_id, hour)) + g["impressions"] += 1 + # 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次);用与发奖同源的解析,口径一致。 + rev = rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0 + g["revenue_yuan"] += rev + if rec.adn: + g["adns"].add(rec.adn) + g["impression_records"].append({ + "id": rec.id, + "created_at": rec.created_at, + "ecpm": rec.ecpm_raw, + "revenue_yuan": round(rev, 6), + "adn": rec.adn, + "slot_id": rec.slot_id, + }) + + # 2) 应发 / 实发金币 ← 复用金币审计逐条复算(同一公式口径),按同维度求和。 + # audit_rows 是单日的,区间逐日调用,每天的行归到当天 report_date(语义与单日报表完全一致)。 + # ad_type=draw 时审计无对应记录(scene 只有 reward_video/feed),金币侧自然为空。 + audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None + if ad_type is None or audit_scene is not None: + for d in _date_range(date_from, date_to): + for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene): + atype = _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]) + hour = _cn_hour(row["created_at"]) if by_hour else None + g = _grp(_key(d, row["user_id"], atype, row.get("app_env"), row.get("our_code_id"), hour)) + g["expected_coin"] += int(row["expected_coin"]) + g["actual_coin"] += int(row["actual_coin"]) + # 逐条明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致)——前端展开该组时下钻展示。 + g["records"].append({ + "record_id": row["record_id"], + "created_at": row["created_at"], + "status": row["status"], + "ecpm": row["ecpm"], + "ecpm_factor": row["ecpm_factor"], + "units": row["units"], + "lt_index_start": row["lt_index_start"], + "lt_index_end": row["lt_index_end"], + "lt_factor_start": row["lt_factor_start"], + "lt_factor_end": row["lt_factor_end"], + "expected_coin": row["expected_coin"], + "actual_coin": row["actual_coin"], + "matched": row["matched"], + }) + + rows = list(groups.values()) + rows.sort( + key=lambda r: ( + r["report_date"], + r["user_id"], + r["hour"] if r["hour"] is not None else -1, + r["ad_type"] or "", + r["our_code_id"] or "", + ) + ) + + total_impressions = sum(r["impressions"] for r in rows) + total_expected_coin = sum(r["expected_coin"] for r in rows) + total_actual_coin = sum(r["actual_coin"] for r in rows) + total_revenue_yuan = round(sum(r["revenue_yuan"] for r in rows), 6) + + # 按日期汇总(全量,不受 limit):供前端按天趋势图。 + daily_map: dict[str, dict] = {} + for r in rows: + d = daily_map.get(r["report_date"]) + if d is None: + d = { + "date": r["report_date"], + "impressions": 0, + "revenue_yuan": 0.0, + "expected_coin": 0, + "actual_coin": 0, + } + daily_map[r["report_date"]] = d + d["impressions"] += r["impressions"] + d["revenue_yuan"] += r["revenue_yuan"] + d["expected_coin"] += r["expected_coin"] + d["actual_coin"] += r["actual_coin"] + daily = [ + {**d, "revenue_yuan": round(d["revenue_yuan"], 6)} + for d in sorted(daily_map.values(), key=lambda x: x["date"]) + ] + + items = [ + { + "report_date": r["report_date"], + "user_id": r["user_id"], + "ad_type": r["ad_type"], + "app_env": r["app_env"], + "our_code_id": r["our_code_id"], + "hour": r["hour"], + "impressions": r["impressions"], + "revenue_yuan": round(r["revenue_yuan"], 6), + "expected_coin": r["expected_coin"], + "actual_coin": r["actual_coin"], + # 组内**逐条**全部一致才记一致——不能用「应发和==实发和」,否则一条多发+一条少发会互相 + # 抵消、求和相等被误判为 ✓,掩盖真实发奖错误。纯展示无发奖记录的组 all([]) → True。 + "matched": all(rec["matched"] for rec in r["records"]), + "adns": sorted(r["adns"]), + "impression_records": sorted( + r["impression_records"], key=lambda x: (x["created_at"], x["id"]) + ), + "records": sorted(r["records"], key=lambda x: (x["created_at"], x["record_id"])), + } + for r in rows[:limit] + ] + + return { + "total": len(rows), + "truncated": len(rows) > limit, + "total_impressions": total_impressions, + "total_revenue_yuan": total_revenue_yuan, + "total_expected_coin": total_expected_coin, + "total_actual_coin": total_actual_coin, + "mismatch_count": sum( + 1 for r in rows if not all(rec["matched"] for rec in r["records"]) + ), + "daily": daily, + "items": items, + } diff --git a/app/admin/repositories/audit_log.py b/app/admin/repositories/audit_log.py index 8b41bf4..7eec119 100644 --- a/app/admin/repositories/audit_log.py +++ b/app/admin/repositories/audit_log.py @@ -4,7 +4,7 @@ """ from __future__ import annotations -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models.admin import AdminAuditLog @@ -49,8 +49,9 @@ def list_audit_logs( admin_id: int | None = None, limit: int = 50, cursor: int | None = None, -) -> tuple[list[AdminAuditLog], int | None]: - """游标分页(id 倒序),与现有 list_* 约定一致。返回 (rows, next_cursor)。""" +) -> tuple[list[AdminAuditLog], int | None, int]: + """offset 分页(id 倒序)+ total。cursor 即 offset((page-1)*pageSize),支持页码跳页。 + 返回 (rows, next_cursor, total)。""" stmt = select(AdminAuditLog) if action: stmt = stmt.where(AdminAuditLog.action == action) @@ -58,13 +59,15 @@ def list_audit_logs( stmt = stmt.where(AdminAuditLog.target_type == target_type) if admin_id is not None: stmt = stmt.where(AdminAuditLog.admin_id == admin_id) - if cursor is not None: - stmt = stmt.where(AdminAuditLog.id < cursor) - stmt = stmt.order_by(AdminAuditLog.id.desc()) - rows = list(db.execute(stmt.limit(limit + 1)).scalars().all()) + + total = int(db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()) + offset = max(cursor or 0, 0) + rows = list( + db.execute( + stmt.order_by(AdminAuditLog.id.desc()).offset(offset).limit(limit + 1) + ).scalars().all() + ) has_more = len(rows) > limit items = rows[:limit] - # next_cursor 必须是"本页返回的最后一条"的 id(下一页查 id < 它),不能用 rows[limit]—— - # rows[limit] 是探测下一页用的第 limit+1 条,它既不在本页也不在下页 → 每页边界丢一条。 - next_cursor = items[-1].id if has_more else None - return items, next_cursor + next_cursor = offset + limit if has_more else None + return items, next_cursor, total diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py new file mode 100644 index 0000000..4b1c7c3 --- /dev/null +++ b/app/admin/repositories/cps.py @@ -0,0 +1,517 @@ +"""admin CPS 数据访问:群/活动 CRUD + 美团订单拉单入库(对账) + 按群统计聚合。 + +美团 query_order 返回的金额是「元」字符串、时间是秒级时间戳,这里统一转「分」+ tz-aware。 +统计在 Python 侧聚合(订单量级小、admin 低频),逻辑清晰、跨 PG/SQLite 无 SQL 方言坑。 +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from uuid import uuid4 + +from sqlalchemy import desc, func, select +from sqlalchemy.orm import Session + +from app.admin.repositories.queries import _as_utc, offset_paginate +from app.integrations import meituan +from app.repositories import cps_link as cps_link_repo +from app.models.cps_activity import CpsActivity +from app.models.cps_group import CpsGroup +from app.models.cps_link import CpsClick +from app.models.cps_order import CpsOrder +from app.models.cps_wx_user import CpsWxUser + +# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账 +_INVALID_STATUS = {"4", "5"} +_SETTLED_STATUS = "6" + +# CPS 点击时序按北京时区分桶(运营看的是北京时间) +_BJ_TZ = timezone(timedelta(hours=8)) + + +# ───────────── 单位换算 ───────────── +def _yuan_to_cents(v: object) -> int | None: + """「元」字符串/数 → 分。None / 空 / 字面 "null" → None。""" + if v is None: + return None + s = str(v).strip() + if not s or s.lower() == "null": + return None + try: + return int((Decimal(s) * 100).to_integral_value()) + except (InvalidOperation, ValueError): + return None + + +def _ts_to_dt(ts: object) -> datetime | None: + """秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。""" + if not ts: + return None + try: + return datetime.fromtimestamp(int(ts), tz=timezone.utc) + except (ValueError, OSError, TypeError): + return None + + +# ───────────── 群 ───────────── +def get_group(db: Session, group_id: int) -> CpsGroup | None: + return db.get(CpsGroup, group_id) + + +def get_group_by_sid(db: Session, sid: str) -> CpsGroup | None: + return db.execute(select(CpsGroup).where(CpsGroup.sid == sid)).scalar_one_or_none() + + +def list_groups( + db: Session, *, keyword: str | None = None, status: str | None = None, + limit: int = 20, cursor: int | None = None, +) -> tuple[list[CpsGroup], int | None, int]: + stmt = select(CpsGroup) + if keyword and keyword.strip(): + kw = f"%{keyword.strip()}%" + stmt = stmt.where(CpsGroup.name.ilike(kw) | CpsGroup.sid.ilike(kw)) + if status: + stmt = stmt.where(CpsGroup.status == status) + return offset_paginate(db, stmt, (desc(CpsGroup.id),), limit=limit, cursor=cursor) + + +def create_group( + db: Session, *, name: str, platforms: list[str], sid: str | None = None, + member_count: int | None = None, remark: str | None = None, commit: bool = True, +) -> CpsGroup: + """建群。含 meituan 才有 sid(留空自动 g);纯淘宝/京东 sid=None(那俩不支持 sid)。""" + has_meituan = "meituan" in (platforms or []) + # 含美团:用填的 sid,或临时占位待回填 g;不含美团:sid 恒 None + seed_sid = (sid or f"tmp{uuid4().hex[:20]}") if has_meituan else None + group = CpsGroup( + name=name, + platforms=list(platforms or []), + sid=seed_sid, + member_count=member_count, + remark=remark, + ) + db.add(group) + db.flush() + if has_meituan and not sid: + group.sid = f"g{group.id}" + db.flush() + if commit: + db.commit() + db.refresh(group) + return group + + +def update_group( + db: Session, group: CpsGroup, *, name: str | None = None, + platforms: list[str] | None = None, + member_count: int | None = None, status: str | None = None, + remark: str | None = None, commit: bool = True, +) -> CpsGroup: + if name is not None: + group.name = name + if platforms is not None: + group.platforms = list(platforms) + if member_count is not None: + group.member_count = member_count + if status is not None: + group.status = status + if remark is not None: + group.remark = remark + if commit: + db.commit() + db.refresh(group) + else: + db.flush() + return group + + +def delete_group(db: Session, group: CpsGroup, *, commit: bool = True) -> None: + """硬删群。不级联删已生成的 cps_link(已发链接继续可用);该群历史点击/订单按 sid 仍在库。""" + db.delete(group) + if commit: + db.commit() + else: + db.flush() + + +# ───────────── 活动 ───────────── +def get_activity(db: Session, activity_id: int) -> CpsActivity | None: + return db.get(CpsActivity, activity_id) + + +def list_activities( + db: Session, *, platform: str | None = None, status: str | None = None, + limit: int = 20, cursor: int | None = None, +) -> tuple[list[CpsActivity], int | None, int]: + stmt = select(CpsActivity) + if platform: + stmt = stmt.where(CpsActivity.platform == platform) + if status: + stmt = stmt.where(CpsActivity.status == status) + return offset_paginate(db, stmt, (desc(CpsActivity.id),), limit=limit, cursor=cursor) + + +def list_activity_images(db: Session) -> list[str]: + """已用过的活动落地页图(distinct 非空 image_url),供新建活动复用选择。""" + rows = ( + db.execute( + select(CpsActivity.image_url) + .where(CpsActivity.image_url.is_not(None)) + .distinct() + ) + .scalars() + .all() + ) + return [r for r in rows if r] + + +def create_activity( + db: Session, *, name: str, platform: str = "meituan", act_id: str | None = None, + product_view_sign: str | None = None, payload: str | None = None, + image_url: str | None = None, remark: str | None = None, commit: bool = True, +) -> CpsActivity: + activity = CpsActivity( + name=name, platform=platform, act_id=act_id, + product_view_sign=product_view_sign, payload=payload, + image_url=image_url, remark=remark, + ) + db.add(activity) + if commit: + db.commit() + db.refresh(activity) + else: + db.flush() + return activity + + +def update_activity( + db: Session, activity: CpsActivity, *, name: str | None = None, + platform: str | None = None, act_id: str | None = None, + product_view_sign: str | None = None, payload: str | None = None, + image_url: str | None = None, remark: str | None = None, + status: str | None = None, commit: bool = True, +) -> CpsActivity: + if name is not None: + activity.name = name + if platform is not None: + activity.platform = platform + if act_id is not None: + activity.act_id = act_id + if product_view_sign is not None: + activity.product_view_sign = product_view_sign + if payload is not None: + activity.payload = payload + if image_url is not None: + activity.image_url = image_url + if remark is not None: + activity.remark = remark + if status is not None: + activity.status = status + if commit: + db.commit() + db.refresh(activity) + else: + db.flush() + return activity + + +def delete_activity(db: Session, activity: CpsActivity, *, commit: bool = True) -> None: + """硬删活动。不级联删 cps_link(已发链接继续可用;淘宝落地页缺活动时取图走兜底默认图)。""" + db.delete(activity) + if commit: + db.commit() + else: + db.flush() + + +# ───────────── 订单(对账) ───────────── +def _map_order_fields(r: dict) -> dict: + """美团 query_order 单条 dataList → CpsOrder 字段(金额转分、时间转 datetime)。""" + pn = r.get("productName") + if pn and len(pn) > 500: + pn = pn[:500] + return { + "sid": (r.get("sid") or None), + "act_id": str(r["actId"]) if r.get("actId") is not None else None, + "biz_line": r.get("businessLine"), + "trade_type": r.get("tradeType"), + "pay_price_cents": _yuan_to_cents(r.get("payPrice")), + "commission_cents": _yuan_to_cents(r.get("profit")), + "commission_rate": str(r["commissionRate"]) if r.get("commissionRate") is not None else None, + "refund_price_cents": _yuan_to_cents(r.get("refundPrice")), + "refund_profit_cents": _yuan_to_cents(r.get("refundProfit")), + "mt_status": str(r["status"]) if r.get("status") is not None else None, + "invalid_reason": (r.get("invalidReason") or None), + "product_name": pn or None, + "pay_time": _ts_to_dt(r.get("payTime")), + "mt_update_time": _ts_to_dt(r.get("updateTime")), + "raw": r, + } + + +def reconcile_orders( + db: Session, *, start_time: int, end_time: int, + query_time_type: int = 1, sid: str | None = None, max_pages: int = 200, +) -> dict: + """调美团 query_order 分页拉单 → 按 order_id upsert。返回 {fetched, inserted, updated, pages}。 + + 订单状态会随时间变(付款→完成→结算/退款),重复拉同一单则更新。max_pages 防异常死循环。 + """ + fetched = inserted = updated = pages = 0 + page = 1 + while page <= max_pages: + resp = meituan.query_order( + sid=sid, start_time=start_time, end_time=end_time, + query_time_type=query_time_type, page=page, limit=100, + ) + rows = ((resp.get("data") or {}).get("dataList")) or [] + if not rows: + break + pages += 1 + for r in rows: + order_id = str(r.get("orderId") or "").strip() + if not order_id: + continue + fetched += 1 + fields = _map_order_fields(r) + existing = db.execute( + select(CpsOrder).where(CpsOrder.order_id == order_id) + ).scalar_one_or_none() + if existing is None: + db.add(CpsOrder(order_id=order_id, **fields)) + inserted += 1 + else: + for k, v in fields.items(): + setattr(existing, k, v) + updated += 1 + if len(rows) < 100: + break + page += 1 + db.commit() + return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages} + + +def list_orders( + db: Session, *, sid: str | None = None, mt_status: str | None = None, + limit: int = 20, cursor: int | None = None, +) -> tuple[list[CpsOrder], int | None, int]: + stmt = select(CpsOrder) + if sid: + stmt = stmt.where(CpsOrder.sid == sid) + if mt_status: + stmt = stmt.where(CpsOrder.mt_status == mt_status) + return offset_paginate(db, stmt, (desc(CpsOrder.id),), limit=limit, cursor=cursor) + + +# ───────────── 统计(按群聚合) ───────────── +def group_stats( + db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None, +) -> list[dict]: + """按 sid 聚合订单 + join 群信息。已建但本期无单的活跃群也列出(全 0)。 + 未归群的 sid(历史/其它来源)单独成行 group_id=None。按预估佣金降序。 + """ + ostmt = select(CpsOrder) + if date_from is not None: + ostmt = ostmt.where(CpsOrder.pay_time >= _as_utc(date_from)) + if date_to is not None: + ostmt = ostmt.where(CpsOrder.pay_time <= _as_utc(date_to)) + orders_by_sid: dict[str | None, list[CpsOrder]] = {} + for o in db.execute(ostmt).scalars().all(): + orders_by_sid.setdefault(o.sid, []).append(o) + + clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to) + groups = list(db.execute(select(CpsGroup)).scalars().all()) + + def _order_agg(items: list[CpsOrder]) -> dict: + valid = [o for o in items if o.mt_status not in _INVALID_STATUS] + settled = [o for o in items if o.mt_status == _SETTLED_STATUS] + canceled = [o for o in items if o.mt_status in _INVALID_STATUS] + return { + "order_count": len(valid), + "settled_count": len(settled), + "canceled_count": len(canceled), + "gmv_cents": sum(o.pay_price_cents or 0 for o in valid), + "est_commission_cents": sum(o.commission_cents or 0 for o in valid), + "settled_commission_cents": sum(o.commission_cents or 0 for o in settled), + } + + # 淘宝/京东无法对账 → 对账字段全 None(前端显示 "-") + _no_recon = { + "order_count": None, "settled_count": None, "canceled_count": None, + "gmv_cents": None, "est_commission_cents": None, "settled_commission_cents": None, + } + + rows: list[dict] = [] + seen_sids: set[str] = set() + for g in groups: + if g.status != "active": + continue + c = clicks.get(g.id) or {} + row = { + "group_id": g.id, "sid": g.sid, "name": g.name, + "platforms": list(g.platforms or []), "member_count": g.member_count, + "click_pv": c.get("pv", 0), "click_uv": c.get("uv", 0), + "copy_pv": c.get("copy_pv", 0), "copy_uv": c.get("copy_uv", 0), + } + # 对账只对美团群(有 sid);淘宝/京东 → None + if "meituan" in (g.platforms or []) and g.sid: + row.update(_order_agg(orders_by_sid.get(g.sid, []))) + seen_sids.add(g.sid) + else: + row.update(_no_recon) + rows.append(row) + + # 未归群的历史美团 sid(如 wonderableai):单列,有对账无点击 + for sid, items in orders_by_sid.items(): + if sid is None or sid in seen_sids: + continue + row = { + "group_id": None, "sid": sid, "name": sid, "platforms": ["meituan"], + "member_count": None, "click_pv": 0, "click_uv": 0, "copy_pv": 0, "copy_uv": 0, + } + row.update(_order_agg(items)) + rows.append(row) + + # 佣金降序(None 当 0),次按点击 + rows.sort(key=lambda x: ((x["est_commission_cents"] or 0), x["click_pv"]), reverse=True) + return rows + + +def group_click_timeseries( + db: Session, *, group_id: int, granularity: str, + start: datetime, end: datetime, +) -> list[dict]: + """该群点击时序(北京时区分桶,granularity=day|hour)。 + + 每桶:click_pv/uv(visit)+copy_pv/uv;UV=distinct(ip,ua)。**生成连续桶(无数据补 0)**, + 让折线图 x 轴按所选范围铺满、不断裂(CPS 数据稀疏,补零是关键)。 + """ + rows = db.execute( + select(CpsClick.clicked_at, CpsClick.event_type, CpsClick.ip, CpsClick.ua) + .where(CpsClick.group_id == group_id) + .where(CpsClick.clicked_at >= _as_utc(start)) + .where(CpsClick.clicked_at <= _as_utc(end)) + ).all() + + agg: dict = {} # bucket_key -> {vp, vu(set), cp, cu(set)} + for clicked_at, event_type, ip, ua in rows: + # naive(本地 SQLite 读回无 tz)按 UTC 兜底,再转北京——与 queries._as_utc 约定一致, + # 否则 astimezone 会把 UTC 瞬刻当系统本地时区解释,分桶整体偏移。 + ca = clicked_at if clicked_at.tzinfo else clicked_at.replace(tzinfo=timezone.utc) + bj = ca.astimezone(_BJ_TZ) + key = bj.strftime("%Y-%m-%d") if granularity == "day" else bj.hour + b = agg.setdefault(key, {"vp": 0, "vu": set(), "cp": 0, "cu": set()}) + uid = f"{ip or ''}|{ua or ''}" + if event_type == "copy": + b["cp"] += 1 + b["cu"].add(uid) + else: + b["vp"] += 1 + b["vu"].add(uid) + + def _point(label: str, b: dict | None) -> dict: + return { + "label": label, + "click_pv": b["vp"] if b else 0, + "click_uv": len(b["vu"]) if b else 0, + "copy_pv": b["cp"] if b else 0, + "copy_uv": len(b["cu"]) if b else 0, + } + + points: list[dict] = [] + if granularity == "day": + cur = start.astimezone(_BJ_TZ).date() + last = end.astimezone(_BJ_TZ).date() + while cur <= last: + points.append(_point(cur.strftime("%m-%d"), agg.get(cur.strftime("%Y-%m-%d")))) + cur += timedelta(days=1) + else: + for h in range(24): + points.append(_point(f"{h:02d}:00", agg.get(h))) + return points + + +def group_order_daily( + db: Session, *, sid: str, start: datetime, end: datetime, +) -> dict[str, dict]: + """美团群订单按天(北京时区,key=YYYY-MM-DD)聚合。口径与 group_stats._order_agg 一致: + 取消(4)/风控(5)不计有效;结算只数 status=6。退款佣金取当天全部单的 refund_profit_cents 之和。 + 返回 {date: {order_count, canceled_count, gmv_cents, est_commission_cents, + refund_profit_cents, settled_commission_cents}};无单的天不在 dict 里(端点侧补零)。 + """ + rows = ( + db.execute( + select(CpsOrder) + .where(CpsOrder.sid == sid) + .where(CpsOrder.pay_time >= _as_utc(start)) + .where(CpsOrder.pay_time <= _as_utc(end)) + ) + .scalars() + .all() + ) + by_day: dict[str, list] = {} + for o in rows: + if not o.pay_time: + continue + pt = o.pay_time if o.pay_time.tzinfo else o.pay_time.replace(tzinfo=timezone.utc) + d = pt.astimezone(_BJ_TZ).strftime("%Y-%m-%d") + by_day.setdefault(d, []).append(o) + + out: dict[str, dict] = {} + for d, items in by_day.items(): + valid = [o for o in items if o.mt_status not in _INVALID_STATUS] + settled = [o for o in items if o.mt_status == _SETTLED_STATUS] + canceled = [o for o in items if o.mt_status in _INVALID_STATUS] + out[d] = { + "order_count": len(valid), + "canceled_count": len(canceled), + "gmv_cents": sum(o.pay_price_cents or 0 for o in valid), + "est_commission_cents": sum(o.commission_cents or 0 for o in valid), + "refund_profit_cents": sum(o.refund_profit_cents or 0 for o in items), + "settled_commission_cents": sum(o.commission_cents or 0 for o in settled), + } + return out + + +def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict]: + """群内微信用户(首次来源 first_group_id=group_id)+ 每人在该群的 visit/copy 次数(领券画像)。 + + 按首次进入倒序。copy 次数 = 该用户在该群点了几次「复制口令」(领券意向),visit = 进落地页次数。 + """ + users = ( + db.execute( + select(CpsWxUser) + .where(CpsWxUser.first_group_id == group_id) + .order_by(desc(CpsWxUser.first_seen)) + .limit(limit) + ) + .scalars() + .all() + ) + if not users: + return [] + openids = [u.openid for u in users] + stat: dict[str, dict] = {} + rows = db.execute( + select(CpsClick.openid, CpsClick.event_type, func.count()) + .where(CpsClick.group_id == group_id) + .where(CpsClick.openid.in_(openids)) + .group_by(CpsClick.openid, CpsClick.event_type) + ).all() + for openid, event_type, cnt in rows: + s = stat.setdefault(openid, {"visit": 0, "copy": 0}) + if event_type == "copy": + s["copy"] = cnt + else: + s["visit"] = cnt + return [ + { + "openid": u.openid, + "nickname": u.nickname, + "headimgurl": u.headimgurl, + "first_seen": u.first_seen, + "visit_count": stat.get(u.openid, {}).get("visit", 0), + "copy_count": stat.get(u.openid, {}).get("copy", 0), + } + for u in users + ] diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index e74f733..4e7f2d2 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -51,15 +51,48 @@ def cursor_paginate( return items, next_cursor +def offset_paginate( + db: Session, stmt: Select, sort_clause: tuple, *, limit: int, cursor: int | None +) -> tuple[list, int | None, int]: + """offset 分页 + 总数。stmt 只含 where/join,不要预先带 order_by/offset/limit。 + + cursor 即 offset(页码分页:offset=(page-1)*pageSize)。返回 (items, next_cursor, total): + - total:符合筛选条件的总条数(供 antd pagination 渲染页码/共 N 条),count 在 P0 量级开销可忽略; + - next_cursor:下一页 offset(兼容「加载更多」),末页为 None。 + 多取 1 条探测下一页。sort_clause 为 order_by 表达式元组(末位应含 id 保证稳定排序)。""" + total = int( + db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one() + ) + offset = max(cursor or 0, 0) + rows = list( + db.execute(stmt.order_by(*sort_clause).offset(offset).limit(limit + 1)).scalars().all() + ) + has_more = len(rows) > limit + items = rows[:limit] + next_cursor = offset + limit if has_more else None + return items, next_cursor, total + + def list_users( db: Session, *, phone: str | None = None, register_channel: str | None = None, status: str | None = None, + nickname: str | None = None, + created_from: datetime | None = None, + created_to: datetime | None = None, + last_login_from: datetime | None = None, + last_login_to: datetime | None = None, + sort_by: str = "id", + sort_order: str = "desc", limit: int = 20, cursor: int | None = None, -) -> tuple[list[User], int | None]: +) -> tuple[list[User], int | None, int]: + """用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选, + 按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一, + 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。 + 日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。""" stmt = select(User) if phone: stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配 @@ -67,7 +100,83 @@ def list_users( stmt = stmt.where(User.register_channel == register_channel) if status: stmt = stmt.where(User.status == status) - return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor) + if nickname and nickname.strip(): + stmt = stmt.where(User.nickname.ilike(f"%{nickname.strip()}%")) + if created_from is not None: + stmt = stmt.where(User.created_at >= _as_utc(created_from)) + if created_to is not None: + stmt = stmt.where(User.created_at <= _as_utc(created_to)) + if last_login_from is not None: + stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from)) + if last_login_to is not None: + stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to)) + + sort_cols = { + "id": User.id, + "created_at": User.created_at, + "last_login_at": User.last_login_at, + } + sort_col = sort_cols.get(sort_by, User.id) + order_fn = asc if sort_order == "asc" else desc + id_order = asc(User.id) if sort_order == "asc" else desc(User.id) + return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) + + +def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None: + """给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。""" + uids = {r.user_id for r in records} + if not uids: + return + rows = db.execute( + select(User.id, User.phone, User.nickname).where(User.id.in_(uids)) + ).all() + umap = {uid: (phone, nick) for uid, phone, nick in rows} + for r in records: + phone, nick = umap.get(r.user_id, (None, None)) + r.phone = phone + r.nickname = nick + + +def list_comparison_records( + db: Session, + *, + user_id: int | None = None, + phone: str | None = None, + status: str | None = None, + business_type: str | None = None, + limit: int = 20, + cursor: int | None = None, +) -> tuple[list[ComparisonRecord], int | None, int]: + """admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛, + offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。""" + stmt = select(ComparisonRecord) + if user_id is not None: + stmt = stmt.where(ComparisonRecord.user_id == user_id) + if phone: + stmt = stmt.where( + ComparisonRecord.user_id.in_( + select(User.id).where(User.phone.like(f"{phone}%")) + ) + ) + if status: + stmt = stmt.where(ComparisonRecord.status == status) + if business_type: + stmt = stmt.where(ComparisonRecord.business_type == business_type) + items, next_cursor, total = offset_paginate( + db, stmt, + (desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)), + limit=limit, cursor=cursor, + ) + _attach_user_info(db, items) + return items, next_cursor, total + + +def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None: + """admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。""" + rec = db.get(ComparisonRecord, record_id) + if rec is not None: + _attach_user_info(db, [rec]) + return rec def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]: @@ -136,7 +245,7 @@ def list_all_withdraw_orders( quick_filter: str | None = None, limit: int = 20, cursor: int | None = None, -) -> tuple[list[WithdrawOrder], int | None]: +) -> tuple[list[WithdrawOrder], int | None, int]: stmt = select(WithdrawOrder) needs_user_join = bool(keyword and keyword.strip()) or quick_filter == "high_risk" if needs_user_join: @@ -166,16 +275,16 @@ def list_all_withdraw_orders( date_col = WithdrawOrder.updated_at if date_field == "updated_at" else WithdrawOrder.created_at if date_from is not None: - stmt = stmt.where(date_col >= _as_utc_naive(date_from)) + stmt = stmt.where(date_col >= _as_utc(date_from)) if date_to is not None: - stmt = stmt.where(date_col <= _as_utc_naive(date_to)) + stmt = stmt.where(date_col <= _as_utc(date_to)) - now = datetime.now(timezone.utc).replace(tzinfo=None) + # tz-aware:列为 timestamptz,比较绝对时刻、与 DB 会话时区无关(同 _as_utc / stats.py) + now = datetime.now(timezone.utc) today_start = ( datetime.now(ZoneInfo("Asia/Shanghai")) .replace(hour=0, minute=0, second=0, microsecond=0) .astimezone(timezone.utc) - .replace(tzinfo=None) ) if quick_filter == "abnormal": stmt = stmt.where( @@ -220,21 +329,19 @@ def list_all_withdraw_orders( sort_col = sort_cols.get(sort_by, WithdrawOrder.created_at) order_fn = asc if sort_order == "asc" else desc id_order = asc(WithdrawOrder.id) if sort_order == "asc" else desc(WithdrawOrder.id) - stmt = stmt.order_by(order_fn(sort_col), id_order) - - offset = max(cursor or 0, 0) - rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all()) - has_more = len(rows) > limit - items = rows[:limit] - next_cursor = offset + limit if has_more else None - return items, next_cursor + return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) -def _as_utc_naive(value: datetime) -> datetime: - """前端传 ISO 时间;DB 当前按 UTC naive 比较最稳(SQLite/本地开发一致)。""" +def _as_utc(value: datetime) -> datetime: + """前端传 ISO 时间 → 统一成 tz-aware UTC 再比较。 + + 所有时间列均为 `DateTime(timezone=True)`(Postgres timestamptz);用 tz-aware 绑定参数 + 比较的是绝对时刻,与 DB 会话时区无关、恒正确。曾用 naive UTC,正确性依赖会话 TimeZone=UTC, + 生产会话非 UTC 时筛选边界会整体偏移——故统一 tz-aware(与 stats.py / withdraw_summary 一致)。 + 无时区入参按 UTC 解释。""" if value.tzinfo is None: - return value - return value.astimezone(timezone.utc).replace(tzinfo=None) + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) def withdraw_list_enrichment(db: Session, user_ids: list[int]) -> dict[int, dict]: @@ -274,15 +381,35 @@ def list_feedbacks( *, status: str | None = None, user_id: int | None = None, + content: str | None = None, + created_from: datetime | None = None, + created_to: datetime | None = None, + sort_by: str = "id", + sort_order: str = "desc", limit: int = 20, cursor: int | None = None, -) -> tuple[list[Feedback], int | None]: +) -> tuple[list[Feedback], int | None, int]: + """反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。 + **offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间 + 数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。 + created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。""" stmt = select(Feedback) if status: stmt = stmt.where(Feedback.status == status) if user_id is not None: stmt = stmt.where(Feedback.user_id == user_id) - return cursor_paginate(db, stmt, Feedback.id, limit=limit, cursor=cursor) + if content and content.strip(): + stmt = stmt.where(Feedback.content.ilike(f"%{content.strip()}%")) + if created_from is not None: + stmt = stmt.where(Feedback.created_at >= _as_utc(created_from)) + if created_to is not None: + stmt = stmt.where(Feedback.created_at <= _as_utc(created_to)) + + sort_cols = {"id": Feedback.id, "created_at": Feedback.created_at} + sort_col = sort_cols.get(sort_by, Feedback.id) + order_fn = asc if sort_order == "asc" else desc + id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id) + return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None: @@ -641,14 +768,14 @@ def list_price_reports( user_id: int | None = None, limit: int = 20, cursor: int | None = None, -) -> tuple[list[PriceReport], int | None]: - """上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。""" +) -> tuple[list[PriceReport], int | None, int]: + """上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,id 倒序。""" stmt = select(PriceReport) if status: stmt = stmt.where(PriceReport.status == status) if user_id is not None: stmt = stmt.where(PriceReport.user_id == user_id) - return cursor_paginate(db, stmt, PriceReport.id, limit=limit, cursor=cursor) + return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor) def price_report_summary(db: Session) -> dict: diff --git a/app/admin/routers/ad_audit.py b/app/admin/routers/ad_audit.py index fd4b057..69f98f3 100644 --- a/app/admin/routers/ad_audit.py +++ b/app/admin/routers/ad_audit.py @@ -29,16 +29,20 @@ def get_ad_coin_audit( str | None, Query(description="reward_video / feed;不传=两类都要") ] = None, limit: Annotated[int, Query(ge=1, le=500)] = 100, + only_mismatch: Annotated[ + bool, Query(description="只看不一致(✗)行;统计数仍按全量,不受影响") + ] = False, ) -> AdCoinAuditOut: audit_date = date or cn_today().isoformat() - rows = ad_audit.ad_coin_audit( + result = ad_audit.ad_coin_audit( db, date=audit_date, user_id=user_id, scene=scene, limit=limit, + only_mismatch=only_mismatch, ) - items = [AdCoinAuditRow(**r) for r in rows] return AdCoinAuditOut( date=audit_date, formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()), - total=len(items), - mismatch_count=sum(1 for it in items if not it.matched), - items=items, + total=result["total"], + mismatch_count=result["mismatch_count"], + truncated=result["truncated"], + items=[AdCoinAuditRow(**r) for r in result["items"]], ) diff --git a/app/admin/routers/ad_config.py b/app/admin/routers/ad_config.py new file mode 100644 index 0000000..c46068c --- /dev/null +++ b/app/admin/routers/ad_config.py @@ -0,0 +1,45 @@ +"""admin 广告配置:读/写穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。 + +存在 app_config 表的 ad_config dict(见 repositories/app_config.get_ad_config/set_ad_config)。 +客户端经 /api/v1/platform/ad-config 拉取(不含 reward_mkey)。权限 operator/finance + 审计。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, 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.ad_config import AdConfigOut, AdConfigUpdate +from app.models.admin import AdminUser +from app.repositories import app_config + +router = APIRouter( + prefix="/admin/api/ad-config", + tags=["admin-ad-config"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("", response_model=AdConfigOut, summary="广告配置(穿山甲ID/验签密钥/各场景开关)") +def get_ad_config(db: AdminDb) -> AdConfigOut: + return AdConfigOut(**app_config.get_ad_config(db)) + + +@router.patch("", response_model=AdConfigOut, summary="改广告配置(部分更新,带审计)") +def update_ad_config( + body: AdConfigUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))], + db: AdminDb, +) -> AdConfigOut: + data = body.model_dump(exclude_none=True) + app_config.set_ad_config(db, data, admin_id=admin.id, commit=False) + # 审计只记改了哪些字段,不记 mkey 明文(机密) + write_audit( + db, admin, action="ad_config.set", target_type="ad_config", target_id="ad_config", + detail={"changed": sorted(data.keys())}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return AdConfigOut(**app_config.get_ad_config(db)) diff --git a/app/admin/routers/ad_revenue.py b/app/admin/routers/ad_revenue.py new file mode 100644 index 0000000..1a67270 --- /dev/null +++ b/app/admin/routers/ad_revenue.py @@ -0,0 +1,75 @@ +"""admin 广告收益报表:按 用户/日期/广告类型/应用/代码位 聚合 展示条数 / 收益 / 金币。 + +任意已登录 admin 可看(只读,不涉及资金操作)。聚合逻辑在 app/admin/repositories/ad_revenue.py。 +""" +from __future__ import annotations + +from datetime import date as _date +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.admin.deps import AdminDb, get_current_admin +from app.admin.repositories import ad_revenue +from app.admin.schemas.ad_revenue import AdRevenueDaily, AdRevenueReportOut, AdRevenueRow +from app.core.rewards import cn_today + +router = APIRouter( + prefix="/admin/api/ad-revenue-report", + tags=["admin-ad-revenue-report"], + dependencies=[Depends(get_current_admin)], +) + +# 区间最大跨度(天);超出拒绝,避免审计页一次拉过多天(逐日审计 + 大查询)拖垮接口。 +_MAX_RANGE_DAYS = 92 + + +def _parse_day(value: str | None, *, field: str, default: _date) -> _date: + if value is None: + return default + try: + return _date.fromisoformat(value) + except ValueError as e: + raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e + + +@router.get("", response_model=AdRevenueReportOut, summary="广告收益报表(按 日期区间/用户/类型/应用/代码位 聚合)") +def get_ad_revenue_report( + db: AdminDb, + date_from: Annotated[str | None, Query(description="起始日 北京时间 YYYY-MM-DD,默认今天")] = None, + date_to: Annotated[str | None, Query(description="结束日 北京时间 YYYY-MM-DD,闭区间,默认=date_from")] = None, + user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None, + ad_type: Annotated[ + str | None, + Query(description="reward_video / feed / draw;不传=全部类型"), + ] = None, + granularity: Annotated[ + str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day") + ] = "day", + limit: Annotated[int, Query(ge=1, le=1000)] = 500, +) -> AdRevenueReportOut: + today = cn_today() + d_from = _parse_day(date_from, field="date_from", default=today) + d_to = _parse_day(date_to, field="date_to", default=d_from) + if d_to < d_from: + raise HTTPException(status_code=422, detail="date_to 不能早于 date_from") + if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS: + raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS} 天") + + result = ad_revenue.ad_revenue_report( + db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), + user_id=user_id, ad_type=ad_type, granularity=granularity, limit=limit, + ) + return AdRevenueReportOut( + date_from=d_from.isoformat(), + date_to=d_to.isoformat(), + daily=[AdRevenueDaily(**d) for d in result["daily"]], + total=result["total"], + truncated=result["truncated"], + total_impressions=result["total_impressions"], + total_revenue_yuan=result["total_revenue_yuan"], + total_expected_coin=result["total_expected_coin"], + total_actual_coin=result["total_actual_coin"], + mismatch_count=result["mismatch_count"], + items=[AdRevenueRow(**r) for r in result["items"]], + ) diff --git a/app/admin/routers/admins.py b/app/admin/routers/admins.py index c3b8483..9f7ac15 100644 --- a/app/admin/routers/admins.py +++ b/app/admin/routers/admins.py @@ -17,6 +17,12 @@ router = APIRouter( ) +def _active_super_count(db: AdminDb) -> int: + return sum( + 1 for a in admin_repo.list_admins(db) if a.role == "super_admin" and a.status == "active" + ) + + @router.get("", response_model=list[AdminOut], summary="管理员列表") def list_admins(db: AdminDb) -> list[AdminOut]: return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)] @@ -48,16 +54,29 @@ def update_admin( if admin_id == admin.id and body.status == "disabled": raise HTTPException(status_code=400, detail="不能禁用自己") + # 防自锁:降级 / 禁用某个 super_admin 前,确认操作后仍至少剩 1 个 active super_admin, + # 否则会进入「零可用超管」死局——本路由仅 super 可进,只能改库恢复。 + demotes_super = ( + target.role == "super_admin" + and target.status == "active" + and ( + (body.role is not None and body.role != "super_admin") + or body.status == "disabled" + ) + ) + if demotes_super and _active_super_count(db) <= 1: + raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员") + changes: dict = {} - if body.role is not None: + if body.role is not None and body.role != target.role: + changes["role"] = {"before": target.role, "after": body.role} target.role = body.role - changes["role"] = body.role - if body.status is not None: + if body.status is not None and body.status != target.status: + changes["status"] = {"before": target.status, "after": body.status} target.status = body.status - changes["status"] = body.status if body.password is not None: - target.password_hash = hash_password(body.password) changes["password"] = "reset" + target.password_hash = hash_password(body.password) if not changes: raise HTTPException(status_code=400, detail="无任何变更字段") db.commit() diff --git a/app/admin/routers/audit.py b/app/admin/routers/audit.py index 580e03c..dd7cace 100644 --- a/app/admin/routers/audit.py +++ b/app/admin/routers/audit.py @@ -26,9 +26,11 @@ def list_audit_logs( limit: Annotated[int, Query(ge=1, le=100)] = 50, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[AdminAuditLogOut]: - items, next_cursor = audit_repo.list_audit_logs( + items, next_cursor, total = audit_repo.list_audit_logs( db, action=action, target_type=target_type, admin_id=admin_id, limit=limit, cursor=cursor, ) return CursorPage( - items=[AdminAuditLogOut.model_validate(x) for x in items], next_cursor=next_cursor, + items=[AdminAuditLogOut.model_validate(x) for x in items], + next_cursor=next_cursor, + total=total, ) diff --git a/app/admin/routers/comparison.py b/app/admin/routers/comparison.py new file mode 100644 index 0000000..9aeb859 --- /dev/null +++ b/app/admin/routers/comparison.py @@ -0,0 +1,58 @@ +"""admin 比价记录 debug:按 user_id / phone 查列表 + 单条详情(含 LLM 每次调用明细 / 原始 payload)。 + +只读。读权限默认即可(get_current_admin),不额外 require_role(查记录是基础 debug 能力)。 +trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.debug_trace_enabled 权限闸。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.admin.deps import AdminDb, get_current_admin +from app.admin.repositories import queries +from app.admin.schemas.common import CursorPage +from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem + +router = APIRouter( + prefix="/admin/api/comparison-records", + tags=["admin-comparison"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get( + "", + response_model=CursorPage[AdminComparisonListItem], + summary="比价记录列表(按 user_id/phone 筛 + 分页)", +) +def list_comparison_records( + db: AdminDb, + user_id: Annotated[int | None, Query()] = None, + phone: Annotated[str | None, Query(description="手机号前缀")] = None, + status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None, + business_type: Annotated[str | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[AdminComparisonListItem]: + items, next_cursor, total = queries.list_comparison_records( + db, user_id=user_id, phone=phone, status=status, + business_type=business_type, limit=limit, cursor=cursor, + ) + return CursorPage( + items=[AdminComparisonListItem.model_validate(r) for r in items], + next_cursor=next_cursor, + total=total, + ) + + +@router.get( + "/{record_id}", + response_model=AdminComparisonDetail, + summary="比价记录详情(含 LLM 每次调用明细 + 原始 payload)", +) +def get_comparison_record(record_id: int, db: AdminDb) -> AdminComparisonDetail: + rec = queries.get_comparison_record(db, record_id) + if rec is None: + raise HTTPException(status_code=404, detail="记录不存在") + return AdminComparisonDetail.model_validate(rec) diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index 6ab6c41..8a65c61 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -13,6 +13,7 @@ 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.config import ConfigItemOut, ConfigUpdateRequest from app.core.config_schema import CONFIG_DEFS +from app.core.rewards import SIGNIN_CYCLE_LEN from app.models.admin import AdminUser from app.repositories import app_config @@ -34,14 +35,17 @@ 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) != 14: - raise ValueError("签到档位必须正好 14 个(对应 14 天循环)") + if key == "signin_rewards" and len(value) != SIGNIN_CYCLE_LEN: + raise ValueError(f"签到档位必须正好 {SIGNIN_CYCLE_LEN} 个(对应 {SIGNIN_CYCLE_LEN} 天循环)") 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) for k, v in value.items() ): raise ValueError("需为 {字符串: 整数} 映射") + elif t == "bool": + if not isinstance(value, bool): + raise ValueError("需为布尔值") def _item(db, key: str) -> ConfigItemOut: diff --git a/app/admin/routers/cps.py b/app/admin/routers/cps.py new file mode 100644 index 0000000..a1fd267 --- /dev/null +++ b/app/admin/routers/cps.py @@ -0,0 +1,498 @@ +"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。 + +平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。 +淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。 +群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。 +""" +from __future__ import annotations + +import time +from datetime import datetime, timedelta, timezone +from typing import Annotated + +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.repositories import cps as cps_repo +from app.admin.schemas.common import CursorPage +from app.admin.schemas.cps import ( + CpsActivityCreate, + CpsActivityOut, + CpsActivityUpdate, + CpsGroupCreate, + CpsGroupOut, + CpsGroupStat, + CpsGroupUpdate, + CpsOrderOut, + CpsReconcileResult, + CpsReferralLinkItem, + CpsReferralLinksOut, + CpsReferralLinksRequest, + CpsStatsOut, +) +from app.core import media +from app.core.config import settings +from app.integrations import meituan +from app.integrations.meituan import MeituanCpsError +from app.models.admin import AdminUser +from app.models.cps_activity import CpsActivity +from app.models.cps_group import CpsGroup +from app.repositories import cps_link as cps_link_repo + +router = APIRouter( + prefix="/admin/api/cps", + tags=["admin-cps"], + dependencies=[Depends(get_current_admin)], +) + +_VALID_PLATFORMS = {"meituan", "taobao", "jd"} + + +# ───────────── 群 ───────────── +@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群列表") +def list_groups( + db: AdminDb, + keyword: Annotated[str | None, Query(max_length=100)] = None, + status: Annotated[str | None, Query(pattern="^(active|archived)$")] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[CpsGroupOut]: + items, next_cursor, total = cps_repo.list_groups( + db, keyword=keyword, status=status, limit=limit, cursor=cursor + ) + return CursorPage( + items=[CpsGroupOut.model_validate(g) for g in items], + next_cursor=next_cursor, + total=total, + ) + + +@router.post("/groups", response_model=CpsGroupOut, summary="新建群") +def create_group( + body: CpsGroupCreate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> CpsGroupOut: + bad = [p for p in body.platforms if p not in _VALID_PLATFORMS] + if bad: + raise HTTPException(status_code=400, detail=f"非法平台: {bad}") + has_meituan = "meituan" in body.platforms + if has_meituan and body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None: + raise HTTPException(status_code=409, detail="sid 已存在") + group = cps_repo.create_group( + db, name=body.name, platforms=body.platforms, sid=body.sid, + member_count=body.member_count, remark=body.remark, commit=False, + ) + write_audit( + db, admin, action="cps.group.create", target_type="cps_group", target_id=group.id, + detail={"name": group.name, "platforms": group.platforms, "sid": group.sid}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(group) + return CpsGroupOut.model_validate(group) + + +@router.patch("/groups/{group_id}", response_model=CpsGroupOut, summary="编辑群") +def update_group( + group_id: int, + body: CpsGroupUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> CpsGroupOut: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + if body.platforms is not None: + bad = [p for p in body.platforms if p not in _VALID_PLATFORMS] + if bad: + raise HTTPException(status_code=400, detail=f"非法平台: {bad}") + cps_repo.update_group( + db, group, name=body.name, platforms=body.platforms, + member_count=body.member_count, status=body.status, remark=body.remark, commit=False, + ) + write_audit( + db, admin, action="cps.group.update", target_type="cps_group", target_id=group_id, + detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(group) + return CpsGroupOut.model_validate(group) + + +@router.delete("/groups/{group_id}", summary="删除群") +def delete_group( + group_id: int, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> dict: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + write_audit( + db, admin, action="cps.group.delete", target_type="cps_group", target_id=group_id, + detail={"name": group.name, "sid": group.sid, "platforms": list(group.platforms or [])}, + ip=get_client_ip(request), commit=False, + ) + cps_repo.delete_group(db, group, commit=False) + db.commit() + return {"ok": True} + + +# ───────────── 活动 ───────────── +@router.get("/activities", response_model=CursorPage[CpsActivityOut], summary="活动列表") +def list_activities( + db: AdminDb, + platform: Annotated[str | None, Query(max_length=20)] = None, + status: Annotated[str | None, Query(pattern="^(active|archived)$")] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[CpsActivityOut]: + items, next_cursor, total = cps_repo.list_activities( + db, platform=platform, status=status, limit=limit, cursor=cursor + ) + return CursorPage( + items=[CpsActivityOut.model_validate(a) for a in items], + next_cursor=next_cursor, + total=total, + ) + + +@router.post("/activities", response_model=CpsActivityOut, summary="新建活动") +def create_activity( + body: CpsActivityCreate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> CpsActivityOut: + if body.platform == "meituan": + if not body.act_id and not body.product_view_sign: + raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign") + else: # taobao / jd + if not body.payload: + label = "淘口令" if body.platform == "taobao" else "推广链接" + raise HTTPException(status_code=400, detail=f"{body.platform} 活动需填{label}") + if body.platform == "taobao" and not body.image_url: + raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)") + activity = cps_repo.create_activity( + db, name=body.name, platform=body.platform, act_id=body.act_id, + product_view_sign=body.product_view_sign, payload=body.payload, + image_url=media.to_abs_media_url(body.image_url), remark=body.remark, commit=False, + ) + write_audit( + db, admin, action="cps.activity.create", target_type="cps_activity", target_id=activity.id, + detail={"name": activity.name, "platform": activity.platform}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(activity) + return CpsActivityOut.model_validate(activity) + + +@router.patch("/activities/{activity_id}", response_model=CpsActivityOut, summary="编辑活动") +def update_activity( + activity_id: int, + body: CpsActivityUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> CpsActivityOut: + activity = cps_repo.get_activity(db, activity_id) + if activity is None: + raise HTTPException(status_code=404, detail="活动不存在") + # 用「合并后最终值」校验平台必填项(同新建口径),允许只改部分字段 + platform = body.platform or activity.platform + act_id = body.act_id if body.act_id is not None else activity.act_id + pvs = body.product_view_sign if body.product_view_sign is not None else activity.product_view_sign + payload = body.payload if body.payload is not None else activity.payload + image_url = body.image_url if body.image_url is not None else activity.image_url + if platform == "meituan": + if not act_id and not pvs: + raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign") + else: # taobao / jd + if not payload: + label = "淘口令" if platform == "taobao" else "推广链接" + raise HTTPException(status_code=400, detail=f"{platform} 活动需填{label}") + if platform == "taobao" and not image_url: + raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)") + cps_repo.update_activity( + db, activity, name=body.name, platform=body.platform, act_id=body.act_id, + product_view_sign=body.product_view_sign, payload=body.payload, + image_url=media.to_abs_media_url(body.image_url) if body.image_url else None, + remark=body.remark, status=body.status, commit=False, + ) + write_audit( + db, admin, action="cps.activity.update", target_type="cps_activity", target_id=activity_id, + detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(activity) + return CpsActivityOut.model_validate(activity) + + +@router.post("/upload-image", summary="上传活动落地页图(返回绝对 URL)") +async def upload_activity_image( + admin: Annotated[AdminUser, Depends(require_role("operator"))], + file: UploadFile = File(..., description="落地页图(jpeg/png/webp,≤5MB)"), +) -> dict: + data = await file.read() + try: + url = media.save_cps_image(admin.id, data) + except media.MediaError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"url": media.to_abs_media_url(url)} + + +@router.get("/activity-images", summary="已有活动落地页图(供新建复用选择)") +def activity_images(db: AdminDb) -> dict: + return {"images": cps_repo.list_activity_images(db)} + + +@router.delete("/activities/{activity_id}", summary="删除活动") +def delete_activity( + activity_id: int, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> dict: + activity = cps_repo.get_activity(db, activity_id) + if activity is None: + raise HTTPException(status_code=404, detail="活动不存在") + write_audit( + db, admin, action="cps.activity.delete", target_type="cps_activity", target_id=activity_id, + detail={"name": activity.name, "platform": activity.platform}, + ip=get_client_ip(request), commit=False, + ) + cps_repo.delete_activity(db, activity, commit=False) + db.commit() + return {"ok": True} + + +# ───────────── 生成链接(批量,按平台分支) ───────────── +def _gen_one_link(db, group: CpsGroup, activity: CpsActivity): + """给一个活动生成一条落地页 link。美团:转链拿短链(需群 sid);淘宝/京东:用 payload。""" + if activity.platform == "meituan": + if not group.sid: + raise HTTPException(status_code=400, detail=f"群「{group.name}」无 sid,无法生成美团链接") + try: + resp = meituan.get_referral_link( + act_id=activity.act_id, product_view_sign=activity.product_view_sign, + sid=group.sid, link_type_list=[1, 2, 3], + ) + except MeituanCpsError as e: + raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e + link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()} + if not link_map and resp.get("data"): + link_map = {"1": resp["data"]} + target = link_map.get("2") or link_map.get("1") or link_map.get("3") + if not target: + raise HTTPException(status_code=502, detail=f"美团未返回有效链接(活动「{activity.name}」)") + else: # taobao(淘口令) / jd(链接) + if not activity.payload: + raise HTTPException(status_code=400, detail=f"活动「{activity.name}」缺少内容") + target = activity.payload + return cps_link_repo.create_link( + db, group_id=group.id, activity_id=activity.id, sid=group.sid, + target_url=target, platform=activity.platform, commit=False, + ) + + +@router.post("/referral-links", response_model=CpsReferralLinksOut, summary="批量生成落地页短链") +def generate_referral_links( + body: CpsReferralLinksRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> CpsReferralLinksOut: + group = cps_repo.get_group(db, body.group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + group_platforms = set(group.platforms or []) + base = settings.CPS_REDIRECT_BASE.rstrip("/") + + items: list[CpsReferralLinkItem] = [] + for aid in body.activity_ids: + activity = cps_repo.get_activity(db, aid) + if activity is None: + raise HTTPException(status_code=404, detail=f"活动 {aid} 不存在") + if activity.platform not in group_platforms: + raise HTTPException( + status_code=400, + detail=f"活动「{activity.name}」平台({activity.platform})不在群「{group.name}」范围内", + ) + link = _gen_one_link(db, group, activity) + items.append(CpsReferralLinkItem( + activity_id=activity.id, activity_name=activity.name, platform=activity.platform, + redirect_url=f"{base}/c/{link.code}" if base else f"/c/{link.code}", code=link.code, + )) + + write_audit( + db, admin, action="cps.referral_link.generate", target_type="cps_group", target_id=group.id, + detail={"group": group.name, "activity_ids": body.activity_ids, "count": len(items)}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + return CpsReferralLinksOut(group_name=group.name, results=items) + + +# ───────────── 订单对账 ───────────── +@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账") +def reconcile_orders( + request: Request, + admin: Annotated[AdminUser, Depends(require_role("finance"))], + db: AdminDb, + days: Annotated[int, Query(ge=1, le=90)] = 7, + sid: Annotated[str | None, Query(max_length=64)] = None, +) -> CpsReconcileResult: + now = int(time.time()) + try: + result = cps_repo.reconcile_orders( + db, start_time=now - days * 86400, end_time=now, sid=sid, + ) + except MeituanCpsError as e: + raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e + write_audit( + db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None, + detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True, + ) + return CpsReconcileResult(**result) + + +@router.get("/orders", response_model=CursorPage[CpsOrderOut], summary="订单明细") +def list_orders( + db: AdminDb, + sid: Annotated[str | None, Query(max_length=64)] = None, + mt_status: Annotated[str | None, Query(max_length=8)] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[CpsOrderOut]: + items, next_cursor, total = cps_repo.list_orders( + db, sid=sid, mt_status=mt_status, limit=limit, cursor=cursor + ) + return CursorPage( + items=[CpsOrderOut.model_validate(o) for o in items], + next_cursor=next_cursor, + total=total, + ) + + +# ───────────── 统计 ───────────── +@router.get("/stats", response_model=CpsStatsOut, summary="按群对账统计") +def get_stats( + db: AdminDb, + days: Annotated[int, Query(ge=1, le=90)] = 30, +) -> CpsStatsOut: + date_to = datetime.now(timezone.utc) + date_from = date_to - timedelta(days=days) + rows = cps_repo.group_stats(db, date_from=date_from, date_to=date_to) + stats = [CpsGroupStat(**r) for r in rows] + return CpsStatsOut( + groups=stats, + total_order_count=sum(s.order_count or 0 for s in stats), + total_est_commission_cents=sum(s.est_commission_cents or 0 for s in stats), + total_settled_commission_cents=sum(s.settled_commission_cents or 0 for s in stats), + ) + + +@router.get("/groups/{group_id}/timeseries", summary="群点击时序(折线图:天/小时级 PV/UV/复制)") +def group_timeseries( + group_id: int, + db: AdminDb, + granularity: Annotated[str, Query(pattern="^(day|hour)$")] = "day", + days: Annotated[int, Query(ge=1, le=30)] = 7, + date: Annotated[str | None, Query()] = None, # hour 粒度看哪天(YYYY-MM-DD 北京),默认今天 +) -> dict: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + bj = timezone(timedelta(hours=8)) + now_bj = datetime.now(bj) + if granularity == "hour": + if date: + try: + day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj) + except ValueError as e: + raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e + else: + day0 = now_bj + start = day0.replace(hour=0, minute=0, second=0, microsecond=0) + end = start + timedelta(days=1) - timedelta(microseconds=1) + else: + start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0) + end = now_bj + points = cps_repo.group_click_timeseries( + db, group_id=group_id, granularity=granularity, start=start, end=end + ) + return { + "group_id": group.id, + "group_name": group.name, + "granularity": granularity, + "points": points, + } + + +@router.get("/groups/{group_id}/daily", summary="群每天明细大表格(点击+订单按天合并)") +def group_daily( + group_id: int, + db: AdminDb, + days: Annotated[int, Query(ge=1, le=90)] = 7, +) -> dict: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + bj = timezone(timedelta(hours=8)) + now_bj = datetime.now(bj) + start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0) + end = now_bj + # 点击侧:连续 N 天补零,顺序 = start..end(与下面 while 同序,按 index 对齐避免跨年 MM-DD 碰撞) + click_points = cps_repo.group_click_timeseries( + db, group_id=group_id, granularity="day", start=start, end=end + ) + # 订单侧:仅美团群(有 sid)有对账;淘宝/京东订单列返回 None(前端显示 "-") + is_meituan = "meituan" in (group.platforms or []) and bool(group.sid) + order_daily = ( + cps_repo.group_order_daily(db, sid=group.sid, start=start, end=end) if is_meituan else {} + ) + _ORDER_KEYS = ( + "order_count", "canceled_count", "gmv_cents", + "est_commission_cents", "refund_profit_cents", "settled_commission_cents", + ) + rows = [] + cur = start.astimezone(bj).date() + last = end.astimezone(bj).date() + idx = 0 + while cur <= last: + cp = click_points[idx] if idx < len(click_points) else None + row = { + "date": cur.strftime("%m-%d"), + "click_pv": cp["click_pv"] if cp else 0, + "click_uv": cp["click_uv"] if cp else 0, + "copy_pv": cp["copy_pv"] if cp else 0, + } + if is_meituan: + od = order_daily.get(cur.strftime("%Y-%m-%d")) + row.update({k: (od[k] if od else 0) for k in _ORDER_KEYS}) + else: + row.update({k: None for k in _ORDER_KEYS}) + rows.append(row) + idx += 1 + cur += timedelta(days=1) + return { + "group_id": group.id, + "group_name": group.name, + "is_meituan": is_meituan, + "days": days, + "rows": rows, + } + + +@router.get("/groups/{group_id}/wx-users", summary="群内微信用户(领券画像:头像/昵称/领券次数)") +def group_wx_users(group_id: int, db: AdminDb) -> dict: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + return {"users": cps_repo.group_wx_users(db, group_id=group_id)} diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index 23f21de..249378d 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -1,6 +1,7 @@ -"""admin 反馈工单:列表(读)+ 标记已处理(写,带审计)。""" +"""admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。""" from __future__ import annotations +from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -25,14 +26,30 @@ def list_feedbacks( db: AdminDb, status: Annotated[str | None, Query()] = None, user_id: Annotated[int | None, Query()] = None, + content: Annotated[str | None, Query(max_length=100)] = None, + created_from: Annotated[datetime | None, Query()] = None, + created_to: Annotated[datetime | None, Query()] = None, + sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id", + sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[FeedbackOut]: - items, next_cursor = queries.list_feedbacks( - db, status=status, user_id=user_id, limit=limit, cursor=cursor, + items, next_cursor, total = queries.list_feedbacks( + db, + status=status, + user_id=user_id, + content=content, + created_from=created_from, + created_to=created_to, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, ) return CursorPage( - items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor, + items=[FeedbackOut.model_validate(f) for f in items], + next_cursor=next_cursor, + total=total, ) diff --git a/app/admin/routers/price_report.py b/app/admin/routers/price_report.py index b29f271..8e04b6d 100644 --- a/app/admin/routers/price_report.py +++ b/app/admin/routers/price_report.py @@ -40,11 +40,13 @@ def list_price_reports( limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[PriceReportOut]: - items, next_cursor = queries.list_price_reports( + items, next_cursor, total = queries.list_price_reports( db, status=status, user_id=user_id, limit=limit, cursor=cursor, ) return CursorPage( - items=[PriceReportOut.model_validate(r) for r in items], next_cursor=next_cursor, + items=[PriceReportOut.model_validate(r) for r in items], + next_cursor=next_cursor, + total=total, ) @@ -60,7 +62,9 @@ def approve_price_report( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> OkResponse: - rep = db.get(PriceReport, report_id) + # 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖, + # 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。 + rep = db.get(PriceReport, report_id, with_for_update=True) if rep is None: raise HTTPException(status_code=404, detail="上报记录不存在") if rep.status != "pending": @@ -88,7 +92,7 @@ def reject_price_report( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> OkResponse: - rep = db.get(PriceReport, report_id) + rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核) if rep is None: raise HTTPException(status_code=404, detail="上报记录不存在") if rep.status != "pending": diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index f58bbce..2e0a017 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -31,22 +31,32 @@ router = APIRouter( ) -@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+分页)") +@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+排序+分页)") def list_users( db: AdminDb, phone: Annotated[str | None, Query()] = None, register_channel: Annotated[str | None, Query()] = None, status: Annotated[str | None, Query()] = None, + nickname: Annotated[str | None, Query(max_length=100)] = None, + created_from: Annotated[datetime | None, Query()] = None, + created_to: Annotated[datetime | None, Query()] = None, + last_login_from: Annotated[datetime | None, Query()] = None, + last_login_to: Annotated[datetime | None, Query()] = None, + sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id", + sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[AdminUserListItem]: - items, next_cursor = queries.list_users( + items, next_cursor, total = queries.list_users( db, phone=phone, register_channel=register_channel, status=status, - limit=limit, cursor=cursor, + nickname=nickname, created_from=created_from, created_to=created_to, + last_login_from=last_login_from, last_login_to=last_login_to, + sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor, ) return CursorPage( items=[AdminUserListItem.model_validate(u) for u in items], next_cursor=next_cursor, + total=total, ) @@ -144,7 +154,7 @@ def set_user_debug_trace( return OkResponse() -@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)") +@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减/设值金币(带审计)") def grant_user_coins( user_id: int, body: GrantCoinsRequest, @@ -152,33 +162,46 @@ def grant_user_coins( admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, ) -> OkResponse: - if body.amount == 0: - raise HTTPException(status_code=400, detail="amount 不能为 0") user = user_repo.get_user_by_id(db, user_id) if user is None: raise HTTPException(status_code=404, detail="用户不存在") - # 负数扣减时不允许扣成负余额(运营误操作保护) - if body.amount < 0: - acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) - if acc_now.coin_balance + body.amount < 0: - raise HTTPException( - status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})" - ) - biz_type = "admin_grant" if body.amount > 0 else "admin_deduct" + # set=设为目标值:读当前余额算出要写的差值,仍复用 grant_coins 写一笔流水(沿用原子/审计/扣负保护) + if body.mode == "set": + if body.amount < 0: + raise HTTPException(status_code=400, detail="目标金币值不能为负") + # lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位 + before = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True).coin_balance + delta = body.amount - before + if delta == 0: + raise HTTPException(status_code=400, detail=f"当前金币已为 {body.amount},无需调整") + else: + if body.amount == 0: + raise HTTPException(status_code=400, detail="amount 不能为 0") + delta = body.amount + # 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿 + if delta < 0: + acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True) + if acc_now.coin_balance + delta < 0: + raise HTTPException( + status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})" + ) + biz_type = "admin_grant" if delta > 0 else "admin_deduct" # grant_coins 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕) acc, _ = wallet_repo.grant_coins( - db, user_id, body.amount, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], + db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], ) + detail = {"amount": delta, "balance_after": acc.coin_balance, "reason": body.reason} + if body.mode == "set": + detail.update({"mode": "set", "target": body.amount, "before": before}) write_audit( db, admin, action="user.coins.grant", target_type="user", target_id=user_id, - detail={"amount": body.amount, "balance_after": acc.coin_balance, "reason": body.reason}, - ip=get_client_ip(request), commit=False, + detail=detail, ip=get_client_ip(request), commit=False, ) db.commit() return OkResponse() -@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减现金(带审计)") +@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减/设值现金(带审计)") def grant_user_cash( user_id: int, body: GrantCashRequest, @@ -186,32 +209,50 @@ def grant_user_cash( admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, ) -> OkResponse: - """给指定用户增/减现金(分)。正=发放、负=扣减;主要用于让无现金用户直接测试提现。""" - if body.amount_cents == 0: - raise HTTPException(status_code=400, detail="amount_cents 不能为 0") + """给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。 + 主要用于让无现金用户直接测试提现。""" user = user_repo.get_user_by_id(db, user_id) if user is None: raise HTTPException(status_code=404, detail="用户不存在") - # 负数扣减时不允许扣成负余额(运营误操作保护) - if body.amount_cents < 0: - acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) - if acc_now.cash_balance_cents + body.amount_cents < 0: + # set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护) + if body.mode == "set": + if body.amount_cents < 0: + raise HTTPException(status_code=400, detail="目标现金值不能为负") + # lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位 + before = wallet_repo.get_or_create_account( + db, user_id, commit=False, lock=True + ).cash_balance_cents + delta = body.amount_cents - before + if delta == 0: raise HTTPException( - status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)" + status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整" ) - biz_type = "admin_grant" if body.amount_cents > 0 else "admin_deduct" + else: + if body.amount_cents == 0: + raise HTTPException(status_code=400, detail="amount_cents 不能为 0") + delta = body.amount_cents + # 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿 + if delta < 0: + acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True) + if acc_now.cash_balance_cents + delta < 0: + raise HTTPException( + status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)" + ) + biz_type = "admin_grant" if delta > 0 else "admin_deduct" # grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕) acc, _ = wallet_repo.grant_cash( - db, user_id, body.amount_cents, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], + db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], ) + detail = { + "amount_cents": delta, + "balance_after_cents": acc.cash_balance_cents, + "reason": body.reason, + } + if body.mode == "set": + detail.update({"mode": "set", "target_cents": body.amount_cents, "before_cents": before}) write_audit( db, admin, action="user.cash.grant", target_type="user", target_id=user_id, - detail={ - "amount_cents": body.amount_cents, - "balance_after_cents": acc.cash_balance_cents, - "reason": body.reason, - }, - ip=get_client_ip(request), commit=False, + detail=detail, ip=get_client_ip(request), commit=False, ) db.commit() return OkResponse() diff --git a/app/admin/routers/withdraw.py b/app/admin/routers/withdraw.py index cb83c4c..a36ad54 100644 --- a/app/admin/routers/withdraw.py +++ b/app/admin/routers/withdraw.py @@ -35,6 +35,7 @@ from app.admin.schemas.wallet import ( from app.core.config import settings from app.integrations import wxpay from app.models.admin import AdminUser +from app.repositories import app_config from app.repositories import wallet as wallet_repo router = APIRouter( @@ -64,7 +65,7 @@ def list_withdraws( limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[WithdrawListItemOut]: - items, next_cursor = queries.list_all_withdraw_orders( + items, next_cursor, total = queries.list_all_withdraw_orders( db, user_id=user_id, status=status, @@ -87,7 +88,7 @@ def list_withdraws( ) for o in items ] - return CursorPage(items=out_items, next_cursor=next_cursor) + return CursorPage(items=out_items, next_cursor=next_cursor, total=total) @router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计") @@ -95,8 +96,13 @@ def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut: return WithdrawSummaryOut(**queries.withdraw_summary(db)) -@router.get("/health-check", response_model=WxpayHealthCheckOut, summary="提现配置健康检查") -def withdraw_health_check() -> WxpayHealthCheckOut: +@router.get( + "/health-check", + response_model=WxpayHealthCheckOut, + summary="提现配置健康检查", + dependencies=[Depends(require_role("finance"))], # 暴露密钥路径/配置,限财务+super +) +def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut: private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001 public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001 issues: list[str] = [] @@ -118,8 +124,15 @@ def withdraw_health_check() -> WxpayHealthCheckOut: issues.append("微信支付基础配置不完整") if not settings.WXPAY_AUTH_NOTIFY_URL: issues.append("免确认授权回调地址未配置") - if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED: - issues.append("自动对账未开启") + + # 实际是否自动对账 = env 部署总闸(worker 起没起)AND 运营后台 DB 开关(本轮跑不跑)。 + worker_running = settings.WITHDRAW_AUTO_RECONCILE_ENABLED + daily_on = bool(app_config.get_value(db, "withdraw_auto_reconcile_enabled")) + auto_reconcile_enabled = worker_running and daily_on + if not worker_running: + issues.append("自动对账 worker 未启动(部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=false)") + elif not daily_on: + issues.append("自动对账运营开关已关闭(系统配置页可开)") return WxpayHealthCheckOut( ok=not issues, @@ -132,7 +145,7 @@ def withdraw_health_check() -> WxpayHealthCheckOut: public_key_exists=public_path.exists(), public_key_loadable=public_loadable, auth_notify_url_configured=bool(settings.WXPAY_AUTH_NOTIFY_URL), - auto_reconcile_enabled=settings.WITHDRAW_AUTO_RECONCILE_ENABLED, + auto_reconcile_enabled=auto_reconcile_enabled, auto_reconcile_interval_sec=settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC, auto_reconcile_older_than_minutes=settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES, issues=issues, @@ -168,7 +181,7 @@ def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut: withdraw_success_cents=overview["withdraw_success_cents"], ) - recent_withdraws, _ = queries.list_all_withdraw_orders( + recent_withdraws, _, _ = queries.list_all_withdraw_orders( db, user_id=order.user_id, limit=5, cursor=None, ) recent_cash_transactions, _ = queries.list_all_cash_transactions( diff --git a/app/admin/schemas/ad_audit.py b/app/admin/schemas/ad_audit.py index cd5f949..b268d8d 100644 --- a/app/admin/schemas/ad_audit.py +++ b/app/admin/schemas/ad_audit.py @@ -48,10 +48,15 @@ class AdCoinFormulaOut(BaseModel): class AdCoinAuditOut(BaseModel): - """审计响应:公式参照 + 命中条数 + 明细。""" + """审计响应:公式参照 + 全量统计 + 明细。""" date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)") formula: AdCoinFormulaOut - total: int = Field(..., description="返回的明细条数") - mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)") - items: list[AdCoinAuditRow] + total: int = Field(..., description="该筛选下复算总条数(全量,不受 limit/only_mismatch 影响)") + mismatch_count: int = Field( + ..., description="全量不一致条数(=0 说明公式全部生效;在截断前统计,可信)" + ) + truncated: bool = Field( + ..., description="展示集是否被 limit 截断(true=还有未返回的明细,请缩小范围或调大 limit)" + ) + items: list[AdCoinAuditRow] = Field(..., description="展示明细;only_mismatch=true 时只含 ✗ 行") diff --git a/app/admin/schemas/ad_config.py b/app/admin/schemas/ad_config.py new file mode 100644 index 0000000..3f5c033 --- /dev/null +++ b/app/admin/schemas/ad_config.py @@ -0,0 +1,30 @@ +"""admin 广告配置 schemas(穿山甲 app_id/各位ID/验签密钥/各场景开关)。""" +from __future__ import annotations + +from pydantic import BaseModel + + +class AdConfigOut(BaseModel): + """admin 读到的完整广告配置(含 reward_mkey;admin 有权见,但绝不经 /platform 下发客户端)。""" + + app_id: str + reward_code_id: str + compare_feed_code_id: str + coupon_feed_code_id: str + reward_mkey: str + reward_enabled: bool + compare_ad_enabled: bool + coupon_ad_enabled: bool + + +class AdConfigUpdate(BaseModel): + """部分更新:只改传入(非 None)字段。空串是合法值(如清空 mkey 回退 .env)。""" + + app_id: str | None = None + reward_code_id: str | None = None + compare_feed_code_id: str | None = None + coupon_feed_code_id: str | None = None + reward_mkey: str | None = None + reward_enabled: bool | None = None + compare_ad_enabled: bool | None = None + coupon_ad_enabled: bool | None = None diff --git a/app/admin/schemas/ad_revenue.py b/app/admin/schemas/ad_revenue.py new file mode 100644 index 0000000..beeb132 --- /dev/null +++ b/app/admin/schemas/ad_revenue.py @@ -0,0 +1,90 @@ +"""广告收益报表 schemas。 + +按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合的只读报表:展示条数、收益(元)、金币、来源。 +字段 snake_case;收益按元(float),金币按整数。 +""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class AdRevenueImpression(BaseModel): + """聚合行下钻的单条**展示**明细(每次广告展示一条,展开该组时展示)。""" + + id: int = Field(..., description="ad_ecpm_record 主键") + created_at: datetime + ecpm: str = Field(..., description="本次展示 eCPM 原始值(分/千次展示)") + revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000") + adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…)") + slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID)") + + +class AdRevenueRecord(BaseModel): + """聚合行下钻的单条发奖复算明细(与金币审计同源,展开该组时展示)。""" + + record_id: int + created_at: datetime + status: str = Field(..., description="granted / capped / ecpm_missing") + ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示)") + ecpm_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空") + units: int = Field(..., description="折算份数:激励视频恒 1;信息流 = 满 10 秒份数") + lt_index_start: int | None = Field(None, description="本条占用「账号累计第几份」的起") + lt_index_end: int | None = Field(None, description="本条占用「账号累计第几份」的止;激励视频 = 起") + lt_factor_start: float | None = Field(None, description="因子2(LT)起值") + lt_factor_end: float | None = Field(None, description="因子2(LT)止值;激励视频 = 起") + expected_coin: int = Field(..., description="按公式复算应发金币") + actual_coin: int = Field(..., description="实际入账金币") + matched: bool = Field(..., description="复算与实发是否一致") + + +class AdRevenueDaily(BaseModel): + """按日期汇总的一天(供前端按天趋势图;全量,不受 limit 影响)。""" + + date: str = Field(..., description="北京时间 YYYY-MM-DD") + impressions: int = Field(..., description="当天展示条数合计") + revenue_yuan: float = Field(..., description="当天预估收益合计(元)") + expected_coin: int = Field(..., description="当天应发金币合计") + actual_coin: int = Field(..., description="当天实发金币合计") + + +class AdRevenueRow(BaseModel): + """一个聚合组(report_date × user × ad_type × app_env × our_code_id)的汇总。""" + + report_date: str = Field(..., description="该组所属日期(北京时间 YYYY-MM-DD)") + user_id: int + ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)") + app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空") + our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空") + hour: int | None = Field(None, description="北京时间小时 0–23(granularity=hour 时有值;按天为 null)") + impressions: int = Field(..., description="展示条数(每条广告展示一条;轮播每条各计一次)") + revenue_yuan: float = Field(..., description="收益(元)= Σ(eCPM元 ÷ 1000);测试应用多为 0") + expected_coin: int = Field(..., description="应发金币(按公式复算,与金币审计同源)") + actual_coin: int = Field(..., description="实发金币(实际入账,按现发奖算法)") + matched: bool = Field(..., description="该组应发==实发(组内任一条不符则 false)") + adns: list[str] = Field(default_factory=list, description="实际填充的底层 ADN 子渠道集合(如 pangle/gdt)") + impression_records: list[AdRevenueImpression] = Field( + default_factory=list, + description="该组逐条展示明细(时间/eCPM/收益/adn);展开下钻用,无发奖也有(只要有展示)", + ) + records: list[AdRevenueRecord] = Field( + default_factory=list, + description="该组逐条发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);展开下钻用,纯展示无发奖记录的组为空", + ) + + +class AdRevenueReportOut(BaseModel): + """报表响应:全量统计 + 按天趋势 + 聚合明细。""" + + date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)") + date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)") + daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)") + total: int = Field(..., description="聚合组总数(全量,不受 limit 影响)") + truncated: bool = Field(..., description="明细是否被 limit 截断") + total_impressions: int = Field(..., description="全量展示条数合计") + total_revenue_yuan: float = Field(..., description="全量收益合计(元)") + total_expected_coin: int = Field(..., description="全量应发金币合计") + total_actual_coin: int = Field(..., description="全量实发金币合计") + mismatch_count: int = Field(..., description="应发≠实发的组数(=0 说明全部按公式发放)") + items: list[AdRevenueRow] = Field(..., description="聚合明细(按 用户→类型→代码位 排序)") diff --git a/app/admin/schemas/common.py b/app/admin/schemas/common.py index 6987eeb..9f283c6 100644 --- a/app/admin/schemas/common.py +++ b/app/admin/schemas/common.py @@ -9,10 +9,15 @@ T = TypeVar("T") class CursorPage(BaseModel, Generic[T]): - """游标分页响应:items + 下一页游标(next_cursor=None 表示末页)。""" + """分页响应:items + 下一页游标(next_cursor=None 表示末页)+ 可选 total。 + + next_cursor:offset 分页时即下一页 offset,「加载更多」用;末页为 None。 + total:符合筛选条件的总条数,页码分页(antd pagination)用;不需要总数的接口可不传(None)。 + """ items: list[T] next_cursor: int | None = None + total: int | None = None class OkResponse(BaseModel): diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py new file mode 100644 index 0000000..2fef36d --- /dev/null +++ b/app/admin/schemas/comparison.py @@ -0,0 +1,70 @@ +"""admin 比价记录 debug 页 schema(独立于 C 端 app/schemas/compare_record:admin 看任意用户、 +无 debug_trace 权限闸、展示全量 debug 字段)。phone/nickname 由 queries 瞬态挂在 ORM 实例上。""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class AdminComparisonListItem(BaseModel): + """列表项:概要 + debug 维度概览(不含大字段 raw_payload/llm_calls/明细数组)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + phone: str | None = None # join User 瞬态(非 DB 列) + nickname: str | None = None # join User 瞬态 + business_type: str + trace_id: str + # admin 是 debug 工具,无条件下发 trace_url(不看 user.debug_trace_enabled) + trace_url: str | None = None + status: str + information: str | None = None + store_name: str | None = None + source_platform_name: str | None = None + best_platform_name: str | None = None + source_price_cents: int | None = None + best_price_cents: int | None = None + saved_amount_cents: int | None = None + # debug 概览 + total_ms: int | None = None + step_count: int | None = None + llm_call_count: int | None = None + retry_count: int | None = None + device_model: str | None = None + rom_vendor: str | None = None + rom_name: str | None = None + android_version: str | None = None + app_version: str | None = None + created_at: datetime + + +class AdminComparisonDetail(AdminComparisonListItem): + """详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。""" + + source_platform_id: str | None = None + source_package: str | None = None + best_platform_id: str | None = None + best_deeplink: str | None = None + is_source_best: bool | None = None + total_dish_count: int | None = None + skipped_dish_count: int | None = None + device_id: str | None = None + items: list = [] + comparison_results: list = [] # 逐平台对比(价格/rank/coupon/打烊...) + skipped_dish_names: list = [] + # 全量环境 + device_manufacturer: str | None = None + rom_version: int | None = None + android_sdk: int | None = None + app_version_code: int | None = None + source_app_version: str | None = None + longitude: float | None = None + latitude: float | None = None + # 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}] + llm_calls: list | None = None + # 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读 + # (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。 + raw_payload: dict | None = None diff --git a/app/admin/schemas/config.py b/app/admin/schemas/config.py index 01b56db..10b694b 100644 --- a/app/admin/schemas/config.py +++ b/app/admin/schemas/config.py @@ -10,7 +10,7 @@ class ConfigItemOut(BaseModel): key: str label: str group: str - type: str # int / int_list / dict_str_int + type: str # int / int_list / dict_str_int / bool help: str | None = None default: Any value: Any diff --git a/app/admin/schemas/cps.py b/app/admin/schemas/cps.py new file mode 100644 index 0000000..e7c3f7e --- /dev/null +++ b/app/admin/schemas/cps.py @@ -0,0 +1,155 @@ +"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。 + +平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。 +对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。 +""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +# ───────────── 群 ───────────── +class CpsGroupOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + sid: str | None = None # 仅美团群有 + name: str + platforms: list[str] = Field(default_factory=list) + member_count: int | None = None + status: str + remark: str | None = None + created_at: datetime + + +class CpsGroupCreate(BaseModel): + name: str = Field(min_length=1, max_length=128) + # 该群发哪些平台,至少选一个 + platforms: list[str] = Field(min_length=1) + # 仅当含 meituan 时需要(留空自动生成);纯淘宝/京东不填,后端置 None。 + sid: str | None = Field(default=None, max_length=64, pattern=r"^[A-Za-z0-9]+$") + member_count: int | None = Field(default=None, ge=0) + remark: str | None = Field(default=None, max_length=256) + + +class CpsGroupUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + platforms: list[str] | None = None + member_count: int | None = Field(default=None, ge=0) + status: str | None = Field(default=None, pattern=r"^(active|archived)$") + remark: str | None = Field(default=None, max_length=256) + + +# ───────────── 活动 ───────────── +class CpsActivityOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + platform: str + name: str + act_id: str | None = None + product_view_sign: str | None = None + payload: str | None = None # 淘宝淘口令 / 京东链接 + image_url: str | None = None # 淘宝落地页图(绝对 URL) + status: str + remark: str | None = None + created_at: datetime + + +class CpsActivityCreate(BaseModel): + name: str = Field(min_length=1, max_length=128) + platform: str = Field(pattern=r"^(meituan|taobao|jd)$") + # 美团:act_id 或 product_view_sign 二选一 + act_id: str | None = Field(default=None, max_length=64) + product_view_sign: str | None = Field(default=None, max_length=128) + # 淘宝:整段淘口令文本 / 京东:推广链接 + payload: str | None = Field(default=None, max_length=4096) + # 淘宝落地页图(绝对/相对 URL;淘宝必填,由 router 校验) + image_url: str | None = Field(default=None, max_length=512) + remark: str | None = Field(default=None, max_length=256) + + +class CpsActivityUpdate(BaseModel): + """编辑活动(PATCH):全字段可选,只更新传入的非 None 项;最终平台对应字段由 router 校验。""" + + name: str | None = Field(default=None, min_length=1, max_length=128) + platform: str | None = Field(default=None, pattern=r"^(meituan|taobao|jd)$") + act_id: str | None = Field(default=None, max_length=64) + product_view_sign: str | None = Field(default=None, max_length=128) + payload: str | None = Field(default=None, max_length=4096) + image_url: str | None = Field(default=None, max_length=512) + remark: str | None = Field(default=None, max_length=256) + status: str | None = Field(default=None, pattern=r"^(active|archived)$") + + +# ───────────── 生成链接(批量) ───────────── +class CpsReferralLinksRequest(BaseModel): + group_id: int + activity_ids: list[int] = Field(min_length=1) + + +class CpsReferralLinkItem(BaseModel): + activity_id: int + activity_name: str + platform: str + redirect_url: str # 我们的落地页 /c/{code}(发群用) + code: str + + +class CpsReferralLinksOut(BaseModel): + group_name: str + results: list[CpsReferralLinkItem] + + +# ───────────── 对账拉单 ───────────── +class CpsReconcileResult(BaseModel): + fetched: int + inserted: int + updated: int + pages: int + + +# ───────────── 订单明细 ───────────── +class CpsOrderOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + order_id: str + sid: str | None = None + act_id: str | None = None + pay_price_cents: int | None = None + commission_cents: int | None = None + commission_rate: str | None = None + mt_status: str | None = None + invalid_reason: str | None = None + product_name: str | None = None + pay_time: datetime | None = None + + +# ───────────── 统计 ───────────── +class CpsGroupStat(BaseModel): + group_id: int | None = None + sid: str | None = None + name: str + platforms: list[str] = Field(default_factory=list) + member_count: int | None = None + click_pv: int = 0 + click_uv: int = 0 + copy_pv: int = 0 # 淘宝"复制口令"次数 + copy_uv: int = 0 + # 对账类:淘宝/京东无法对账 → None(前端显示 "-");美团群为数值 + order_count: int | None = None + settled_count: int | None = None + canceled_count: int | None = None + gmv_cents: int | None = None + est_commission_cents: int | None = None + settled_commission_cents: int | None = None + + +class CpsStatsOut(BaseModel): + groups: list[CpsGroupStat] + total_order_count: int + total_est_commission_cents: int + total_settled_commission_cents: int diff --git a/app/admin/schemas/price_report.py b/app/admin/schemas/price_report.py index 30ea8ad..ddf3278 100644 --- a/app/admin/schemas/price_report.py +++ b/app/admin/schemas/price_report.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class PriceReportOut(BaseModel): @@ -36,6 +36,14 @@ class PriceReportOut(BaseModel): class PriceReportRejectRequest(BaseModel): reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到") + @field_validator("reason") + @classmethod + def _reason_not_blank(cls, v: str) -> str: + # min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计/用户端记录到空理由 + if not v.strip(): + raise ValueError("拒绝理由不能为空") + return v.strip() + class PriceReportSummary(BaseModel): """审核台顶部各状态计数。""" diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index cf7c39d..fad0a62 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class AdminUserListItem(BaseModel): @@ -67,15 +67,38 @@ class UserCoinRecord(BaseModel): coin: int # 发放金币数 +def _strip_reason(v: str) -> str: + # min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因 + if not v.strip(): + raise ValueError("操作原因不能为空") + return v.strip() + + class GrantCoinsRequest(BaseModel): - amount: int = Field(..., description="金币变动:正=增加,负=扣减(不可为 0)") + mode: Literal["delta", "set"] = Field( + "delta", description="delta=增减(amount 为变动量) / set=设为(amount 为目标值,须≥0)" + ) + amount: int = Field( + ..., + description="delta 模式:金币变动(正=增加,负=扣减,不可为 0);set 模式:目标金币值(须≥0)", + ) reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") + _v_reason = field_validator("reason")(_strip_reason) + class GrantCashRequest(BaseModel): - amount_cents: int = Field(..., description="现金变动(分):正=增加,负=扣减(不可为 0)") + mode: Literal["delta", "set"] = Field( + "delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)" + ) + amount_cents: int = Field( + ..., + description="delta 模式:现金变动(分,正=增加,负=扣减,不可为 0);set 模式:目标现金值(分,须≥0)", + ) reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") + _v_reason = field_validator("reason")(_strip_reason) + class SetUserStatusRequest(BaseModel): status: Literal["active", "disabled"] = Field( diff --git a/app/api/internal/app_version.py b/app/api/internal/app_version.py new file mode 100644 index 0000000..b3c0173 --- /dev/null +++ b/app/api/internal/app_version.py @@ -0,0 +1,58 @@ +"""最新 App 版本写入端点(发布流程 → app-server)。 + +发车流程出 APK 后,把版本号 / 下载链接 / sha256 等 POST 到这里,落 app_config(key=latest_app_version)。 +客户端再 GET /api/v1/platform/app-version 读取做 OTA 检查更新。**不是给客户端的接口**: +不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。 +密钥未配置(默认空)时直接 503,避免裸奔的写端点。也是应急改版本信息(紧急下线/改 apk_url)的入口。 +""" +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 app_config +from app.schemas.platform import AppVersionOut, AppVersionWriteIn + +logger = logging.getLogger("shagua.internal.app_version") + +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( + "/app-version", + response_model=AppVersionOut, + summary="写最新 App 版本(发布流程→app-server,落 app_config)", +) +def write_app_version( + payload: AppVersionWriteIn, + db: DbSession, + x_internal_secret: Annotated[str | None, Header()] = None, +) -> AppVersionOut: + _check_secret(x_internal_secret) + data = payload.model_dump() + app_config.set_app_version(db, data) + logger.info( + "app_version set code=%d name=%s url=%s", + payload.latest_version_code, payload.latest_version_name, payload.apk_url, + ) + return AppVersionOut(**data) diff --git a/app/api/internal/launch_confirm.py b/app/api/internal/launch_confirm.py new file mode 100644 index 0000000..5f24d3f --- /dev/null +++ b/app/api/internal/launch_confirm.py @@ -0,0 +1,63 @@ +"""启动确认窗兜底样本内部上报端点(pricebot → app-server)。 + +pricebot 的 launch_confirm_agent 每次靠 LLM 兜底放行一个"静态 PROFILES 没认出"的跨 App +启动确认窗时,把完整样本 POST 到这里落库。**不是给客户端的接口**:不走用户 JWT,靠 +server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。密钥未配置 +(默认空)时直接 503,避免裸奔写端点。 + +研发定期人工把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES。 +""" +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 launch_confirm_sample as repo +from app.schemas.launch_confirm_sample import ( + LaunchConfirmSampleIn, + LaunchConfirmSampleOut, +) + +logger = logging.getLogger("shagua.internal.launch_confirm") + +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( + "/launch-confirm-sample", + response_model=LaunchConfirmSampleOut, + summary="启动确认窗兜底样本上报(pricebot→app-server,落 launch_confirm_sample)", +) +def report_launch_confirm_sample( + payload: LaunchConfirmSampleIn, + db: DbSession, + x_internal_secret: Annotated[str | None, Header()] = None, +) -> LaunchConfirmSampleOut: + _check_secret(x_internal_secret) + sid = repo.insert_sample(db, payload) + logger.info( + "launch_confirm_sample id=%d host=%s locale=%s success=%s trace=%s", + sid, payload.host_package, payload.system_locale, + payload.exec_success, payload.trace_id, + ) + return LaunchConfirmSampleOut(id=sid) diff --git a/app/api/internal/store.py b/app/api/internal/store.py new file mode 100644 index 0000000..104d379 --- /dev/null +++ b/app/api/internal/store.py @@ -0,0 +1,110 @@ +"""平台店铺映射内部上报端点(pricebot → app-server)。 + +pricebot 在淘宝比价拿到 shopId 后,把这一行跨平台店铺映射 POST 到这里落库。 +**不是给客户端的接口**:不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验 +(复用 price.py 的 _check_secret,与 price-observation 同一密钥)。 + +与 price.py 的 /internal/price-observation 平行:那个落价格事实,这个落店铺身份映射。 +""" +from __future__ import annotations + +import logging + +from typing import Annotated + +from fastapi import APIRouter, Header + +from app.api.deps import DbSession +from app.api.internal.price import _check_secret +from app.repositories import store_mapping as repo +from app.schemas.store_mapping import ( + StoreMappingIn, + StoreMappingInvalidateIn, + StoreMappingInvalidateOut, + StoreMappingOut, +) + +logger = logging.getLogger("shagua.internal.store") + +router = APIRouter(prefix="/internal", tags=["internal"]) + + +@router.get( + "/store-mapping/lookup", + summary="比价前按源平台店名反查各目标平台已沉淀的店铺 id(命中→pricebot 直接 deeplink)", +) +def lookup_store_mapping( + source_platform: str, + name: str, + db: DbSession, + lat: float | None = None, + lng: float | None = None, + x_internal_secret: Annotated[str | None, Header()] = None, +) -> dict: + _check_secret(x_internal_secret) + result = repo.lookup_nearest(db, source_platform, name, lat, lng) + if result: + hits = ", ".join( + f"{t}:row{v['row_id']}" + f"{'(' + str(v['dist_km']) + 'km)' if 'dist_km' in v else ''}" + f"→{v.get('deeplink') or '(无deeplink)'}" + for t, v in result.items() + ) + logger.info( + "store_mapping lookup source=%s name=%r geo=(%s,%s) → 命中 %s", + source_platform, name, lat, lng, hits, + ) + else: + logger.info( + "store_mapping lookup source=%s name=%r geo=(%s,%s) → MISS", + source_platform, name, lat, lng, + ) + return result + + +@router.post( + "/store-mapping", + response_model=StoreMappingOut, + summary="平台店铺映射内部上报(pricebot→app-server,落 store_mapping)", +) +def report_store_mapping( + payload: StoreMappingIn, + db: DbSession, + x_internal_secret: Annotated[str | None, Header()] = None, +) -> StoreMappingOut: + _check_secret(x_internal_secret) + created, row_id = repo.upsert(db, payload) + logger.info( + "store_mapping trace=%s %s row_id=%s source=%s " + "taobao=(%s,%s) jd=(%s,%s) device=%s user=%s", + payload.trace_id, "新建" if created else "合并", row_id, payload.source_platform, + payload.id_taobao, payload.name_taobao, payload.id_jd, payload.name_jd, + payload.source_device_id, payload.source_user_id, + ) + return StoreMappingOut(inserted=created, row_id=row_id) + + +@router.post( + "/store-mapping/invalidate", + response_model=StoreMappingInvalidateOut, + summary="标记某平台 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报,lookup 不再返回)", +) +def invalidate_store_mapping( + payload: StoreMappingInvalidateIn, + db: DbSession, + x_internal_secret: Annotated[str | None, Header()] = None, +) -> StoreMappingInvalidateOut: + _check_secret(x_internal_secret) + if payload.platform == "taobao": + affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id) + elif payload.platform == "jd": + affected = repo.mark_jd_deeplink_invalid(db, payload.shop_id) + else: + # 当前只接淘宝/京东; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。 + logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform) + return StoreMappingInvalidateOut(ok=True, affected=0) + logger.info( + "store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d 行", + payload.platform, payload.shop_id, affected, + ) + return StoreMappingInvalidateOut(ok=True, affected=affected) diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index cbf82f6..79dbcfd 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -24,6 +24,7 @@ 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.repositories import app_config from app.repositories import signin as crud_signin from app.schemas.ad import ( AdRewardStatusOut, @@ -32,6 +33,8 @@ from app.schemas.ad import ( FeedRewardIn, FeedRewardOut, PangleCallbackOut, + RewardNoShowIn, + RewardNoShowOut, TestGrantIn, TestGrantOut, WatchReportIn, @@ -78,14 +81,22 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: 验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。 granted / capped → is_verify=true + reason=0。 """ - if not settings.pangle_callback_configured: + if not settings.PANGLE_CALLBACK_ENABLED: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured" ) params = dict(request.query_params) - if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET): + # 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。 + # 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。 + ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or "" + secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets + if not secrets: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured" + ) + if not pangle.verify_callback_sign_any(params, secrets): logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id")) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign") @@ -242,10 +253,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm ad_type=payload.ad_type, ecpm_raw=payload.ecpm, ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, + app_env=payload.app_env, our_code_id=payload.our_code_id, ) logger.info( - "ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s", + "ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s", user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id, + payload.app_env, payload.our_code_id, ) return EcpmReportOut(ok=True) @@ -360,6 +373,9 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed adn=payload.adn, slot_id=payload.slot_id, feed_scene=payload.feed_scene, + app_env=payload.app_env, + our_code_id=payload.our_code_id, + aborted=payload.aborted, ) logger.info( "feed ad reward user_id=%d event=%s status=%s units=%d coin=%d", @@ -372,3 +388,30 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed unit_count=rec.unit_count, daily_limit=rewards.get_ad_daily_limit(db), ) + + +@router.post( + "/reward-noshow", + response_model=RewardNoShowOut, + summary="激励视频提前关闭/未发奖留痕", + dependencies=[Depends(rate_limit(120, 60, "ad-reward-noshow"))], +) +def reward_noshow(payload: RewardNoShowIn, user: CurrentUser, db: DbSession) -> RewardNoShowOut: + """激励视频展示了但用户提前关/跳过、未触发 S2S 发奖时,客户端 best-effort 上报一条留痕, + 让广告收益报表能呈现「有展示、没发金币」的原因。不发金币;同一 session 已发奖则跳过。 + """ + rec = crud_ad.record_reward_noshow( + db, + user.id, + ad_session_id=payload.ad_session_id, + ecpm=payload.ecpm, + adn=payload.adn, + slot_id=payload.slot_id, + app_env=payload.app_env, + our_code_id=payload.our_code_id, + ) + logger.info( + "ad reward noshow user_id=%d session=%s watched=%ds -> status=%s", + user.id, payload.ad_session_id, payload.watched_seconds, rec.status, + ) + return RewardNoShowOut(ok=True, status=rec.status) diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py index 93279b3..c05e7ca 100644 --- a/app/api/v1/compare.py +++ b/app/api/v1/compare.py @@ -116,3 +116,11 @@ async def intent_precoupon_step(request: Request) -> dict[str, Any]: @router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)") async def price_step(request: Request) -> dict[str, Any]: return await _passthrough(request, "/api/price/step") + + +@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)") +async def trace_finalize(request: Request) -> dict[str, Any]: + # 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时 + # 打这个, _passthrough 按 trace_id 一致性 hash 落到处理这条 trace 的同一 pricebot 进程 + # (dir_cache 在那, 才能算对 trace 目录), 由后者打包上云返回 {trace_url}。 + return await _passthrough(request, "/api/trace/finalize") diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 8e527dc..d4b1a03 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -15,10 +15,13 @@ from __future__ import annotations import logging -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status from app.api.deps import CurrentUser, DbSession +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord from app.repositories import comparison as crud_compare +from app.services.pricebot_llm_calls import fetch_llm_calls from app.schemas.compare_record import ( CompareStatsOut, ComparisonRecordCreatedOut, @@ -39,11 +42,18 @@ router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"]) summary="上报一次比价结果(幂等)", ) def report_record( - payload: ComparisonRecordIn, user: CurrentUser, db: DbSession + payload: ComparisonRecordIn, + user: CurrentUser, + db: DbSession, + background_tasks: BackgroundTasks, ) -> ComparisonRecordCreatedOut: rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload) + # LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台 + # 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用 + # 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。 + background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id) logger.info( - "compare record user=%s trace=%s biz=%s status=%s saved=%s", + "compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)", user.id, rec.trace_id, rec.business_type, @@ -53,6 +63,28 @@ def report_record( return ComparisonRecordCreatedOut(id=rec.id) +def _backfill_llm_calls(record_id: int, trace_id: str) -> None: + """后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。 + 独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报。""" + calls = fetch_llm_calls(trace_id) + if not calls: + return + db = SessionLocal() + try: + rec = db.get(ComparisonRecord, record_id) + if rec is None: + return + rec.llm_calls = calls + rec.llm_call_count = len(calls) + rec.retry_count = sum(1 for c in calls if c.get("error")) + db.commit() + logger.info("backfill llm_calls trace=%s n=%d", trace_id, len(calls)) + except Exception as e: # noqa: BLE001 best-effort + logger.warning("backfill llm_calls failed trace=%s: %s", trace_id, e) + finally: + db.close() + + @router.get( "/stats", response_model=CompareStatsOut, @@ -73,13 +105,20 @@ def list_records( db: DbSession, limit: int = Query(20, ge=1, le=100), cursor: int | None = Query(None, description="上一页末条 id"), + include_trace: bool = Query( + False, + description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url", + ), ) -> ComparisonRecordPage: items, next_cursor = crud_compare.list_records( db, user.id, limit=limit, cursor=cursor ) outs = [ComparisonRecordOut.model_validate(it) for it in items] - # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它) - if not user.debug_trace_enabled: + # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。 + # include_trace=true 例外:客户端开了本机 agent 调试模式时带上,放行**本人记录**的 trace_url + # ——list_records 只查 user.id 自己的记录,给本人看自己的调试链接无越权,与实时结果页 + # CompResultScreen「debug 权限 OR 本机 agent 调试」同口径(领导 2026-06-12 拍板)。 + if not (user.debug_trace_enabled or include_trace): for o in outs: o.trace_url = None return ComparisonRecordPage(items=outs, next_cursor=next_cursor) diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index e44adb5..07c19af 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -18,7 +18,7 @@ import httpx from fastapi import APIRouter, HTTPException, Request, status from fastapi.concurrency import run_in_threadpool -from app.api.deps import DbSession +from app.api.deps import CurrentUser, DbSession from app.core.config import settings from app.core.pricebot_router import pick_pricebot from app.db.session import SessionLocal @@ -27,6 +27,8 @@ from app.schemas.coupon_state import ( CouponCompletedTodayOut, CouponPromptDismissIn, CouponPromptShouldShowOut, + CouponPromptShownIn, + CouponStatsOut, ) logger = logging.getLogger("shagua.coupon") @@ -66,11 +68,11 @@ def _extract_coupon_results(resp_json: dict) -> list[dict]: def _mark_engagement_blocking( - device_id: str, user_id: int | None, engage_type: str + device_id: str, package: str, user_id: int | None, engage_type: str ) -> None: """独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。""" with SessionLocal() as db: - coupon_repo.mark_engagement(db, device_id, user_id, engage_type) + coupon_repo.mark_engagement(db, device_id, package, user_id, engage_type) def _record_claims_blocking( @@ -111,14 +113,17 @@ async def coupon_step( device_id = meta.get("device_id") user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用 trace_id = meta.get("trace_id") + # 发起领券时前台 App 包名(step body 带 "package")。频控按 App,这条 engagement 要记到 + # 对应 App 上。App 内「去领取」发起时 package 可能缺/为空 → 退化为 "" 占位(全局态)。 + pkg = meta.get("package") or "" # 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started), - # 今天这台设备不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能 + # 今天**这个 App** 不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能 # 连累领券主流程,整段吞掉。 if device_id and meta.get("step") == 0: try: await run_in_threadpool( - _mark_engagement_blocking, device_id, user_id, "claim_started" + _mark_engagement_blocking, device_id, pkg, user_id, "claim_started" ) except Exception as e: # noqa: BLE001 logger.warning("coupon engagement write failed: %s", e) @@ -188,14 +193,29 @@ async def coupon_step( return resp_json +@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)") +def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]: + """客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。 + + 频控主判据(管跨重装):弹出即占用今天这个 App 的"一次"。用户领/拒/无视都算用掉。 + 后续点领取/拒绝再由 step/dismiss 把 type 升级。按 (device, package, 日) 记。 + """ + coupon_repo.mark_engagement( + db, payload.device_id, payload.package, payload.user_id, "shown" + ) + return {"ok": True} + + @router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)") def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]: - """客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天不再弹。 + """客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天**这个 App** 不再弹。 server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。 - MVP 不鉴权,按 device_id 记。 + 频控按 (device, package, 日),各 App 独立。MVP 不鉴权,按 device_id 记。 """ - coupon_repo.mark_engagement(db, payload.device_id, payload.user_id, "dismissed") + coupon_repo.mark_engagement( + db, payload.device_id, payload.package, payload.user_id, "dismissed" + ) return {"ok": True} @@ -205,12 +225,12 @@ def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict summary="切到外卖 App 时是否还应弹领券引导窗", ) def coupon_prompt_should_show( - device_id: str, db: DbSession + device_id: str, db: DbSession, package: str = "" ) -> CouponPromptShouldShowOut: - """今天这台设备已 engage(领或拒)过 → should_show=false。客户端据此决定弹不弹 - (纯后台判据,客户端不再做前台 SP 缓存判断)。""" + """今天这台设备**这个 App** 已 engage(弹/领/拒)过 → should_show=false。各 App 独立: + 美团弹过不压淘宝/京东。客户端切到目标 App 时带 package 查(老客户端不带 → "" 全局态)。""" return CouponPromptShouldShowOut( - should_show=not coupon_repo.has_engaged_today(db, device_id) + should_show=not coupon_repo.has_engaged_today(db, device_id, package) ) @@ -236,3 +256,28 @@ def coupon_completed_today( return CouponCompletedTodayOut( completed=coupon_repo.has_completed_today(db, device_id) ) + + +@router.post( + "/completed-today/reset", + summary="重置今日已完成(开发设置全重置用,恢复首页「去领取」卡可点)", +) +def coupon_completed_today_reset( + payload: CouponPromptDismissIn, db: DbSession +) -> dict[str, bool]: + """删这台设备今天的 completion → has_completed_today 变 false,首页「去领取」卡恢复可点。 + 与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。""" + coupon_repo.reset_today_completion(db, payload.device_id) + return {"ok": True} + + +@router.get( + "/stats", + response_model=CouponStatsOut, + summary="累计领券数(「我的」页战绩卡「领取优惠券 X 张」)", +) +def coupon_stats(user: CurrentUser, db: DbSession) -> CouponStatsOut: + """该登录用户累计领到的券数(SUM(claimed_count),口径见 coupon_repo.sum_claimed_count)。 + **鉴权(CurrentUser)**——区别于同文件不鉴权的 /step 透传与 /prompt 频控(那些按 device_id): + 个人战绩按 user_id 聚合,必须有登录态。""" + return CouponStatsOut(coupon_count=coupon_repo.sum_claimed_count(db, user.id)) diff --git a/app/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py new file mode 100644 index 0000000..ee7aa0b --- /dev/null +++ b/app/api/v1/cps_redirect.py @@ -0,0 +1,208 @@ +"""CPS 群发短链落地:用户点 /c/{code} → 记点击 → 按平台 302 跳 或 返回淘宝落地页。 + +公网无鉴权(群里任何人点都要能跳/能领)。记点击失败绝不影响用户。 +- 美团/京东:记 visit + 302 跳 target(美团短链 / 京东链接) +- 淘宝:记 visit + 返回 H5 落地页(整段淘口令复制按钮);点"复制口令"→ POST /c/{code}/copy 记 copy +""" +from __future__ import annotations + +import html +import json +import logging + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse +from sqlalchemy.orm import Session + +from app.core import media +from app.core.config import settings +from app.db.session import get_db +from app.integrations import wx_oauth +from app.models.cps_activity import CpsActivity +from app.models.cps_link import CpsLink +from app.repositories import cps_link as cps_link_repo +from app.repositories import cps_wx_user as cps_wx_user_repo + +logger = logging.getLogger("shagua.cps_redirect") + +router = APIRouter(tags=["cps-redirect"]) + +# code 不存在/失效时的兜底落地 +_FALLBACK_URL = "https://www.meituan.com/" + +# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发) +_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg" + +# 微信网页授权拿到的用户标识 cookie(种在 coupon 域,30 天免重复授权) +_WX_OPENID_COOKIE = "wx_openid" +_WX_UINFO_COOKIE = "wx_uinfo" # "1" = 已拿过昵称头像(userinfo),点领券不再跳授权 +_COOKIE_MAX_AGE = 30 * 86400 + + +def _is_wechat(request: Request) -> bool: + """是否微信内置浏览器(网页授权只在微信内有意义,外部浏览器不跳授权)。""" + return "micromessenger" in (request.headers.get("user-agent", "").lower()) + + +def _client_ip(request: Request) -> str | None: + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else None + + +def _record( + db: Session, link: CpsLink, request: Request, event_type: str, openid: str | None = None +) -> None: + try: + cps_link_repo.record_click( + db, link=link, ip=_client_ip(request), + ua=request.headers.get("user-agent"), event_type=event_type, openid=openid, + ) + except Exception: + pass # 记点击失败不阻断 + + +@router.get("/MP_verify_F7wnRQ7xPbhVOWC8.txt", include_in_schema=False) +def wx_mp_domain_verify() -> PlainTextResponse: + """微信「网页授权域名」归属校验文件。coupon.shaguabijia.com 全反代 app-server, + 微信请求 coupon.shaguabijia.com/MP_verify_xxx.txt 验证域名归属;内容=文件名核心串。 + 放域名根目录(本端点即根路径),为微信网页授权(拿 openid)接入做准备。""" + return PlainTextResponse("F7wnRQ7xPbhVOWC8") + + +@router.get("/c/{code}", summary="群发短链落地(微信授权拿 openid + 记点击 + 跳转/淘宝落地页)") +def cps_landing(code: str, request: Request, db: Session = Depends(get_db)): + link = cps_link_repo.get_by_code(db, code) + if link is None: + return RedirectResponse(_FALLBACK_URL, status_code=302) + openid = request.cookies.get(_WX_OPENID_COOKIE) + # 微信内 + 还没拿到 openid + 配了服务号 → 先 base 静默授权拿 openid,再 302 回本页。 + # 非微信 / 已有 cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,绝不阻断领券)。 + if openid is None and settings.wx_oauth_active and _is_wechat(request): + redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb" + auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_base", f"base:{code}") + return RedirectResponse(auth_url, status_code=302) + _record(db, link, request, "visit", openid=openid) + if link.platform == "taobao": + activity = db.get(CpsActivity, link.activity_id) + image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE + has_uinfo = request.cookies.get(_WX_UINFO_COOKIE) == "1" + return HTMLResponse( + _taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo) + ) + # 美团短链 / 京东链接:直接 302 跳 + return RedirectResponse(link.target_url, status_code=302) + + +@router.get("/wx/oauth/cb", include_in_schema=False) +def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)): + """微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。 + 换 openid(+userinfo)→ upsert 用户 → 种 cookie → 302 回落地页。任何失败兜底回落地页, + 绝不阻断用户领券。""" + kind, _, orig_code = state.partition(":") + try: + token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...} + openid = token["openid"] + link = cps_link_repo.get_by_code(db, orig_code) + group_id = link.group_id if link else None + nickname = headimgurl = unionid = None + if kind == "uinfo" and "userinfo" in (token.get("scope") or ""): + info = wx_oauth.get_userinfo(token["access_token"], openid) + nickname, headimgurl, unionid = ( + info.get("nickname"), info.get("headimgurl"), info.get("unionid"), + ) + cps_wx_user_repo.upsert( + db, openid=openid, code=orig_code, group_id=group_id, + nickname=nickname, headimgurl=headimgurl, unionid=unionid, + ) + except Exception: + logger.exception("[wx_oauth] callback failed state=%s", state) + return RedirectResponse(f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302) + # userinfo 授权回来带 ?authed=1,落地页据此自动触发复制(把"点领券→授权→复制"衔接为一步) + target = f"/c/{orig_code}" + ("?authed=1" if kind == "uinfo" else "") + resp = RedirectResponse(target, status_code=302) + resp.set_cookie(_WX_OPENID_COOKIE, openid, max_age=_COOKIE_MAX_AGE, httponly=True, samesite="lax") + if nickname: + resp.set_cookie(_WX_UINFO_COOKIE, "1", max_age=_COOKIE_MAX_AGE, samesite="lax") + return resp + + +@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件,带 openid)") +def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict: + link = cps_link_repo.get_by_code(db, code) + if link is not None: + _record(db, link, request, "copy", openid=request.cookies.get(_WX_OPENID_COOKIE)) + return {"ok": True} + + +def _taobao_landing_html( + token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool +) -> str: + """淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。 + + image_url 经 html.escape 嵌入 (防注入);token 经 json.dumps 安全嵌入 JS。 + uinfo_url:已有 openid 但还没拿过昵称头像时,生成 userinfo 授权链接 —— 用户点「领券」 + 时先跳它(交互触发,避免微信快照页),授权回来自动复制。已拿过 / 无 openid 则为空,直接复制。 + """ + safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True) + uinfo_url = "" + if openid and not has_uinfo and settings.wx_oauth_active: + redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb" + uinfo_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}") + return ( + _TAOBAO_HTML + .replace("__IMAGE_URL__", safe_img) + .replace("__UINFO_URL__", json.dumps(uinfo_url)) + .replace("__TOKEN_JS__", json.dumps(token)) + ) + + +_TAOBAO_HTML = """ + + + + +淘宝闪购 · 天天领红包 + + + +淘宝闪购 天天领红包 +
+
+ + +""" diff --git a/app/api/v1/feedback.py b/app/api/v1/feedback.py index 1734923..79f1a61 100644 --- a/app/api/v1/feedback.py +++ b/app/api/v1/feedback.py @@ -1,7 +1,7 @@ """帮助与反馈 endpoint。 路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。 - POST / 提交反馈(multipart:content / contact 必填,images 可选 ≤4 张) + POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 ≤6 张) 截图复用 [app.core.media] 落盘到 /media/feedback/。 """ @@ -21,8 +21,8 @@ logger = logging.getLogger("shagua.feedback") router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"]) -_MAX_IMAGES = 4 -_CONTENT_MAX = 2000 +_MAX_IMAGES = 6 +_CONTENT_MAX = 200 _CONTACT_MAX = 128 @@ -31,7 +31,8 @@ async def submit_feedback( user: CurrentUser, db: DbSession, content: str = Form(...), - contact: str = Form(...), + # 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。 + contact: str = Form(default=""), images: list[UploadFile] = File(default=[]), ) -> FeedbackOut: content = content.strip() @@ -40,8 +41,6 @@ async def submit_feedback( raise HTTPException(status_code=400, detail="反馈内容不能为空") if len(content) > _CONTENT_MAX: raise HTTPException(status_code=400, detail="反馈内容过长") - if not contact: - raise HTTPException(status_code=400, detail="联系方式不能为空") if len(contact) > _CONTACT_MAX: raise HTTPException(status_code=400, detail="联系方式过长") diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py index 5c3c9d1..31908bd 100644 --- a/app/api/v1/platform.py +++ b/app/api/v1/platform.py @@ -2,6 +2,7 @@ 路由前缀 `/api/v1/platform`: GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。 + GET /flags 客户端运营 feature flag(比价/领券期广告开关等),客户端拉取后缓存。 展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。 """ @@ -12,9 +13,17 @@ import logging from fastapi import APIRouter, Query from app.api.deps import DbSession +from app.repositories import app_config 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 +from app.schemas.platform import ( + AdConfigPublicOut, + AppFlagsOut, + AppVersionOut, + PlatformStatsOut, + SavingsFeedItem, + SavingsFeedOut, +) logger = logging.getLogger("shagua.platform") @@ -35,3 +44,38 @@ def stats(db: DbSession) -> PlatformStatsOut: 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]) + + +@router.get("/flags", response_model=AppFlagsOut, summary="客户端运营 feature flag(不鉴权)") +def flags(db: DbSession) -> AppFlagsOut: + """客户端拉取运营开关并缓存(app 启动 / 每场比价开始时刷新)。不鉴权:开关非敏感, + 且比价无障碍服务取值时未必有登录态。值来自 app_config(admin 可改),空库回退默认。""" + return AppFlagsOut( + comparing_ad_enabled=bool(app_config.get_value(db, "comparing_ad_enabled")), + ) + + +@router.get("/ad-config", response_model=AdConfigPublicOut, summary="客户端拉广告配置(穿山甲ID+场景开关,不鉴权)") +def ad_config(db: DbSession) -> AdConfigPublicOut: + """客户端启动/每场广告前拉,缓存后用:app_id + 各位ID + 各场景开关。 + 不含验签密钥;空库回退默认(=客户端内置值,维持现状)。""" + c = app_config.get_ad_config(db) + return AdConfigPublicOut( + app_id=c["app_id"], + reward_code_id=c["reward_code_id"], + compare_feed_code_id=c["compare_feed_code_id"], + coupon_feed_code_id=c["coupon_feed_code_id"], + reward_enabled=c["reward_enabled"], + compare_ad_enabled=c["compare_ad_enabled"], + coupon_ad_enabled=c["coupon_ad_enabled"], + ) + + +@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)") +def app_version(db: DbSession) -> AppVersionOut: + """客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。 + 用 latest_version_code 与本机 versionCode 比;未配置(返回默认 0)时客户端视为已是最新。""" + data = app_config.get_app_version(db) + if not data: + return AppVersionOut() + return AppVersionOut(**data) diff --git a/app/api/v1/user.py b/app/api/v1/user.py index 6be689f..dc23388 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -18,6 +18,7 @@ from app.api.deps import CurrentUser, DbSession from app.core import media from app.repositories import onboarding as onboarding_repo from app.repositories import user as user_repo +from app.repositories import wallet as wallet_repo from app.schemas.auth import UserOut from app.schemas.user import ( OkResponse, @@ -87,6 +88,17 @@ def onboarding_status( @router.delete("", response_model=OkResponse, summary="注销账号(软删除)") def delete_account(user: CurrentUser, db: DbSession) -> OkResponse: + # 资金前置校验:注销是软删 + 匿名化,余额一旦留在 deleted 行就锁死(既退不出、新号也 + # 拿不回)。有可提现现金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。 + # (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。) + if wallet_repo.get_cash_balance_cents(db, user.id) > 0: + raise HTTPException( + status_code=409, detail="账户还有未提现的现金余额,请先提现后再注销" + ) + if wallet_repo.has_reviewing_withdraw(db, user.id): + raise HTTPException( + status_code=409, detail="有提现正在审核中,请等审核完成后再注销" + ) media.delete_avatar(user.avatar_url) user_repo.soft_delete_account(db, user) logger.info("delete account user_id=%d", user.id) diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 8b64c23..55a6f5e 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -35,6 +35,7 @@ from app.schemas.welfare import ( ExchangeResultOut, TransferAuthResultOut, TransferAuthStatusOut, + UnbindWechatRequest, UnbindWechatResultOut, WithdrawInfoOut, WithdrawOrderOut, @@ -151,9 +152,24 @@ def bind_wechat(req: BindWechatRequest, user: CurrentUser, db: DbSession) -> Bin @router.post("/unbind-wechat", response_model=UnbindWechatResultOut, summary="解绑微信(清空 openid)") -def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut: +def unbind_wechat( + user: CurrentUser, db: DbSession, req: UnbindWechatRequest | None = None +) -> UnbindWechatResultOut: + # 有「待审核(reviewing)」提现单时,首次解绑拦下要求确认:这类单还没打款,确认解绑(force) + # 时会被立即退回现金余额(refund_reviewing_withdraws_on_unbind),后台不再挂单,用户需先知情。 + # (pending 单转账已在途、解绑影响不到,不拦;见 has_reviewing_withdraw。) + force = req.force if req is not None else False + if not force and crud_wallet.has_reviewing_withdraw(db, user.id): + logger.info("unbind wechat needs_confirm(有待审核提现) user_id=%d", user.id) + return UnbindWechatResultOut( + bound=bool(user.wechat_openid), + needs_confirm=True, + message="你有提现正在审核中,解绑微信后这笔会自动退回到现金余额。确定解绑吗?", + ) + # 先把待审核提现立即退回现金(无则 no-op),再清 openid —— 让"解绑即退"名副其实 + refunded = crud_wallet.refund_reviewing_withdraws_on_unbind(db, user.id) crud_wallet.unbind_wechat_openid(db, user.id) - logger.info("unbind wechat ok user_id=%d", user.id) + logger.info("unbind wechat ok user_id=%d force=%s refunded_withdraws=%d", user.id, force, refunded) return UnbindWechatResultOut(bound=False) diff --git a/app/core/config.py b/app/core/config.py index 507ff8a..0520923 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -9,11 +9,16 @@ from functools import lru_cache from pathlib import Path from typing import Literal -from pydantic import Field +from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +# 生产环境 JWT secret 的最小可接受长度(字节)。HS256 推荐高熵随机串;<16 视为弱密钥。 +_MIN_PROD_SECRET_LEN = 16 +# 已知的占位默认值(代码里写死的 default),prod 下绝不能沿用。 +_INSECURE_SECRET_DEFAULTS = frozenset({"change-me", "change-me-admin", ""}) + class Settings(BaseSettings): model_config = SettingsConfigDict( @@ -90,6 +95,25 @@ class Settings(BaseSettings): """美团 CPS 凭证齐全(缺则接口返空,而非 502)。""" return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET) + # ===== 微信服务号(网页授权) ===== + # CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。 + # ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。 + WX_MP_APPID: str = "" + WX_MP_SECRET: str = "" + # 落地页微信网页授权【总开关】。默认关:服务号认证审核中/未就绪时,落地页走原逻辑 + # (不跳授权、不拿 openid),整套微信代码保留。审核通过后 .env 置 true + 重启即启用,无需改代码。 + WX_MP_OAUTH_ENABLED: bool = False + + @property + def wx_mp_configured(self) -> bool: + """服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。""" + return bool(self.WX_MP_APPID and self.WX_MP_SECRET) + + @property + def wx_oauth_active(self) -> bool: + """落地页是否真正发起微信授权 = 凭证齐全 且 总开关开。""" + return self.wx_mp_configured and self.WX_MP_OAUTH_ENABLED + # ===== 微信支付(商家转账到零钱 / 提现)===== # 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于 # 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。 @@ -135,8 +159,15 @@ class Settings(BaseSettings): # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。 - # PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。 + # 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自 + # 生成),但共用同一回调 URL → 多个位的 m-key 都要配上。验签时所有非空 m-key 逐个试、任一通过 + # 即接受(见 pangle.verify_callback_sign_any / 下面的 pangle_reward_secrets)。 PANGLE_CALLBACK_ENABLED: bool = False + # 推荐:每个激励位的 m-key 分开一行配,清晰不混淆(留空的忽略)。 + PANGLE_REWARD_SECRET_TEST: str = "" # 测试应用 激励位 104099649 + PANGLE_REWARD_SECRET_TEST_DEDICATED: str = "" # 测试应用 专属激励位 104127529 + PANGLE_REWARD_SECRET_PROD: str = "" # 正式应用 激励位 104099389 + # 旧用法:单个或逗号分隔的多个 m-key,仍兼容(会与上面三个命名项合并去重)。 PANGLE_REWARD_SECRET: str = "" # ⚠️ 仅本地联调:打开后开放 POST /api/v1/ad/test-grant,让(已登录的)客户端在没部署公网、 @@ -144,10 +175,23 @@ class Settings(BaseSettings): # 它让客户端能自助发奖 = 绕过反作弊,**生产必须保持 False**(默认 False;只在本地 .env 设 true)。 AD_REWARD_TEST_GRANT_ENABLED: bool = False + @property + def pangle_reward_secrets(self) -> list[str]: + """汇总所有 m-key 成列表(去空白、去空项、去重保序)。验签时逐个试、任一通过即接受 + (见 pangle.verify_callback_sign_any)。来源可混用:三个命名项 + 旧的逗号分隔 PANGLE_REWARD_SECRET。""" + raw = [ + *self.PANGLE_REWARD_SECRET.split(","), + self.PANGLE_REWARD_SECRET_TEST, + self.PANGLE_REWARD_SECRET_TEST_DEDICATED, + self.PANGLE_REWARD_SECRET_PROD, + ] + cleaned = [s.strip() for s in raw if s and s.strip()] + return list(dict.fromkeys(cleaned)) # 去重保序 + @property def pangle_callback_configured(self) -> bool: - """回调开关打开且验签密钥已配,才接受发奖回调。""" - return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET) + """回调开关打开且至少配了一个验签密钥,才接受发奖回调。""" + return bool(self.PANGLE_CALLBACK_ENABLED and self.pangle_reward_secrets) # ===== Pricebot 上游 (领券/比价业务透传目标) ===== # pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step @@ -189,6 +233,13 @@ class Settings(BaseSettings): # MVP 落地页就放 app-server 的 /media 静态目录下(dl.html)。 INVITE_LANDING_URL: str = "https://app-api.shaguabijia.com/media/dl.html" + # ===== CPS 群发短链跳转 ===== + # 群发券链接套一层我们的短链 /c/{code} 做点击统计后 302 跳美团。这里是写进 admin + # 生成链接里的对外跳转域名前缀。本地填 http://localhost:8770;生产**强烈建议用独立 + # 域名**(大量群发会被微信封,别用主 API 域名连累整个 App)。留空 → 跳转端点用请求 + # 的 Host 兜底拼绝对地址。 + CPS_REDIRECT_BASE: str = "" + # ===== CORS ===== CORS_ALLOW_ORIGINS: str = "" @@ -202,6 +253,29 @@ class Settings(BaseSettings): def is_prod(self) -> bool: return self.APP_ENV == "prod" + @model_validator(mode="after") + def _enforce_prod_secrets(self) -> "Settings": + """prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。 + + 只校验两个签发凭证:App 用户的 JWT_SECRET_KEY、后台的 ADMIN_JWT_SECRET——它们沿用默认值 + 时任何人都能伪造 access/admin token → 账号与后台失陷。INTERNAL_API_SECRET 默认空 = 内部端点 + 关闭(返 503),是安全的默认态,故不在此强制。dev 不触发,便于本地直接起。 + """ + if not self.is_prod: + return self + weak: list[str] = [] + for name in ("JWT_SECRET_KEY", "ADMIN_JWT_SECRET"): + value = getattr(self, name) + if value in _INSECURE_SECRET_DEFAULTS or len(value) < _MIN_PROD_SECRET_LEN: + weak.append(name) + if weak: + raise ValueError( + f"APP_ENV=prod 但检测到弱/默认密钥: {', '.join(weak)} —— 必须改成 " + f"≥{_MIN_PROD_SECRET_LEN} 位高熵随机串(否则 JWT 可被伪造 → 用户/后台账号失陷)。" + f"生成示例: python -c \"import secrets; print(secrets.token_urlsafe(48))\"" + ) + return self + @lru_cache(maxsize=1) def get_settings() -> Settings: diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 1546223..5a56c3a 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -11,12 +11,12 @@ from typing import Any from app.core import rewards as r -# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int +# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool CONFIG_DEFS: dict[str, dict[str, Any]] = { "signin_rewards": { - "default": list(r.SIGNIN_REWARDS), "label": "签到 14 天金币档位", + "default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位", "group": "签到", "type": "int_list", - "help": "第 1~14 天每天签到发的金币;断签重置回第 1 天。长度需为 14。", + "help": "第 1~7 天每天签到发的金币;断签重置回第 1 天。长度需为 7。", }, "min_exchange_coin": { "default": r.MIN_EXCHANGE_COIN, "label": "最低兑换金币", @@ -63,6 +63,25 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "signin_boost_coin": { "default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币", "group": "签到", "type": "int", - "help": "Day1-Day13 签到后看完激励视频额外发放的固定金币;Day14 不展示也不允许膨胀。", + "help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。", + }, + "comparing_ad_enabled": { + "default": True, "label": "比价/领券期信息流广告", + "group": "看广告", "type": "bool", + "help": ( + "开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);" + "关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的" + "远程开关(下一场比价生效),用于出问题时无需发版即可快速止血。debug 包可用本地开关覆盖。" + ), + }, + "withdraw_auto_reconcile_enabled": { + "default": True, "label": "提现自动对账", + "group": "钱包", "type": "bool", + "help": ( + "开启后后台 worker 每隔一段时间自动扫描超时仍「打款中」的提现单并归一化" + "(查微信/退款/撤单)。需部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=true 启动 worker " + "进程后此开关才起效;扫描间隔/超时阈值仍由 env 控制。关掉只停自动扫描," + "提现页「批量对账」手动按钮不受影响。" + ), }, } diff --git a/app/core/media.py b/app/core/media.py index f8c1ca4..192b186 100644 --- a/app/core/media.py +++ b/app/core/media.py @@ -77,6 +77,23 @@ def save_feedback_qr(data: bytes) -> str: return _save_named("feedback_qr", "qr", data) +def save_cps_image(admin_id: int, data: bytes) -> str: + """保存 CPS 活动落地页图,返回相对 URL(`/media/cps/`)。admin_id 入文件名便于追溯。""" + return _save_image("cps", admin_id, data) + + +def to_abs_media_url(rel_url: str | None) -> str | None: + """相对 `/media/...` → 基于 CPS_REDIRECT_BASE 的绝对 URL(CPS 落地页域名,跨域可访问)。 + + 已是绝对 URL / 空 / base 未配(dev) 时原样返回。CPS 活动图入库前转绝对,使落地页 + (coupon 域)与 admin-web(另一域,不 serve /media)都能直接 加载。 + """ + if not rel_url or rel_url.startswith(("http://", "https://")): + return rel_url + base = settings.CPS_REDIRECT_BASE.rstrip("/") + return f"{base}{rel_url}" if base else rel_url + + def _delete_managed(subdir: str, url: str | None) -> None: """删除本服务托管的某子目录下文件;外部 URL / 空值 / 非本子目录的不处理。""" prefix = f"{settings.MEDIA_URL_PREFIX}/{subdir}/" diff --git a/app/core/rewards.py b/app/core/rewards.py index 84de0c7..e9eac9a 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -17,11 +17,10 @@ def cn_today() -> date: return datetime.now(CN_TZ).date() -# ===== 签到:14 天循环,断签重置回第 1 天 ===== -# 第 1→14 天的金币奖励,签到逻辑用 cycle_day(1..14)索引。 +# ===== 签到:7 天循环,断签重置回第 1 天(原型 2026-06 由 14 天改 7 天一轮)===== +# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引;一轮 7 天累计 2500 金币(对原型 banner)。 SIGNIN_REWARDS: tuple[int, ...] = ( - 200, 120, 150, 180, 200, 250, 500, - 200, 250, 300, 350, 400, 500, 3000, + 200, 200, 300, 200, 400, 400, 800, ) SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS) @@ -57,13 +56,40 @@ WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元 TASK_ENABLE_NOTIFICATION = "enable_notification" # task_key -> 奖励金币 -# 打开消息提醒: 1000 金币(=¥0.1, 客户端原型展示口径; 量级与签到/里程碑相称)。 +# 打开消息提醒: 750 金币(客户端原型展示口径 2026-06 调 1000→750; 量级与签到/里程碑相称)。 # 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。 +# ⚠️打开消息提醒是「可重复领取」任务(2026-06):每次开启通知发一次,金额逐次减半(见 notification_reward), +# 这里的 750 是首次(基数)。客户端只在「通知权限关闭」时展示该任务,开着就隐藏。 TASK_REWARDS: dict[str, int] = { - TASK_ENABLE_NOTIFICATION: 1000, + TASK_ENABLE_NOTIFICATION: 750, } +def notification_reward(base: int, times_claimed: int) -> int: + """打开消息提醒第 (times_claimed+1) 次领取的金币。 + + 基数 base(=750)起,每次减半、四舍五入到整数、最低 1: + 750 → 375 → 188 → 94 → 47 → 24 → 12 → 6 → 3 → 2 → 1 → 1 … + (四舍五入用 (x+1)//2,匹配产品给的 750/375/188/94 序列。) + """ + r = max(1, base) + for _ in range(max(0, times_claimed)): + r = max(1, (r + 1) // 2) + return r + + +def notification_max_claims(base: int) -> int: + """打开消息提醒最多可领取的次数:减半到最低 1 那次为止(含),之后不再可领。 + + 防止「通知一直关着」时无限刷最低档 1 金币(发放上限闸,服务端硬限,不依赖客户端只在 + 通知关闭时才展示的约定)。base=750 时序列 750→…→1 在第 11 次落到 1,故上限 11 次。 + """ + n = 0 + while notification_reward(base, n) > 1: + n += 1 + return n + 1 # 含第一次减半到 1 的那一次 + + # ===== 比价战绩里程碑(累计成功比价 N 次,逐档解锁领金币)===== # 第 1→6 次的金币奖励(1-based:第 N 次比价解锁第 N 档)。值沿用客户端原型档位。 # 数据源是 comparison_record 里 status='success' 的条数;每档领一次, @@ -117,6 +143,13 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = ( (1.0, 11, None), ) +# 客户端可影响的 eCPM 可信上限(分/千次展示):信息流广告一期由客户端上报 eCPM,伪造天价 eCPM +# 可铸出天量金币(见 calculate_ad_reward_coin)。真实 eCPM 一般 <¥100 CPM(=10000 分),档位表顶档 +# 为 >¥400(=40000 分);取 ¥500 CPM=50000 分,留足真实头部余量又封死伪造值。钳在唯一计算口 +# calculate_ad_reward_coin,故 feed 与 reward_video(回退客户端上报 eCPM 时)一并护住;阈值设在所有 +# 真实值之上,不会少发正规奖励。 +AD_ECPM_MAX_FEN: int = 50_000 + def parse_ecpm_fen(ecpm: str | int | float | None) -> float: """解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。""" @@ -146,28 +179,33 @@ def ad_ecpm_factor(ecpm_yuan: float) -> float: 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) +def ad_lt_factor(count_after_this: int) -> float: + """LT 因子。count_after_this 是该账号累计第 N 条/份看视频奖励(不按天重置)。""" + count = max(1, 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: +def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: int) -> int: """按金币数值体系计算单份广告奖励金币。 eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。 单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT); - 再按 1 元=10000 金币取整。 + 再按 1 元=10000 金币取整。count_after_this 为账号累计第 N 次看视频(LT 因子用,不按天重置)。 + + eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值 + 会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于 + 所有真实值,不影响正规发奖。 """ - ecpm_yuan = parse_ecpm_yuan(ecpm) - yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this) + ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0) + yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this) return max(0, round(yuan * COIN_PER_YUAN)) -SIGNIN_BOOST_COIN: int = 2000 +# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。 +SIGNIN_BOOST_COIN: int = 3000 # ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)===== diff --git a/app/core/withdraw_reconcile_worker.py b/app/core/withdraw_reconcile_worker.py index eac6dd6..6d667f7 100644 --- a/app/core/withdraw_reconcile_worker.py +++ b/app/core/withdraw_reconcile_worker.py @@ -14,8 +14,11 @@ from sqlalchemy.exc import SQLAlchemyError from app.core.config import settings from app.db.session import SessionLocal from app.integrations.wxpay import WxPayNotConfiguredError +from app.repositories import app_config from app.repositories import wallet as wallet_repo +_AUTO_RECONCILE_KEY = "withdraw_auto_reconcile_enabled" + logger = logging.getLogger("shagua.withdraw_reconcile") _LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "withdraw_reconcile.lock" @@ -59,8 +62,15 @@ def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]: _LOCK_PATH.unlink() -def _reconcile_once(older_than_minutes: int) -> dict: +def _reconcile_once(older_than_minutes: int) -> dict | None: + """读运营开关(app_config):关着返回 None(本轮跳过),开着才真扫单。 + + env WITHDRAW_AUTO_RECONCILE_ENABLED 是部署级总闸(决定 worker 起不起); + 这里的 DB 开关是运营级日常开关,后台一改下一轮即生效、跨进程一致、无需重启。 + """ with SessionLocal() as db: + if not app_config.get_value(db, _AUTO_RECONCILE_KEY): + return None return wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes) @@ -78,16 +88,18 @@ async def _run_loop() -> None: async def _run_locked_loop(interval: int, older_than: int) -> None: logger.info( - "withdraw auto reconcile started interval=%ss older_than=%sm", + "withdraw auto reconcile worker started interval=%ss older_than=%sm " + "(runtime on/off via app_config '%s')", interval, older_than, + _AUTO_RECONCILE_KEY, ) try: while True: try: _touch_lock() result = await asyncio.to_thread(_reconcile_once, older_than) - if result["checked"] or result["resolved"]: + if result is not None and (result["checked"] or result["resolved"]): logger.info("withdraw auto reconcile result=%s", result) except WxPayNotConfiguredError: logger.warning("withdraw auto reconcile skipped: wxpay not configured") @@ -103,7 +115,8 @@ async def _run_locked_loop(interval: int, older_than: int) -> None: def start_withdraw_reconcile_worker() -> asyncio.Task | None: if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED: - logger.info("withdraw auto reconcile disabled") + # 部署级总闸关:worker 进程不启动(运营后台开关此时无效,需先在 env 打开)。 + logger.info("withdraw auto reconcile worker not started (env master switch off)") return None if not settings.wxpay_configured: logger.warning("withdraw auto reconcile enabled but wxpay not configured") diff --git a/app/integrations/cities.py b/app/integrations/cities.py new file mode 100644 index 0000000..a91fa55 --- /dev/null +++ b/app/integrations/cities.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""美团城市字典(地级市)读取。 + +数据源:app/integrations/data/meituan_cities.json,由 tools/gen_meituan_cities.py 从 +美团官方「城市字典」Excel 生成(359 个地级市:city_id / 城市名 / 省份)。 +实测一个地级市 cityId 已覆盖其下辖县级市供给(徐州 → 邳州 / 新沂 / 睢宁 …),故无区县层级。 + +- ETL(scripts/pull_meituan_coupons.py)遍历 all_cities() 全量抓取入库。 +- 读取侧将来「按城市过滤」时,也从这里取 city_id ↔ 城市名映射。 +""" +from __future__ import annotations + +import functools +import json +from pathlib import Path + +_JSON = Path(__file__).resolve().parent / "data" / "meituan_cities.json" + + +@functools.lru_cache(maxsize=1) +def all_cities() -> list[dict]: + """全部城市 [{city_id, name, province}, ...](359 个地级市)。""" + return json.loads(_JSON.read_text(encoding="utf-8")) + + +@functools.lru_cache(maxsize=1) +def _by_id() -> dict[str, dict]: + return {c["city_id"]: c for c in all_cities()} + + +def city_name(city_id: str) -> str | None: + """city_id → 城市名(查不到返回 None)。""" + c = _by_id().get(city_id) + return c["name"] if c else None diff --git a/app/integrations/data/meituan_cities.json b/app/integrations/data/meituan_cities.json new file mode 100644 index 0000000..8431142 --- /dev/null +++ b/app/integrations/data/meituan_cities.json @@ -0,0 +1,1797 @@ +[ + { + "city_id": "3NUYJKKJXPHVNZUHFK3HWUDHNM", + "name": "宣城市", + "province": "安徽省" + }, + { + "city_id": "LXXSHOY7LNK74ZK2SKVUXFY72Q", + "name": "阜阳市", + "province": "安徽省" + }, + { + "city_id": "ZEBF2LBJOEHGM4XGFPNW4IHBIA", + "name": "合肥市", + "province": "安徽省" + }, + { + "city_id": "WADWEY3GR6IARJLSNGQWG2KI4E", + "name": "滁州市", + "province": "安徽省" + }, + { + "city_id": "Z62QL3X66AOT6MSY7LB6LVO4CI", + "name": "芜湖市", + "province": "安徽省" + }, + { + "city_id": "M6QCWRCECRT6ZEJ6RERR6IWHFI", + "name": "淮南市", + "province": "安徽省" + }, + { + "city_id": "UP2NBJACSAO7FUH4XQQDMBOUE4", + "name": "马鞍山市", + "province": "安徽省" + }, + { + "city_id": "WLL7BSHUBPTLTMITFITJX2GKWY", + "name": "蚌埠市", + "province": "安徽省" + }, + { + "city_id": "ECTBNJ4KNNHPGXEVI4TBJNU7BY", + "name": "亳州市", + "province": "安徽省" + }, + { + "city_id": "KNEGDNOSRGNQPAMOMH54ATLIUU", + "name": "六安市", + "province": "安徽省" + }, + { + "city_id": "YH53FRR55H6VA4KXW36OJ7RNSE", + "name": "宿州市", + "province": "安徽省" + }, + { + "city_id": "LH6LX5DUVFPOFCAQ6NEGPWCLBI", + "name": "淮北市", + "province": "安徽省" + }, + { + "city_id": "RIFXWJ46SEJXE7EP6HZXEUIALU", + "name": "铜陵市", + "province": "安徽省" + }, + { + "city_id": "FK6F7KMO4WNARZKUXMMSBWGATM", + "name": "安庆市", + "province": "安徽省" + }, + { + "city_id": "M3CHGNNUSFSPS5HRV3W7MCBWFI", + "name": "黄山市", + "province": "安徽省" + }, + { + "city_id": "XFI6PM7SRO6NHBGYQUZEEA2WT4", + "name": "池州市", + "province": "安徽省" + }, + { + "city_id": "D2ZILSASTTGEFQWZIDTHA7PWU4", + "name": "澳门", + "province": "澳门特别行政区" + }, + { + "city_id": "WKV2HMXUEK634WP64CUCUQGM64", + "name": "北京市", + "province": "北京市" + }, + { + "city_id": "HPMKHLM3QR6EZGMY7GEI4H3QYQ", + "name": "泉州市", + "province": "福建省" + }, + { + "city_id": "6V523YRU54Y3PEAM2XPADNJM2U", + "name": "福州市", + "province": "福建省" + }, + { + "city_id": "HH3ZZCERPVQIYUZPW4A2U4JKZI", + "name": "莆田市", + "province": "福建省" + }, + { + "city_id": "BQ5RWEJS4O7W27SQMLPMRIRJDU", + "name": "宁德市", + "province": "福建省" + }, + { + "city_id": "TDN7XDQMZEP6ZCK6UO3VVMCSMM", + "name": "三明市", + "province": "福建省" + }, + { + "city_id": "ZKNTORN6YTZL2BXRYUSRGV3CHU", + "name": "厦门市", + "province": "福建省" + }, + { + "city_id": "Y2DI2QLZN5NNACMD3KI2DBR4IA", + "name": "龙岩市", + "province": "福建省" + }, + { + "city_id": "C4RS32I6QAHWLP55UQWI3N5LLY", + "name": "南平市", + "province": "福建省" + }, + { + "city_id": "5T23WDEOAP7RYNU4JNL2ZG7PDQ", + "name": "漳州市", + "province": "福建省" + }, + { + "city_id": "UHZBROATFB2KWNMNLS23DPRJBY", + "name": "定西市", + "province": "甘肃省" + }, + { + "city_id": "WLPAHIUOIVKS644QSN4V5ZY5XQ", + "name": "金昌市", + "province": "甘肃省" + }, + { + "city_id": "J7TO3UHZ57ABNUKIQUBCIJZQFA", + "name": "白银市", + "province": "甘肃省" + }, + { + "city_id": "GX277SS75375VEFYVCEHBL6ISA", + "name": "临夏回族自治州", + "province": "甘肃省" + }, + { + "city_id": "ORA3R7F2LSJHUOCIDTZCP54G7Q", + "name": "张掖市", + "province": "甘肃省" + }, + { + "city_id": "L6VQYYOJNTHSLW5JCR5SXBITDM", + "name": "武威市", + "province": "甘肃省" + }, + { + "city_id": "65OXQPQVFXNOYHXTMCE2RNFRL4", + "name": "兰州市", + "province": "甘肃省" + }, + { + "city_id": "IUIZYQ7E2SPMEAIGOUUNZWBQOA", + "name": "天水市", + "province": "甘肃省" + }, + { + "city_id": "BYBRGRDRV4NKAWCU6GWBIVLX3Q", + "name": "酒泉市", + "province": "甘肃省" + }, + { + "city_id": "KDST2VRETG6WK5SMJO2G2FN2RU", + "name": "嘉峪关市", + "province": "甘肃省" + }, + { + "city_id": "R3Q2XWVFVF4T2ADZZZMPZJRWBA", + "name": "庆阳市", + "province": "甘肃省" + }, + { + "city_id": "7FWNT2TP66SU4QEP6IBBMZWFNE", + "name": "陇南市", + "province": "甘肃省" + }, + { + "city_id": "T33S2GYGPVHAL5SR2FLSPTJSBE", + "name": "平凉市", + "province": "甘肃省" + }, + { + "city_id": "RR6KAWBLOKD4H2UINKYPFCPXO4", + "name": "甘南藏族自治州", + "province": "甘肃省" + }, + { + "city_id": "QKX4DS3CTJJG7SFW5SBHPLD42I", + "name": "茂名市", + "province": "广东省" + }, + { + "city_id": "JJZ75A32XCQNZU4IN2ZCEUGN3M", + "name": "梅州市", + "province": "广东省" + }, + { + "city_id": "SJSOXOSJASLUT6LBH4E32SUKKQ", + "name": "清远市", + "province": "广东省" + }, + { + "city_id": "SQQWAN5BQOVSX55S7EPF7QHMAU", + "name": "珠海市", + "province": "广东省" + }, + { + "city_id": "NGRJMW6JMRS6U2KUJEONSORAEY", + "name": "韶关市", + "province": "广东省" + }, + { + "city_id": "FAUMIGOSOET4E5WR5BL6P3OZHA", + "name": "佛山市", + "province": "广东省" + }, + { + "city_id": "JSBIH55ICFZQ2D3LEV47YMZ2NI", + "name": "河源市", + "province": "广东省" + }, + { + "city_id": "KOYAYPD2DBLDCF2ZI5GCW4LF6Y", + "name": "中山市", + "province": "广东省" + }, + { + "city_id": "647JGFPUYM4VWVLZCPSHT63XKQ", + "name": "汕头市", + "province": "广东省" + }, + { + "city_id": "AMOIPZW3Q2NMTDSEREFVM4SV74", + "name": "深圳市", + "province": "广东省" + }, + { + "city_id": "XIHEJY4H2CDZJCLIXDIN36BKXQ", + "name": "广州市", + "province": "广东省" + }, + { + "city_id": "UZ6OT4CYUR42KCTTED2KJW6EGA", + "name": "东莞市", + "province": "广东省" + }, + { + "city_id": "RM3HLOIUEYKTQ5O2JSVEKDMFRY", + "name": "阳江市", + "province": "广东省" + }, + { + "city_id": "C6XNJAZA6N3NNUDBUU3JIZPTDI", + "name": "潮州市", + "province": "广东省" + }, + { + "city_id": "AUPF3G2ULSV4TDT4L3NMHRTY6Y", + "name": "揭阳市", + "province": "广东省" + }, + { + "city_id": "7HIITKBPRXTVBA2FBBN443XITQ", + "name": "云浮市", + "province": "广东省" + }, + { + "city_id": "TO6ILZ7MPJMJN3S7W2SXMIFQQY", + "name": "江门市", + "province": "广东省" + }, + { + "city_id": "DTTBMGCIOMPCZY5NETUEKWJ6PY", + "name": "汕尾市", + "province": "广东省" + }, + { + "city_id": "HJY7JYWBA6FQY42RYSKX3RZTRI", + "name": "湛江市", + "province": "广东省" + }, + { + "city_id": "SLICHB4FBDVDLI53MR74WVUUNI", + "name": "肇庆市", + "province": "广东省" + }, + { + "city_id": "ZPX4JXJVBBYSSD2KTWHAPXO6NE", + "name": "惠州市", + "province": "广东省" + }, + { + "city_id": "JH4Q44RQA4EZ3Q6MHQVEVE7KZQ", + "name": "百色市", + "province": "广西壮族自治区" + }, + { + "city_id": "H2JXFEJFIL4PPFMYOS4MHZ5IBM", + "name": "崇左市", + "province": "广西壮族自治区" + }, + { + "city_id": "SXIRRISUOEGBU335AWT2ZFL6A4", + "name": "贵港市", + "province": "广西壮族自治区" + }, + { + "city_id": "HEQHKC4KP7YGGYVBZM5JEUI5AQ", + "name": "北海市", + "province": "广西壮族自治区" + }, + { + "city_id": "SKGG7KMFKVDIDKRVQEPTS7SIE4", + "name": "贺州市", + "province": "广西壮族自治区" + }, + { + "city_id": "N4WR7CWCULNA5Z35OTDJSZYDCU", + "name": "钦州市", + "province": "广西壮族自治区" + }, + { + "city_id": "T4RXX2WY6WPQYZEUXVJNZBXQZU", + "name": "梧州市", + "province": "广西壮族自治区" + }, + { + "city_id": "3R23AS3EIY7EYE2D5MWWORZODI", + "name": "河池市", + "province": "广西壮族自治区" + }, + { + "city_id": "57SMWWCV7X44E256P4I23OQ3AA", + "name": "防城港市", + "province": "广西壮族自治区" + }, + { + "city_id": "YHGHVIQ37UCTNQ4JKPQEAUWIQA", + "name": "桂林市", + "province": "广西壮族自治区" + }, + { + "city_id": "MQJZTM455OKZLAN5WQYUTA5TDE", + "name": "柳州市", + "province": "广西壮族自治区" + }, + { + "city_id": "C6FZPLB4NJQ6VUPSKDJH3EWQDM", + "name": "玉林市", + "province": "广西壮族自治区" + }, + { + "city_id": "BFSU5W6E5XBIDFPQLVPGRDSATY", + "name": "南宁市", + "province": "广西壮族自治区" + }, + { + "city_id": "CKXOQUZDNOVNME3PEBOY2CULQQ", + "name": "来宾市", + "province": "广西壮族自治区" + }, + { + "city_id": "LV32FV6IQTKFR7JIBEQMHRUVCA", + "name": "贵阳市", + "province": "贵州省" + }, + { + "city_id": "F26RCNKMFTZONJCSJ5C6FHVY74", + "name": "毕节市", + "province": "贵州省" + }, + { + "city_id": "G2LMYRWVRCK7BTD2WM4NWX4SYM", + "name": "黔南布依族苗族自治州", + "province": "贵州省" + }, + { + "city_id": "MFSOO3NBMB2PVLIVSI5EJK7MWY", + "name": "黔西南布依族苗族自治州", + "province": "贵州省" + }, + { + "city_id": "KWUL44L7SEMJGIMXCWSSEB3OOA", + "name": "遵义市", + "province": "贵州省" + }, + { + "city_id": "T2P3OFGQZUGR7D6TGRCMDF22GI", + "name": "铜仁市", + "province": "贵州省" + }, + { + "city_id": "AGUFUANSZNGC4TMOPZO65IRSPI", + "name": "六盘水市", + "province": "贵州省" + }, + { + "city_id": "2XOCOSNUAK3J5QDGTKIBWKK7KU", + "name": "安顺市", + "province": "贵州省" + }, + { + "city_id": "6XRTSAEYJTA2UBKXO4XEPQE5ZY", + "name": "黔东南苗族侗族自治州", + "province": "贵州省" + }, + { + "city_id": "YRMKRP2GOE2VMRS73N4YRIZUHY", + "name": "三亚市", + "province": "海南省" + }, + { + "city_id": "CJRGVLBNLJAVBJ4ZKKIZ3FZ2LY", + "name": "白沙黎族自治县", + "province": "海南省" + }, + { + "city_id": "5XOUAJ5Z4J4K7SVIQGXL2OM2JQ", + "name": "保亭黎族苗族自治县", + "province": "海南省" + }, + { + "city_id": "2UFQ6A2QRJPXPH3VOYEAQHMVSQ", + "name": "海口市", + "province": "海南省" + }, + { + "city_id": "TGCVXVS4M7NDQM4ROUDCVI6I3A", + "name": "承德市", + "province": "河北省" + }, + { + "city_id": "JEUP6QWCOXPSM3SQTINQCJKIGM", + "name": "衡水市", + "province": "河北省" + }, + { + "city_id": "Z442MNCW6BO2BBHIRUPRBACXPI", + "name": "唐山市", + "province": "河北省" + }, + { + "city_id": "RFE6R34GD4FY3LUKC2ICSF6AFY", + "name": "张家口市", + "province": "河北省" + }, + { + "city_id": "CWJN55M73VZDCYJEQ7AHDBWGGY", + "name": "沧州市", + "province": "河北省" + }, + { + "city_id": "DFL4ES776ECRGBYNOPLWKB247I", + "name": "雄安新区", + "province": "河北省" + }, + { + "city_id": "ZLSXYY34IHBHIC2NOVPQQBFTBE", + "name": "保定市", + "province": "河北省" + }, + { + "city_id": "3DO6Z2QRJQFMPLLDS55PG7DSBU", + "name": "石家庄市", + "province": "河北省" + }, + { + "city_id": "PR57XT25LI3246VGASEPSHP63E", + "name": "邢台市", + "province": "河北省" + }, + { + "city_id": "SKYLNH737BS56TD452FOKYL36U", + "name": "邯郸市", + "province": "河北省" + }, + { + "city_id": "5PWPERL7GQKJD6QPLR2TWUUM7E", + "name": "秦皇岛市", + "province": "河北省" + }, + { + "city_id": "5T2TGV6SJFVL3MO7HIMN2KTQTA", + "name": "廊坊市", + "province": "河北省" + }, + { + "city_id": "ECSTLZ7GP7IX3MB5EVNKS47MLE", + "name": "焦作市", + "province": "河南省" + }, + { + "city_id": "VTWW34QB2F5Q4LW7ISNUMWX7GY", + "name": "开封市", + "province": "河南省" + }, + { + "city_id": "D2NUN47NY4Q55X3UED4JMSI6CM", + "name": "周口市", + "province": "河南省" + }, + { + "city_id": "TR3XJFQR4EFYRRIX7TUQF3B26Y", + "name": "郑州市", + "province": "河南省" + }, + { + "city_id": "CKJGF5S6XMHW5ZJEBU7MJC47QA", + "name": "新乡市", + "province": "河南省" + }, + { + "city_id": "IFASZ625MCFJQKPLJ7EA2SMJUU", + "name": "商丘市", + "province": "河南省" + }, + { + "city_id": "SKXPYKTTRG4YAUHE2HZXWRWXGM", + "name": "鹤壁市", + "province": "河南省" + }, + { + "city_id": "G5LXE74CUHO2K6BBRN7Q5DSRJY", + "name": "漯河市", + "province": "河南省" + }, + { + "city_id": "SLNOAFJV2LTBSH7SJCRXJA36K4", + "name": "驻马店市", + "province": "河南省" + }, + { + "city_id": "65WO7LH7CFDUGKYMXQLRAYKKWM", + "name": "安阳市", + "province": "河南省" + }, + { + "city_id": "RIX2X7FAVTZCAQ5RT2C2CWK22Q", + "name": "南阳市", + "province": "河南省" + }, + { + "city_id": "SZOW5OY3U54SSY4WRC65VJUNTI", + "name": "平顶山市", + "province": "河南省" + }, + { + "city_id": "7VPIDDUS4P2LSZ6Q5S57MAQDEM", + "name": "信阳市", + "province": "河南省" + }, + { + "city_id": "4VYCRORUOZ4DC2U6S3CT6H6KWE", + "name": "洛阳市", + "province": "河南省" + }, + { + "city_id": "M5WNO2BQ3UGLLHBCG4NCEFPP5U", + "name": "濮阳市", + "province": "河南省" + }, + { + "city_id": "LY3O6PBPIWETMA3ZOL6ETY5UB4", + "name": "三门峡市", + "province": "河南省" + }, + { + "city_id": "FXXJLIRE72LS2W4OWWQVJMRJHA", + "name": "许昌市", + "province": "河南省" + }, + { + "city_id": "5XH353QTY3VWF2KYOCZCL3TOXY", + "name": "牡丹江市", + "province": "黑龙江省" + }, + { + "city_id": "2KGRZKF6IECV2W7K5J64Y2LY4M", + "name": "齐齐哈尔市", + "province": "黑龙江省" + }, + { + "city_id": "FASGWS5ADVSTFGJG6TGBZPZP6Q", + "name": "鹤岗市", + "province": "黑龙江省" + }, + { + "city_id": "2O6CDIXSWIKBXILZEEPKCS7MVI", + "name": "双鸭山市", + "province": "黑龙江省" + }, + { + "city_id": "OZ2PTOBYTBG57XJZMIC23QFJKM", + "name": "佳木斯市", + "province": "黑龙江省" + }, + { + "city_id": "FO24MQMULT3J5JW64APNXSQEPU", + "name": "伊春市", + "province": "黑龙江省" + }, + { + "city_id": "ETZ2HYWVU6U7SKU6G4JAO64RUQ", + "name": "黑河市", + "province": "黑龙江省" + }, + { + "city_id": "PR7EJNBY2VZBEUT36JAWE3TM7I", + "name": "七台河市", + "province": "黑龙江省" + }, + { + "city_id": "HADAAVLERKIW4SQGCTQYGX4AL4", + "name": "哈尔滨市", + "province": "黑龙江省" + }, + { + "city_id": "CGTU45YC5C3JYLHMA47USDPA7Y", + "name": "大庆市", + "province": "黑龙江省" + }, + { + "city_id": "T4W7SQIPOM4EYMEFFRAB5BSTII", + "name": "鸡西市", + "province": "黑龙江省" + }, + { + "city_id": "TYGZHNQL6YT7CX6EEG5DJQQHMA", + "name": "绥化市", + "province": "黑龙江省" + }, + { + "city_id": "OOSJTSN2CVUUCKD6XAB7EYYIPY", + "name": "大兴安岭地区", + "province": "黑龙江省" + }, + { + "city_id": "I3YF3EKZHIZTN6TZOTYTGZ2UXQ", + "name": "随州市", + "province": "湖北省" + }, + { + "city_id": "ESGVBOSTHW7JWEVCGYJUTEHEBQ", + "name": "宜昌市", + "province": "湖北省" + }, + { + "city_id": "MTJRWJ53XBW5SBTWHKNNZDLM7U", + "name": "十堰市", + "province": "湖北省" + }, + { + "city_id": "PXZLF2ISKQL5ACM67ZCBNOGDT4", + "name": "黄石市", + "province": "湖北省" + }, + { + "city_id": "44RMTOEHPUFXBHZXX4IQ4IRZVQ", + "name": "荆州市", + "province": "湖北省" + }, + { + "city_id": "OTKZGG743NFC474ADMMRX4ZOOA", + "name": "鄂州市", + "province": "湖北省" + }, + { + "city_id": "EXOUAZAQ73OEFAK72CHQ32GQHQ", + "name": "恩施土家族苗族自治州", + "province": "湖北省" + }, + { + "city_id": "SUCY7I72QJDZD7EBFXREIQ67SI", + "name": "咸宁市", + "province": "湖北省" + }, + { + "city_id": "QENSGB5R7HGYDXCG2LQZQTO3TU", + "name": "荆门市", + "province": "湖北省" + }, + { + "city_id": "OHIWL6SAE2PR4EJR4BOMLAE6FU", + "name": "武汉市", + "province": "湖北省" + }, + { + "city_id": "ZXCE4WV2CDVPQTA4HAOVELQMNE", + "name": "襄阳市", + "province": "湖北省" + }, + { + "city_id": "KEFN5OPSS4ZZF6NU2TTL72S6HE", + "name": "孝感市", + "province": "湖北省" + }, + { + "city_id": "ROAHLMQ67H6M5NDFVXROJG723E", + "name": "黄冈市", + "province": "湖北省" + }, + { + "city_id": "YBEBX2YYN4WPBNH6Z6C73DNE7I", + "name": "张家界市", + "province": "湖南省" + }, + { + "city_id": "45XGRKYGSCPE5VNRYF4FVJGFMM", + "name": "株洲市", + "province": "湖南省" + }, + { + "city_id": "LK3SEIBRU7GTDT4J2EPTLIO33U", + "name": "永州市", + "province": "湖南省" + }, + { + "city_id": "R4YXFIK53W5E556BSGSBJWS4DM", + "name": "郴州市", + "province": "湖南省" + }, + { + "city_id": "PQDO3RNADWXX75OWZW2GSXJ4SE", + "name": "怀化市", + "province": "湖南省" + }, + { + "city_id": "RRRT6QOJYEJ432L3F76ZN5NHCA", + "name": "长沙市", + "province": "湖南省" + }, + { + "city_id": "B6WPNMCZ3ENQSV4NFY5MSTPDAM", + "name": "岳阳市", + "province": "湖南省" + }, + { + "city_id": "KNDZW5EHDPKP2DX7HBLKP4DYLM", + "name": "益阳市", + "province": "湖南省" + }, + { + "city_id": "PA2GHG3XZ7I47HTKZ4YAFH3OYY", + "name": "湘西土家族苗族自治州", + "province": "湖南省" + }, + { + "city_id": "I7CNIUA5PYV2EHDEW3RGYT2R4U", + "name": "邵阳市", + "province": "湖南省" + }, + { + "city_id": "SRI2SU4FN66FMJJCKQOCZD72ZY", + "name": "常德市", + "province": "湖南省" + }, + { + "city_id": "H7UHHJAMQUL7UA5QEUTGKNSL3A", + "name": "湘潭市", + "province": "湖南省" + }, + { + "city_id": "EFB255OBTB2BUDZENR5UVIC7ZQ", + "name": "衡阳市", + "province": "湖南省" + }, + { + "city_id": "RDMANB4KCM3OJSNVGZWVYVME6E", + "name": "娄底市", + "province": "湖南省" + }, + { + "city_id": "LD37PDU5OB4UAV5QDOBMKG5YTY", + "name": "吉林市", + "province": "吉林省" + }, + { + "city_id": "EO3GF4XNF5RXWRPUVAT3KTQO4U", + "name": "四平市", + "province": "吉林省" + }, + { + "city_id": "4GD7OS4CAQABH5YIWVK5SKGHMY", + "name": "通化市", + "province": "吉林省" + }, + { + "city_id": "6DRI2R5VAWMYJHJPCJKUNMDYEQ", + "name": "延边朝鲜族自治州", + "province": "吉林省" + }, + { + "city_id": "TVBCNVGUND4MOUOOUXFGX7DIUA", + "name": "松原市", + "province": "吉林省" + }, + { + "city_id": "JYY62HSKBUVK5OU7KGJDKQ4RTA", + "name": "白城市", + "province": "吉林省" + }, + { + "city_id": "QEDUHKMZ36CHJTKRD6O2ZPLNBU", + "name": "长春市", + "province": "吉林省" + }, + { + "city_id": "4EADVCBJMZ5UBH2FVRT6QCLS2U", + "name": "辽源市", + "province": "吉林省" + }, + { + "city_id": "EUQD5EGS2LR5KJSFNG6PPSIHHI", + "name": "白山市", + "province": "吉林省" + }, + { + "city_id": "YLTIISPCLBEGTZZX3WUWAD7WDE", + "name": "淮安市", + "province": "江苏省" + }, + { + "city_id": "L6U5DZP6MESXPMHOHCDMJS55O4", + "name": "宿迁市", + "province": "江苏省" + }, + { + "city_id": "HQMLYA7TDGMYQAXFCDUXBZPYHI", + "name": "镇江市", + "province": "江苏省" + }, + { + "city_id": "K6XJ4UN65ZD6XQKYEG5YN7HCRI", + "name": "盐城市", + "province": "江苏省" + }, + { + "city_id": "36I4X3EZZU4EHOSCLQI5OAKKBE", + "name": "南通市", + "province": "江苏省" + }, + { + "city_id": "S3GWFQU6QAVRDKLJT77LD6OFLE", + "name": "泰州市", + "province": "江苏省" + }, + { + "city_id": "IO6F4AFGAVIFRGYTZEC4TXM7W4", + "name": "无锡市", + "province": "江苏省" + }, + { + "city_id": "NUXNK2VOFSD2JFTEO2AMWX6NSU", + "name": "扬州市", + "province": "江苏省" + }, + { + "city_id": "TEVZU6CU6SK57HFW7DFNGMQ44A", + "name": "南京市", + "province": "江苏省" + }, + { + "city_id": "UTYSRBQ4FSB7XLWCF3Z2HTKNUA", + "name": "常州市", + "province": "江苏省" + }, + { + "city_id": "OCZOBCJDEXKE7KBN3BD7AYQG2Q", + "name": "徐州市", + "province": "江苏省" + }, + { + "city_id": "6LIBPJGZROLXE3CLZGJRYMYBOU", + "name": "连云港市", + "province": "江苏省" + }, + { + "city_id": "FS4PIU74F7QKYARDWR5ZMOLICI", + "name": "苏州市", + "province": "江苏省" + }, + { + "city_id": "R2F4OWUO65HYZW2IQIKINORZ7Y", + "name": "赣州市", + "province": "江西省" + }, + { + "city_id": "YW346BTN3VFYNRC3744UR5MZXY", + "name": "抚州市", + "province": "江西省" + }, + { + "city_id": "QR3FDR26U2EJIXOMBHL7IJLQSA", + "name": "南昌市", + "province": "江西省" + }, + { + "city_id": "OAJHJL7L7VNW2Q5UXRE7F4CUJQ", + "name": "九江市", + "province": "江西省" + }, + { + "city_id": "OMH7D45R4DX2KNHLV3G2UP56OY", + "name": "景德镇市", + "province": "江西省" + }, + { + "city_id": "YSB2PAEROB2IZSJZFVFH7KJPEI", + "name": "鹰潭市", + "province": "江西省" + }, + { + "city_id": "5OYAMNORCXKYA6UF7DW6KFFBIU", + "name": "上饶市", + "province": "江西省" + }, + { + "city_id": "SMHZOYKE7BXQJ2NT6Q24TFMLEQ", + "name": "吉安市", + "province": "江西省" + }, + { + "city_id": "2RZV26OUPKUHUJ5ZPB673VDGZU", + "name": "萍乡市", + "province": "江西省" + }, + { + "city_id": "232VHZEEZ6SXACE4AC5HQ4ZTFQ", + "name": "新余市", + "province": "江西省" + }, + { + "city_id": "QRLM74YXNDW2QDBWLTFGEMXK2I", + "name": "宜春市", + "province": "江西省" + }, + { + "city_id": "S6OHUVUKIIWPVMQD44RREUMNT4", + "name": "葫芦岛市", + "province": "辽宁省" + }, + { + "city_id": "DQQ4OIFUGFYJY3XZRK5VDWMLCA", + "name": "辽阳市", + "province": "辽宁省" + }, + { + "city_id": "S4YXGFEYXEUG6ISZ6O337OPVSI", + "name": "阜新市", + "province": "辽宁省" + }, + { + "city_id": "D3JHM7A4CG6RJMBD7YRDS5JOYU", + "name": "盘锦市", + "province": "辽宁省" + }, + { + "city_id": "VTRWMOSS6PCUYUAIPG6VPBKUUQ", + "name": "营口市", + "province": "辽宁省" + }, + { + "city_id": "ZPHFGWBIEVLKP5CVZNZUB3CRT4", + "name": "朝阳市", + "province": "辽宁省" + }, + { + "city_id": "NGYYULZ4UAGD3Q2PG726FFXSHU", + "name": "抚顺市", + "province": "辽宁省" + }, + { + "city_id": "Q5BRTSW752VSHIAKLLL7KL5TNA", + "name": "锦州市", + "province": "辽宁省" + }, + { + "city_id": "ZGV3WNPOSS7J4ZWBP6ZQG46BNM", + "name": "沈阳市", + "province": "辽宁省" + }, + { + "city_id": "XEX676YYMTYIV5QPIUZB4TA7IY", + "name": "本溪市", + "province": "辽宁省" + }, + { + "city_id": "PRTEQZMLNLQNZXJHRCYYLWZB4E", + "name": "丹东市", + "province": "辽宁省" + }, + { + "city_id": "4GN4WF6UQRFU64T4FVZPRDXRWQ", + "name": "鞍山市", + "province": "辽宁省" + }, + { + "city_id": "3QTZDFLJFSLLOOVCZ65PSDAVOU", + "name": "铁岭市", + "province": "辽宁省" + }, + { + "city_id": "CC4ZTMKKXI73ZEVT5QQTJN5SMM", + "name": "大连市", + "province": "辽宁省" + }, + { + "city_id": "3MBJEFDLAVOMQZ7L7CM5MNSYKA", + "name": "鄂尔多斯市", + "province": "内蒙古自治区" + }, + { + "city_id": "5NOS4YC5WO2IZCQPVB6MCBYDJ4", + "name": "呼和浩特市", + "province": "内蒙古自治区" + }, + { + "city_id": "OQNIP675H7L5R64652BH7KHUOQ", + "name": "通辽市", + "province": "内蒙古自治区" + }, + { + "city_id": "4WA6I63MGVINV5DNLNWRRHCDDM", + "name": "阿拉善盟", + "province": "内蒙古自治区" + }, + { + "city_id": "ELI6BDJBAN6RCYTETMK2EX2UKU", + "name": "乌兰察布市", + "province": "内蒙古自治区" + }, + { + "city_id": "PV5ZAAXFW2DZCVZKCF4I4KK7BQ", + "name": "巴彦淖尔市", + "province": "内蒙古自治区" + }, + { + "city_id": "YU6UUT6G6T6AMWTJFECDIUQFEQ", + "name": "乌海市", + "province": "内蒙古自治区" + }, + { + "city_id": "V4MYANW5QFZCXG3FIDPXA3HOTE", + "name": "呼伦贝尔市", + "province": "内蒙古自治区" + }, + { + "city_id": "S5DCFJWJ7J3MJSLY2PHKWLNPOQ", + "name": "包头市", + "province": "内蒙古自治区" + }, + { + "city_id": "LY7SAZRFSJJMRU3JEO5SKNKIVM", + "name": "兴安盟", + "province": "内蒙古自治区" + }, + { + "city_id": "NM2XP54CNQCFOILKACYEQWUSGM", + "name": "锡林郭勒盟", + "province": "内蒙古自治区" + }, + { + "city_id": "S5G3IO75IDEJPZQA6VFM3OYPDI", + "name": "赤峰市", + "province": "内蒙古自治区" + }, + { + "city_id": "UUFUUPM5RT6ZU5UKILQC5YQV54", + "name": "吴忠市", + "province": "宁夏回族自治区" + }, + { + "city_id": "VMSRLIATK44WQXQEWAL63AXJ3M", + "name": "固原市", + "province": "宁夏回族自治区" + }, + { + "city_id": "4GWWCAAKGNJV2SMQPSWWZNCGYY", + "name": "中卫市", + "province": "宁夏回族自治区" + }, + { + "city_id": "6IE7GEETBQEF7GUSGU2FLIUEEM", + "name": "石嘴山市", + "province": "宁夏回族自治区" + }, + { + "city_id": "VI4YIH3URSON4Q4MWOEESXJ56Q", + "name": "银川市", + "province": "宁夏回族自治区" + }, + { + "city_id": "SIE4ED6QWVRT727GEHWBFH3DAA", + "name": "海东市", + "province": "青海省" + }, + { + "city_id": "JNJH6OJZIOQKXDXWW5ZGEHG5MA", + "name": "海西蒙古族藏族自治州", + "province": "青海省" + }, + { + "city_id": "MJADYNCKQNDJU2TXACTDP5I52M", + "name": "海北藏族自治州", + "province": "青海省" + }, + { + "city_id": "LRGFXIVB6RJWQWYAFH7EIUHCPE", + "name": "黄南藏族自治州", + "province": "青海省" + }, + { + "city_id": "J4TG3PCK2ZEMNEUMIPZF32UNQY", + "name": "果洛藏族自治州", + "province": "青海省" + }, + { + "city_id": "2YS5POGG53LKZGFBIUMDWP57SM", + "name": "玉树藏族自治州", + "province": "青海省" + }, + { + "city_id": "GRZMJEZCA2DNCZK3O6ZSUHMRPM", + "name": "西宁市", + "province": "青海省" + }, + { + "city_id": "NBFQIACRBBCAH5AZWJ5LVT7AU4", + "name": "海南藏族自治州", + "province": "青海省" + }, + { + "city_id": "MQUKCLQ76P4FRRECDBA3HBKT7Q", + "name": "滨州市", + "province": "山东省" + }, + { + "city_id": "633FVSBDDBM5WSMXSKOCX6QC5I", + "name": "潍坊市", + "province": "山东省" + }, + { + "city_id": "4434FVT3PXLMV6UAWLEW6O3M5A", + "name": "菏泽市", + "province": "山东省" + }, + { + "city_id": "P7PK4UBVCOHW3PI6IPEIA54DLY", + "name": "济南市", + "province": "山东省" + }, + { + "city_id": "I5M6JGTGSQEWX6HL7E5I6GRBAY", + "name": "德州市", + "province": "山东省" + }, + { + "city_id": "V562AOMBVU5NG5GB3EPK6U42XY", + "name": "烟台市", + "province": "山东省" + }, + { + "city_id": "4OSPHTE5TD24J6DYGR6DXMEDKY", + "name": "淄博市", + "province": "山东省" + }, + { + "city_id": "227TLAVTUJABWPJD4S4ZECJ3FY", + "name": "临沂市", + "province": "山东省" + }, + { + "city_id": "DAEZKZU32ZAPJGUTA6LLGO3WTY", + "name": "聊城市", + "province": "山东省" + }, + { + "city_id": "LBRRK2EOYJN5MLYJWT4R3QBSXM", + "name": "东营市", + "province": "山东省" + }, + { + "city_id": "AB6PBGCDBTNTG4KUQROY2FJ4GY", + "name": "枣庄市", + "province": "山东省" + }, + { + "city_id": "EVANGU7WZCVRAAM6NWTDJVP7SU", + "name": "济宁市", + "province": "山东省" + }, + { + "city_id": "GNUEGWZ3OKRWAKKVJ5THHHX6YY", + "name": "泰安市", + "province": "山东省" + }, + { + "city_id": "F3VWSF4ART2FYYBBOZYWKRXTUI", + "name": "青岛市", + "province": "山东省" + }, + { + "city_id": "KD6MNWWLVKB4E655XMV6MMA3KE", + "name": "日照市", + "province": "山东省" + }, + { + "city_id": "LHYVF4LBCOZ34G3WNZYUVEIQGA", + "name": "威海市", + "province": "山东省" + }, + { + "city_id": "ENVYDMYGDO3BMDXSAVQYZXLX74", + "name": "阳泉市", + "province": "山西省" + }, + { + "city_id": "UXOUG4UIF7ZRJYCNMQJ3LDN5FY", + "name": "临汾市", + "province": "山西省" + }, + { + "city_id": "4NZPT6Z35BMYACJ2HZGHUWRJ6E", + "name": "吕梁市", + "province": "山西省" + }, + { + "city_id": "KSNXQME2A3VFHCE3DM3SFZKIJQ", + "name": "晋城市", + "province": "山西省" + }, + { + "city_id": "HDOX7WKYSHJKEHET6TUYMVCTMQ", + "name": "太原市", + "province": "山西省" + }, + { + "city_id": "DFJIZVXJGBGBIABPSL3DGMIDIE", + "name": "长治市", + "province": "山西省" + }, + { + "city_id": "5KQYYTJR2EMP653QIALMA6LXXI", + "name": "忻州市", + "province": "山西省" + }, + { + "city_id": "T76EOJA332RIHML7B6LYS5LF4U", + "name": "朔州市", + "province": "山西省" + }, + { + "city_id": "HVX67CKT5TS6GPRDFDYOOLK4PE", + "name": "大同市", + "province": "山西省" + }, + { + "city_id": "S4NGXQJDOH7E4IHDWOH3EK6IIE", + "name": "晋中市", + "province": "山西省" + }, + { + "city_id": "GWDLZXLAWU54FKQ6G3HRQRR7E4", + "name": "运城市", + "province": "山西省" + }, + { + "city_id": "3FFTTN5PPV7MBCE5AGY2NGYOOI", + "name": "安康市", + "province": "陕西省" + }, + { + "city_id": "GACVPL3SWO3ZKH73JMJV6YI4NY", + "name": "延安市", + "province": "陕西省" + }, + { + "city_id": "6KPS7VRMW57P2DAC6OPR4ISHQQ", + "name": "商洛市", + "province": "陕西省" + }, + { + "city_id": "OMMF6XLNDNYWG5TBSNTWO2ZJZ4", + "name": "渭南市", + "province": "陕西省" + }, + { + "city_id": "EALFXGMWYRS6E6TWXQ2K3YHV4M", + "name": "咸阳市", + "province": "陕西省" + }, + { + "city_id": "WFG7U6JNUWDIS5ZZYM4FSM5C64", + "name": "榆林市", + "province": "陕西省" + }, + { + "city_id": "R3VBMYTCF5LVHO35X3MYJQFOOE", + "name": "宝鸡市", + "province": "陕西省" + }, + { + "city_id": "RQOWP7C234IS4RKSHB26IYZ5IU", + "name": "西安市", + "province": "陕西省" + }, + { + "city_id": "K4YU6B4T5GZLRWVHGVCR3576HI", + "name": "铜川市", + "province": "陕西省" + }, + { + "city_id": "VVFVAPLKSCN5KN4Q6RK2GPGUUA", + "name": "汉中市", + "province": "陕西省" + }, + { + "city_id": "2QSF6IG3KMDXWO5VP7FXHMMKXA", + "name": "上海市", + "province": "上海市" + }, + { + "city_id": "X3JCRNIPTCUU6DGOFFJ4MUK37M", + "name": "眉山市", + "province": "四川省" + }, + { + "city_id": "GQ24IZNTZJ3PUB5FDAMDA4W7UI", + "name": "攀枝花市", + "province": "四川省" + }, + { + "city_id": "6B6WT62WHBZRHPQUT7BAD2N6ZI", + "name": "泸州市", + "province": "四川省" + }, + { + "city_id": "HJ35P4KXL442MLIII7PFFWUNAE", + "name": "雅安市", + "province": "四川省" + }, + { + "city_id": "K4A6VSJH2AJYT46LMUSVQZPCCU", + "name": "资阳市", + "province": "四川省" + }, + { + "city_id": "646ZNPATOOM3MHI3LDU6HI4KFI", + "name": "阿坝藏族羌族自治州", + "province": "四川省" + }, + { + "city_id": "MU735ZDBFPXRQDUZ3I35JK3XEU", + "name": "内江市", + "province": "四川省" + }, + { + "city_id": "4WPGGJ63USY77GSRN2PPFCYKPQ", + "name": "广安市", + "province": "四川省" + }, + { + "city_id": "O4FFS4DALDAAKIFAUH4F5V5VS4", + "name": "宜宾市", + "province": "四川省" + }, + { + "city_id": "IRFJVK2KXBE6BZ7CSN4UFCI624", + "name": "绵阳市", + "province": "四川省" + }, + { + "city_id": "NELFD7FEKKUNDJ46VLD55SMDCE", + "name": "甘孜藏族自治州", + "province": "四川省" + }, + { + "city_id": "TKMVEUPZSQCXNRZPBEIK3F45AI", + "name": "遂宁市", + "province": "四川省" + }, + { + "city_id": "VQW7DPB4KTUI65COJBO3NODU24", + "name": "巴中市", + "province": "四川省" + }, + { + "city_id": "STP4ELXTVGQSB572LFRFJRIUUY", + "name": "南充市", + "province": "四川省" + }, + { + "city_id": "6ST5EX2JVXUCLR5GP5VEFSKN5M", + "name": "成都市", + "province": "四川省" + }, + { + "city_id": "UWNFCMW3HYJRALQI2MJH6EM2O4", + "name": "德阳市", + "province": "四川省" + }, + { + "city_id": "J5ZYU7XRV6CHSJAGOPQKS5YXNA", + "name": "达州市", + "province": "四川省" + }, + { + "city_id": "KYJTF5S746T35RFBMR65BLGM6U", + "name": "凉山彝族自治州", + "province": "四川省" + }, + { + "city_id": "RDUXR23XROLB4NGRVDDLXFXDSE", + "name": "乐山市", + "province": "四川省" + }, + { + "city_id": "4TUBIBHMVESJUCGMTUSLJPHXWI", + "name": "广元市", + "province": "四川省" + }, + { + "city_id": "AXQL57AO27NCHYMEOLRHAAKMTA", + "name": "自贡市", + "province": "四川省" + }, + { + "city_id": "4RXX566RZORCXS6HLEX3DIICSM", + "name": "花莲县", + "province": "台湾" + }, + { + "city_id": "BD5Y7SISWSSQGP3HTVPU6TXAH4", + "name": "台东县", + "province": "台湾" + }, + { + "city_id": "I4DNWLECRYOJAZLGYQB7PBBJXQ", + "name": "台中市", + "province": "台湾" + }, + { + "city_id": "GIZQIESFOMAEQSDQKOEQ5RTTPA", + "name": "南投县", + "province": "台湾" + }, + { + "city_id": "MPM6M2C634FAW7KYG3KIHERDTU", + "name": "彰化县", + "province": "台湾" + }, + { + "city_id": "NH2NK6JVOBBYYTK2G53ADWLX4Y", + "name": "苗栗县", + "province": "台湾" + }, + { + "city_id": "DW2Q2R2UEEDNQA7IHBGYV423K4", + "name": "新竹市", + "province": "台湾" + }, + { + "city_id": "FX5AOPFRPHGHHYZB4XNIPXLNNM", + "name": "新北市", + "province": "台湾" + }, + { + "city_id": "UVNZNB6G4M35RV3IGRUN6OXMXE", + "name": "屏东县", + "province": "台湾" + }, + { + "city_id": "MPB3M2YK24ZORO3EDCJ6UDWIGQ", + "name": "基隆市", + "province": "台湾" + }, + { + "city_id": "EBHVITJPDHJEEMMZTM4TD4UVRU", + "name": "台北市", + "province": "台湾" + }, + { + "city_id": "FQS4PZNCTQEX6I5F34Z2AGJUZM", + "name": "高雄市", + "province": "台湾" + }, + { + "city_id": "WVOJ636Q7MGT6RMN6QYQ4SZWIE", + "name": "嘉义市", + "province": "台湾" + }, + { + "city_id": "QRLER4EEMMKYGQLER2RWEQA74E", + "name": "台南市", + "province": "台湾" + }, + { + "city_id": "K2LHF64R2P4OJ7MDHJ6J2NSTPE", + "name": "桃园市", + "province": "台湾" + }, + { + "city_id": "BILG6LJIWUCTXZ6CDPXYAVM6XI", + "name": "澎湖县", + "province": "台湾" + }, + { + "city_id": "3FYRA3O2HUMLIQAAJQCPX2TETE", + "name": "宜兰县", + "province": "台湾" + }, + { + "city_id": "4MW6X22PAPVMHB6SBGF3RYS324", + "name": "天津市", + "province": "天津市" + }, + { + "city_id": "YEYPP4SQOBXU5UCNDN7ORSR6DI", + "name": "拉萨市", + "province": "西藏自治区" + }, + { + "city_id": "UNE6UPENGWQDOWGABDJEAQ2FEY", + "name": "山南市", + "province": "西藏自治区" + }, + { + "city_id": "VDXKN2YCIUPOHBZKKQHPN6HJWE", + "name": "林芝市", + "province": "西藏自治区" + }, + { + "city_id": "EAWIMNI77H72EYSOAYV3M76CB4", + "name": "阿里地区", + "province": "西藏自治区" + }, + { + "city_id": "HCF6UHTXOOKIOA43AIJH3ARKXA", + "name": "昌都市", + "province": "西藏自治区" + }, + { + "city_id": "Y47QI3KJY352QV3VOPXHM2IDWU", + "name": "日喀则市", + "province": "西藏自治区" + }, + { + "city_id": "R4UWJX44GVAA54NFKHT4Y4ZC5A", + "name": "那曲市", + "province": "西藏自治区" + }, + { + "city_id": "2D37GB5XUALJDXONWJIGXV3QXU", + "name": "香港", + "province": "香港特别行政区" + }, + { + "city_id": "PA5W7Z255K3EGYA4LTA7BGLHRA", + "name": "巴音郭楞蒙古自治州", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "RJZOCU5ECQCOLCOCHJH2UNLQJM", + "name": "哈密市", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "RYK6AR3VDQJFXZLX3MHYFV5VAI", + "name": "塔城地区", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "MXUVGU5NPVTNINAKKPNLWUQ54Q", + "name": "博尔塔拉蒙古自治州", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "T5RKMSH5VIGRTHDZOKV2EIKPIM", + "name": "伊犁哈萨克自治州", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "NABMKFZPOUCZMS4TUVJSZ24DNA", + "name": "克孜勒苏柯尔克孜自治州", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "2YQBXWNFYVJX4NU6WXB5II6X34", + "name": "昌吉回族自治州", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "DBKCQCCQU2URQG2EP5ZNPKBETY", + "name": "乌鲁木齐市", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "DON6KYBCR2XJQOJQGOZTYV4RMM", + "name": "阿克苏地区", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "WKG47NEVYII5JISZJN7QSI2BDQ", + "name": "克拉玛依市", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "DWLK6D3OUOXOVQHSEJVTRIDRAI", + "name": "喀什地区", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "SAPKF2PQGJD4UMVJZTC3IZKI64", + "name": "阿勒泰地区", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "ARWSLGG54LGUGN3XMIWW76NW34", + "name": "吐鲁番市", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "VQCWAKL6ADHYFTSVPBGPDFSB2I", + "name": "北屯市", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "ES5A6MOROAG6F2XAQ25TYYPWUE", + "name": "铁门关市", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "36IUY52AESIPF4QEQAR2RTNQYA", + "name": "和田地区", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "Z26KSUL6ULS65ITZNUCRRWBYJM", + "name": "文山壮族苗族自治州", + "province": "云南省" + }, + { + "city_id": "XBBUUATPBD2IUJR47FCKVUXB2I", + "name": "昭通市", + "province": "云南省" + }, + { + "city_id": "TK2LP3JCYYMPTV4OA3WJCH7UQ4", + "name": "怒江傈僳族自治州", + "province": "云南省" + }, + { + "city_id": "ISJ4FESOYKCQ5LXOQO6NL7TQHA", + "name": "曲靖市", + "province": "云南省" + }, + { + "city_id": "ELBUBVI5UMUIEQGIOAHPMESXFA", + "name": "西双版纳傣族自治州", + "province": "云南省" + }, + { + "city_id": "QHOYHEGZM4WSJZPFEU6FCBYIWY", + "name": "玉溪市", + "province": "云南省" + }, + { + "city_id": "4G4SPJ7MVHMYZAWMQ4642PMLVI", + "name": "保山市", + "province": "云南省" + }, + { + "city_id": "HCHRV2LGJ2TJ4X6BWNWI2IMID4", + "name": "普洱市", + "province": "云南省" + }, + { + "city_id": "IS4Q6NASBWHO3RFO7UCIWOXVI4", + "name": "昆明市", + "province": "云南省" + }, + { + "city_id": "TDJZOAZFQUQPYRR5BHOJTG6RWM", + "name": "红河哈尼族彝族自治州", + "province": "云南省" + }, + { + "city_id": "EIYC62RNU4SHQW3RLAMDPTQYTI", + "name": "大理白族自治州", + "province": "云南省" + }, + { + "city_id": "BA3XFPITAYKBUWDKU3QONRHBC4", + "name": "德宏傣族景颇族自治州", + "province": "云南省" + }, + { + "city_id": "6P6FFFO6C5MLNCVRJAJUICSVQI", + "name": "临沧市", + "province": "云南省" + }, + { + "city_id": "OXHMWH2TSIDI7BQ43EHAMXJ6N4", + "name": "丽江市", + "province": "云南省" + }, + { + "city_id": "QQPDT4LBI2K2KMBNXZ6YH7X2FI", + "name": "楚雄彝族自治州", + "province": "云南省" + }, + { + "city_id": "XBG4EJAWCRJL2TDPNJ23PTFYHQ", + "name": "迪庆藏族自治州", + "province": "云南省" + }, + { + "city_id": "DINNCH54AP74TJ62MICEYAZP74", + "name": "宁波市", + "province": "浙江省" + }, + { + "city_id": "XYTSLYGB2ETU6HG7GIXA7X5SOE", + "name": "嘉兴市", + "province": "浙江省" + }, + { + "city_id": "NNAALJZXGAWALR3LGE2V4UZT6U", + "name": "丽水市", + "province": "浙江省" + }, + { + "city_id": "H5UOJ5MQYJ737GS3TXYN2OJHUU", + "name": "杭州市", + "province": "浙江省" + }, + { + "city_id": "HG5VQGOMSCEGNXJXKO6XCNCHMY", + "name": "湖州市", + "province": "浙江省" + }, + { + "city_id": "LJ2SWEPRINTYDH5A2QHRMI5US4", + "name": "衢州市", + "province": "浙江省" + }, + { + "city_id": "TW4RRM62TDA7WWU77FDSLSAXGY", + "name": "台州市", + "province": "浙江省" + }, + { + "city_id": "HBBN247QZ6YUW5ZYSS6D7RVJCA", + "name": "绍兴市", + "province": "浙江省" + }, + { + "city_id": "GVFB23SJZGRPRXXTIKDCAOCDPI", + "name": "金华市", + "province": "浙江省" + }, + { + "city_id": "UEW4ENX7N7IFGFM7FD5SZ5GI7Y", + "name": "舟山市", + "province": "浙江省" + }, + { + "city_id": "HCCXS5DGRJQMYZMRLGVAMUIQEA", + "name": "温州市", + "province": "浙江省" + }, + { + "city_id": "FDGY55I6IHKY76E3MWDBOT2R6Y", + "name": "重庆市", + "province": "重庆市" + } +] \ No newline at end of file diff --git a/app/integrations/meituan.py b/app/integrations/meituan.py index 6d1b10a..b628b90 100644 --- a/app/integrations/meituan.py +++ b/app/integrations/meituan.py @@ -126,13 +126,23 @@ def query_coupon( def get_referral_link( *, - product_view_sign: str, + product_view_sign: str | None = None, + act_id: str | None = None, platform: int = 1, biz_line: int | None = None, sid: str | None = None, link_type_list: list[int] | None = None, ) -> dict[str, Any]: - body: dict[str, Any] = {"productViewSign": product_view_sign} + """换推广链接。actId(活动物料)与 productViewSign(商品券)二选一。 + 用 actId 入参时,出参 data 为 null,链接全在 referralLinkMap(实测)。 + """ + if not act_id and not product_view_sign: + raise MeituanCpsError("act_id 或 product_view_sign 必填其一") + body: dict[str, Any] = {} + if act_id: + body["actId"] = act_id + else: + body["productViewSign"] = product_view_sign if platform == 2: body["platform"] = 2 if biz_line is not None: @@ -142,3 +152,30 @@ def get_referral_link( body["linkTypeList"] = link_type_list or [1, 3] return _call("/cps_open/common/api/v1/get_referral_link", body) + + +def query_order( + *, + sid: str | None = None, + start_time: int, + end_time: int, + query_time_type: int = 1, + page: int = 1, + limit: int = 100, +) -> dict[str, Any]: + """按时间窗(+可选 sid)拉 CPS 订单做对账。实测要点: + - 分页是 page / limit(不是 pageNo/pageSize);startTime/endTime 是 10 位秒级时间戳 + - query_time_type: 1 按支付时间 2 按更新时间 + - 响应 data.dataList[],每条含 orderId / sid / payPrice(元) / profit(元) / + commissionRate / status(2付款 3完成 4取消 5风控 6结算) / payTime(秒) / actId 等 + """ + body: dict[str, Any] = { + "queryTimeType": query_time_type, + "startTime": start_time, + "endTime": end_time, + "page": page, + "limit": limit, + } + if sid: + body["sid"] = sid + return _call("/cps_open/common/api/v1/query_order", body) diff --git a/app/integrations/pangle.py b/app/integrations/pangle.py index aacae85..1badabb 100644 --- a/app/integrations/pangle.py +++ b/app/integrations/pangle.py @@ -42,3 +42,13 @@ def verify_callback_sign(params: dict[str, str], secret: str) -> bool: got = params.get("sign") or "" expect = build_sign(trans_id, secret) return hmac.compare_digest(got, expect) + + +def verify_callback_sign_any(params: dict[str, str], secrets: list[str]) -> bool: + """对**多个** m-key 逐个验签,任一通过即接受。 + + 多广告位场景:每个 GroMore 广告位的 m-key 由后台各自生成、互不相同,但本服务用 + 同一个回调 URL 接所有位的回调。配置里放多个密钥(逗号分隔),回调到达时挨个试。 + 仍然安全:伪造者必须知道其中某个 m-key 才能算出合法 sign;空列表 → 一律失败。 + """ + return any(verify_callback_sign(params, s) for s in secrets if s) diff --git a/app/integrations/wx_oauth.py b/app/integrations/wx_oauth.py new file mode 100644 index 0000000..0af50d6 --- /dev/null +++ b/app/integrations/wx_oauth.py @@ -0,0 +1,75 @@ +"""微信服务号网页授权:构造授权链接 + code 换 openid + 拉 userinfo。 + +用于 CPS 落地页(coupon.shaguabijia.com)在微信内拿用户身份。流程(见微信文档): + 授权链接(snsapi_base|snsapi_userinfo) → 回调带 code → sns/oauth2/access_token 换 openid + → (userinfo 时)sns/userinfo 拿昵称头像。 + +注意: + - 这里的 access_token 是【网页授权 token】,与基础 access_token(cgi-bin/token)不同, + 走 sns/* 接口,不需要 IP 白名单。 + - secret 只在服务器,不下发客户端。 + - 用 WX_MP_APPID/SECRET(已认证服务号),区别于 WECHAT_APP_ID(App 移动应用)。 +""" +from __future__ import annotations + +from urllib.parse import quote + +import httpx + +from app.core.config import settings + +_API = "https://api.weixin.qq.com" +_AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize" +_TIMEOUT = 10 + + +class WxOauthError(Exception): + """网页授权调用失败(凭证/网络/code 失效等)。调用方兜底为无 openid。""" + + +def build_authorize_url(redirect_uri: str, scope: str, state: str) -> str: + """构造网页授权跳转链接。scope: 'snsapi_base'(静默,仅 openid) | 'snsapi_userinfo'(昵称头像)。 + + 微信要求 redirect_uri urlEncode、参数顺序固定、结尾 #wechat_redirect。 + """ + return ( + f"{_AUTHORIZE}?appid={settings.WX_MP_APPID}" + f"&redirect_uri={quote(redirect_uri, safe='')}" + f"&response_type=code&scope={scope}&state={quote(state, safe='')}" + f"#wechat_redirect" + ) + + +def exchange_code(code: str) -> dict: + """code 换网页授权 access_token + openid。返回含 openid/access_token/scope(/unionid)。 + + code 5 分钟内一次性有效。失败(errcode)抛 WxOauthError。 + """ + resp = httpx.get( + f"{_API}/sns/oauth2/access_token", + params={ + "appid": settings.WX_MP_APPID, + "secret": settings.WX_MP_SECRET, + "code": code, + "grant_type": "authorization_code", + }, + timeout=_TIMEOUT, + ) + data = resp.json() + if "openid" not in data: + raise WxOauthError(f"exchange_code failed: {data}") + return data + + +def get_userinfo(access_token: str, openid: str) -> dict: + """拉用户信息(nickname/headimgurl/unionid)。需 scope=snsapi_userinfo 的 access_token。""" + resp = httpx.get( + f"{_API}/sns/userinfo", + params={"access_token": access_token, "openid": openid, "lang": "zh_CN"}, + timeout=_TIMEOUT, + ) + resp.encoding = "utf-8" # 昵称含中文/emoji,强制 utf-8 避免乱码 + data = resp.json() + if "openid" not in data: + raise WxOauthError(f"get_userinfo failed: {data}") + return data diff --git a/app/main.py b/app/main.py index 20b11aa..af011f8 100644 --- a/app/main.py +++ b/app/main.py @@ -20,7 +20,11 @@ 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.v1.cps_redirect import router as cps_redirect_router +from app.api.internal.app_version import router as internal_app_version_router +from app.api.internal.launch_confirm import router as internal_launch_confirm_router from app.api.internal.price import router as internal_price_router +from app.api.internal.store import router as internal_store_router from app.api.v1.feedback import router as feedback_router from app.api.v1.invite import router as invite_router from app.api.v1.meituan import router as meituan_router @@ -108,9 +112,14 @@ app.include_router(savings_router) app.include_router(ad_router) app.include_router(order_router) app.include_router(report_router) -# 内部(server→server)端点:pricebot 上报价格观测,靠共享密钥头校验,不对客户端开放。 +# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。 app.include_router(internal_price_router) +app.include_router(internal_store_router) +app.include_router(internal_app_version_router) +app.include_router(internal_launch_confirm_router) app.include_router(platform_router) +# CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团) +app.include_router(cps_redirect_router) # 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。 _media_root = Path(settings.MEDIA_ROOT) diff --git a/app/models/__init__.py b/app/models/__init__.py index 4ed0d1b..d88882f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,6 +6,11 @@ from app.models.ad_watch_log import AdWatchLog # noqa: F401 from app.models.admin import AdminAuditLog, AdminUser # noqa: F401 from app.models.app_config import AppConfig # noqa: F401 from app.models.comparison import ComparisonRecord # noqa: F401 +from app.models.cps_activity import CpsActivity # noqa: F401 +from app.models.cps_group import CpsGroup # noqa: F401 +from app.models.cps_link import CpsClick, CpsLink # noqa: F401 +from app.models.cps_order import CpsOrder # noqa: F401 +from app.models.cps_wx_user import CpsWxUser # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.coupon_state import ( # noqa: F401 CouponClaimRecord, @@ -15,6 +20,7 @@ from app.models.coupon_state import ( # noqa: F401 from app.models.feedback import Feedback # noqa: F401 from app.models.invite import InviteRelation # noqa: F401 from app.models.invite_fingerprint import InviteFingerprint # noqa: F401 +from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401 from app.models.meituan_coupon import MeituanCoupon # noqa: F401 from app.models.onboarding import OnboardingCompletion # noqa: F401 from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401 @@ -23,6 +29,7 @@ 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 SigninBoostRecord, SigninRecord # noqa: F401 +from app.models.store_mapping import StoreMapping # 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_ecpm.py b/app/models/ad_ecpm.py index 0f46b51..fc10a2f 100644 --- a/app/models/ad_ecpm.py +++ b/app/models/ad_ecpm.py @@ -4,8 +4,8 @@ 由客户端生成,并通过激励视频 extra 透传给 S2S 回调,用于把"展示 eCPM"与 "奖励完成"绑定。没有 session id 的旧上报仍可作为按天对账补充。 -⚠️ `ecpm_raw` 原样存客户端上报的字符串——eCPM 单位(分 / 元)截至 2026-05-31 尚未最终确认, -确认后再加一列解析好的数值;在此之前对账按"待定单位"处理。 +`ecpm_raw` 原样存客户端上报的字符串——单位是穿山甲 getEcpm 官方口径【分/千次展示】 +(非元!),对账由 `core/rewards.parse_ecpm_fen` 解析、`parse_ecpm_yuan` ÷100 转元;本列只存原值。 """ from __future__ import annotations @@ -35,6 +35,11 @@ class AdEcpmRecord(Base): adn: Mapped[str | None] = mapped_column(String(32), nullable=True) # 实际展示用的代码位(底层 mediation rit,非客户端配置位) slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 我们的穿山甲应用环境:prod(傻瓜比价正式应用) / test(测试应用)。客户端按 AdConfig.useProductionApp 上报。 + # 与底层 adn 不同:这是「我们用的是哪个 App」,adn 是「聚合后实际填充的子渠道」。旧数据为 NULL。 + app_env: Mapped[str | None] = mapped_column(String(16), nullable=True) + # 我们在穿山甲后台配置的代码位 ID(AdConfig.feedCodeId/rewardCodeId 返回的 104xxx,**非** slot_id 的底层 rit)。旧数据为 NULL。 + our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) # 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较) diff --git a/app/models/ad_feed_reward.py b/app/models/ad_feed_reward.py index b99a38e..55acbee 100644 --- a/app/models/ad_feed_reward.py +++ b/app/models/ad_feed_reward.py @@ -33,6 +33,9 @@ class AdFeedRewardRecord(Base): # 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流 # 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。 feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True) + # 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。 + app_env: Mapped[str | None] = mapped_column(String(16), nullable=True) + our_code_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") diff --git a/app/models/ad_reward.py b/app/models/ad_reward.py index c9e8e9f..d45be76 100644 --- a/app/models/ad_reward.py +++ b/app/models/ad_reward.py @@ -33,6 +33,10 @@ class AdRewardRecord(Base): ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报) ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx。 + # S2S 回调本身不带这俩,发奖时按 ad_session_id 匹配 ad_ecpm_record 回填(查不到为 NULL)。 + app_env: Mapped[str | None] = mapped_column(String(16), nullable=True) + our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数 reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) # 穿山甲上报的奖励名(参考,不作发奖依据) diff --git a/app/models/comparison.py b/app/models/comparison.py index 0dbde5e..6924510 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -18,6 +18,7 @@ from sqlalchemy import ( JSON, Boolean, DateTime, + Float, ForeignKey, Index, Integer, @@ -94,13 +95,39 @@ class ComparisonRecord(Base): # ===== 明细(JSON,越详细越好)===== # 下单菜品 [{name, qty, specs?}] items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) - # 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名) + # 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细) comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) # 目标平台未找到、跳过的菜名 skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) # 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底 raw_payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + # ===== debug 维度(客户端上报;旧记录 / 未发版客户端为 None)===== + # 设备环境:无障碍比价高度依赖机型/ROM,这是排"某机型跑不通"类问题的头号线索 + device_model: Mapped[str | None] = mapped_column(String(64), nullable=True) + device_manufacturer: Mapped[str | None] = mapped_column(String(64), nullable=True) + rom_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True) # vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung + rom_name: Mapped[str | None] = mapped_column(String(32), nullable=True) # OriginOS/ColorOS/HyperOS/MIUI/EMUI/HarmonyOS... + rom_version: Mapped[int | None] = mapped_column(Integer, nullable=True) + android_version: Mapped[str | None] = mapped_column(String(16), nullable=True) # Build.VERSION.RELEASE + android_sdk: Mapped[int | None] = mapped_column(Integer, nullable=True) # Build.VERSION.SDK_INT + app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 我们 app versionName + app_version_code: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 被操控的源平台 App 版本(美团/淘宝/京东),排"平台 App 改版导致 UI 适配失效"用 + source_app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 比价时设备定位(影响淘宝 deeplink / 距离匹配) + longitude: Mapped[float | None] = mapped_column(Float, nullable=True) + latitude: Mapped[float | None] = mapped_column(Float, nullable=True) + + # ===== 性能 / 过程统计(debug 用)===== + total_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端整场比价墙钟耗时 + step_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端 agent loop 总步数 + llm_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 本次 LLM 调用次数(server 从 llm_calls 算) + retry_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # LLM 失败重试次数(server 从 llm_calls error 算) + # 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}]; + # server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。 + llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False ) diff --git a/app/models/coupon_state.py b/app/models/coupon_state.py index bd2aa2d..3f9364d 100644 --- a/app/models/coupon_state.py +++ b/app/models/coupon_state.py @@ -137,25 +137,35 @@ class CouponDailyCompletion(Base): class CouponPromptEngagement(Base): - """按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。""" + """按 (device, **App**, 自然日) 记"今天这个 App 是否对领券引导窗表达过意向"——弹窗频控源。 + + 2026-06-14:频控维度从 (device, 日) 改为 (device, package, 日)。需求是美团/淘宝/京东 + 各自独立——在美团弹过/领过,不影响淘宝、京东今天仍各弹一次。原来缺 package → 任一 App + 弹过就把整台设备当天标记 engage,其余 App 被压住不弹(bug)。 + """ __tablename__ = "coupon_prompt_engagement" __table_args__ = ( - # 一台设备一天一条:今天 engage 过(领或拒)就不再弹。 + # 一台设备、一个 App、一天一条:今天**这个 App** engage 过(领或拒)才不再弹该 App。 UniqueConstraint( - "device_id", "engage_date", - name="uq_coupon_engage_device_date", + "device_id", "package", "engage_date", + name="uq_coupon_engage_device_pkg_date", ), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) device_id: Mapped[str] = mapped_column(String(64), nullable=False) + # 触发弹窗的目标 App 包名(com.sankuai.meituan / com.taobao.taobao / com.jingdong.app.mall)。 + # 频控维度,各 App 独立。旧行(改造前)无此值 → 迁移用占位 "" 填,不影响新逻辑判断。 + package: Mapped[str] = mapped_column( + String(64), nullable=False, server_default="" + ) user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) # Asia/Shanghai 自然日。 engage_date: Mapped[date] = mapped_column(Date, nullable=False) - # claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)。仅记录区分, - # 判断只看"今天有没有这条",type 不影响弹不弹。 + # claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)/ shown(自动弹出即记)。 + # 仅记录区分,判断只看"今天这个 App 有没有这条",type 不影响弹不弹。 engage_type: Mapped[str] = mapped_column(String(16), nullable=False) created_at: Mapped[datetime] = mapped_column( diff --git a/app/models/cps_activity.py b/app/models/cps_activity.py new file mode 100644 index 0000000..e650cca --- /dev/null +++ b/app/models/cps_activity.py @@ -0,0 +1,38 @@ +"""CPS 可推广活动池(cps_activity)。 + +运营预存常推的活动(券),生成链接时选它,省得每次记 actId。当前只接美团: +`act_id` = 美团联盟「我要推广-活动推广」第一列的物料 ID。`platform` 预留,接 +淘宝/京东时复用本表(各自的活动标识)。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class CpsActivity(Base): + __tablename__ = "cps_activity" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + platform: Mapped[str] = mapped_column(String(20), nullable=False, default="meituan") # meituan|taobao|jd + name: Mapped[str] = mapped_column(String(128), nullable=False) + # 美团活动物料 ID(actId);淘宝/京东接入后存各自活动标识。 + act_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 美团商品券 productViewSign(与 act_id 二选一转链;多数活动用 act_id)。 + product_view_sign: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 淘宝:整段淘口令文本(落地页原样复制);京东:推广链接(落地页 302 跳)。美团不用这个(用 act_id)。 + payload: Mapped[str | None] = mapped_column(Text, nullable=True) + # 淘宝落地页主视觉图(绝对 URL,基于 CPS_REDIRECT_BASE)。运营建活动时上传/选已有,落地页按活动展示。 + image_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived + remark: Mapped[str | None] = mapped_column(String(256), 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/cps_group.py b/app/models/cps_group.py new file mode 100644 index 0000000..8a9e516 --- /dev/null +++ b/app/models/cps_group.py @@ -0,0 +1,37 @@ +"""CPS 推广群(cps_group)。 + +每个微信群一条记录,`sid` 是美团联盟二级渠道追踪位(get_referral_link / query_order +共用):发券时塞进推广链接,订单回来按 sid 归群对账。美团限制 sid 仅字母+数字 ≤64。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, DateTime, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class CpsGroup(Base): + __tablename__ = "cps_group" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 渠道标识。仅美团需要(sid 追踪);纯淘宝/京东群无 sid(那俩不支持)。含美团时建群分配。 + sid: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + # 该群发哪些平台的券(多选): ["meituan","taobao","jd"]。含 meituan 才需要 sid。 + platforms: Mapped[list] = mapped_column( + JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=list + ) + # 群人数,运营填,作转化率分母(可空)。 + member_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived + remark: Mapped[str | None] = mapped_column(String(256), 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/cps_link.py b/app/models/cps_link.py new file mode 100644 index 0000000..bb88e6b --- /dev/null +++ b/app/models/cps_link.py @@ -0,0 +1,59 @@ +"""CPS 群发短链(cps_link)+ 点击事件(cps_click)。 + +群发券链接套一层我们自己的短链 `/c/{code}`:用户点 → 记一条 cps_click → 302 跳 +target_url(美团短链,微信可打开)。据此统计点击 PV/UV(按群/活动/时段),配合 cps_order +的下单/佣金做"点击→下单→佣金"漏斗。group_id/sid 在 cps_click 里冗余一份,统计直接按 +群聚合点击、不必 join cps_link。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class CpsLink(Base): + __tablename__ = "cps_link" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 短码:发群链接 /c/{code} 的 code,全局唯一。 + code: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False) + group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + activity_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + # 仅美团 link 有 sid(渠道追踪);淘宝/京东无 sid。 + sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + platform: Mapped[str] = mapped_column(String(20), nullable=False, default="meituan") + # 跳转/落地目标: 美团短链 / 京东链接(302 跳) / 淘宝整段淘口令文本(H5 落地页复制)。 + target_url: Mapped[str] = mapped_column(String(2048), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" + + +class CpsClick(Base): + __tablename__ = "cps_click" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + link_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) # 冗余,按群聚合快 + sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 冗余(淘宝/京东无) + # 事件类型: visit(进落地页/被跳转) | copy(淘宝落地页点了"复制口令") + event_type: Mapped[str] = mapped_column( + String(16), nullable=False, default="visit", server_default="visit" + ) + ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + ua: Mapped[str | None] = mapped_column(String(512), nullable=True) + # 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计 + openid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + clicked_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/cps_order.py b/app/models/cps_order.py new file mode 100644 index 0000000..306b30d --- /dev/null +++ b/app/models/cps_order.py @@ -0,0 +1,63 @@ +"""CPS 对账订单(cps_order)。 + +从美团联盟 query_order 按时间窗拉回、按 sid 归群的订单明细。字段对齐 query_order +实测返回: + - payPrice / profit 是「元」字符串 → 入库统一转「分」(与全站口径一致) + - payTime / updateTime 是秒级时间戳 → 入库转 tz-aware datetime + - status: 2付款 3完成 4取消 5风控 6结算(取消/风控不计佣金) +order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, DateTime, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class CpsOrder(Base): + __tablename__ = "cps_order" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 美团订单号(加密串),全局唯一,upsert 幂等键。 + order_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + # 渠道追踪位 = 群 sid(历史无 sid 订单为空)。按它归群聚合。 + sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + act_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + biz_line: Mapped[int | None] = mapped_column(Integer, nullable=True) # 1=外卖 + trade_type: Mapped[int | None] = mapped_column(Integer, nullable=True) # 1=cps 2=cpa + + pay_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) # 预估佣金(profit) + commission_rate: Mapped[str | None] = mapped_column(String(16), nullable=True) # "300"=3% "10"=0.1% + refund_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + refund_profit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # 美团订单状态: 2付款 3完成 4取消 5风控 6结算 + mt_status: Mapped[str | None] = mapped_column(String(8), index=True, nullable=True) + invalid_reason: Mapped[str | None] = mapped_column(String(128), nullable=True) + product_name: Mapped[str | None] = mapped_column(String(512), nullable=True) + + pay_time: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), index=True, nullable=True + ) + mt_update_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + raw: Mapped[dict] = mapped_column( + JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=dict + ) + first_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + 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/cps_wx_user.py b/app/models/cps_wx_user.py new file mode 100644 index 0000000..669b26c --- /dev/null +++ b/app/models/cps_wx_user.py @@ -0,0 +1,42 @@ +"""CPS 落地页微信用户(cps_wx_user)。 + +用户在微信内打开群发短链落地页 /c/{code},经服务号网页授权: + - base 静默:拿 openid(唯一标识,统计主力) + - userinfo(点领券触发):补 nickname/headimgurl/unionid + +按 openid 唯一,记录首次来源群(first_group_id),用于群内用户级统计(谁领了券)。 +下单归因到人需 user-level sid(另一期),本表只承载身份 + 领券/点击侧。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class CpsWxUser(Base): + __tablename__ = "cps_wx_user" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 服务号下用户唯一标识(网页授权拿到) + openid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + # 开放平台 unionid(服务号绑开放平台 + scope=userinfo 才有);跨 App/服务号统一用户 + unionid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + # 昵称/头像:userinfo 授权后才有(base 阶段为空) + nickname: Mapped[str | None] = mapped_column(String(128), nullable=True) + headimgurl: Mapped[str | None] = mapped_column(String(512), nullable=True) + # 首次进入来源(从哪个群的链接授权进来) + first_code: Mapped[str | None] = mapped_column(String(16), nullable=True) + first_group_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) + first_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/feedback.py b/app/models/feedback.py index 004d9e2..caef033 100644 --- a/app/models/feedback.py +++ b/app/models/feedback.py @@ -1,7 +1,8 @@ """用户反馈表(帮助与反馈)。 -每条 = 用户一次提交。content 必填,contact 必填(微信/QQ/手机,便于回访),images 为可选的 -截图 URL 列表(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。 +每条 = 用户一次提交。content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集, +新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表 +(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。 """ from __future__ import annotations diff --git a/app/models/launch_confirm_sample.py b/app/models/launch_confirm_sample.py new file mode 100644 index 0000000..de225b0 --- /dev/null +++ b/app/models/launch_confirm_sample.py @@ -0,0 +1,61 @@ +"""启动确认窗 agent 兜底样本表(launch_confirm_sample)。 + +pricebot 的 launch_confirm_agent 每次"静态 PROFILES 没认出、靠 LLM 兜底放行"一个跨 App +启动确认窗(繁体 / 英文 / 小语种 / ROM 改版)时,把完整样本 server→server 落这里。研发定期 +**人工**把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES(静态快路径), +让以后同款窗不必再调 LLM。 + +设计: +- 与登录无关、不鉴权(server→server 共享密钥头 X-Internal-Secret)。 +- **都上报、不去重**(by design):同一种窗多台机器上报多条,研发按 host_package / locale + 聚合后人工筛。 +- 大字段(弹窗树快照 + LLM plan + 设备信息)塞 payload(JSONB)兜底,免得加字段就迁移 schema。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, Boolean, DateTime, Integer, String, 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(同 price_observation)。 +_JSON = JSON().with_variant(JSONB(), "postgresql") + + +class LaunchConfirmSample(Base): + __tablename__ = "launch_confirm_sample" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + # pricebot 侧 trace_id:回指原始 trace,能去 price.shaguabijia.com 查完整上下文。 + trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + + # 弹窗宿主包(= 触发判据, 也是研发沉淀进 PROFILES.host_packages 的键)。按它聚合人工筛。 + host_package: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True) + # 被打开的目标 App 包名(标题里"想要打开 X"的 X 是变量, 据此把它从 sign 里挖空)。 + target_app: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 系统语言地区(zh-TW / en-US…)—— 多语言问题的核心维度。 + # ⚠️ 前端 AgentStepRequest 暂未上报设备信息 → 现恒 None,待前端加 device_info 字段后回填。 + system_locale: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True) + + # 这次兜底是否成功放行(弹窗消失=True;放弃 / LLM 没认出=False)。研发只沉淀 True 的。 + exec_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # 弹窗标题全文(研发据此提 launch_sign),如"「傻瓜比价」想要開啟「京東」"。 + dialog_title: Mapped[str | None] = mapped_column(String(256), nullable=True) + + # 大 JSON:完整样本。{is_launch_confirm, llm_model, llm_reason, plan, dialog_tree, + # device_info: {screen, locale, brand, model, android_version, rom_version}} + payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/models/signin.py b/app/models/signin.py index 63a9043..0a94ea0 100644 --- a/app/models/signin.py +++ b/app/models/signin.py @@ -1,7 +1,8 @@ """签到记录表。 每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。 - - cycle_day: 1..14,14 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。 + - cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1 + (周期长度 = rewards.SIGNIN_CYCLE_LEN,2026-06 由 14 天改 7 天一轮)。 - streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。 """ from __future__ import annotations diff --git a/app/models/store_mapping.py b/app/models/store_mapping.py new file mode 100644 index 0000000..78850a2 --- /dev/null +++ b/app/models/store_mapping.py @@ -0,0 +1,122 @@ +"""平台店铺表(store_mapping)—— 跨平台"同一家店"的 id/名 映射资产层。 + +每完成一次淘宝比价(在目标淘宝店通过 更多操作→分享→复制链接 拿到分享短链、 +HTTP 解析出 shopId 后),pricebot server→server 内部上报落这里一行。**与登录无关、 +不依赖客户端鉴权**(比价透传链路当前不鉴权,user_id 客户端带上时一并记)。 + +与 price_observation 的区别: +- price_observation:平台/门店视角的**价格事实**(某店这单多少钱)。 +- store_mapping:平台/门店视角的**身份映射**(同一家物理店在 淘宝/美团/京东 各自的 + 店铺 id 与店名)。是未来"我见过这家店→跳过重新搜索/匹配"的源头。两表独立。 + +「先存下来、用法后说」:列尽量铺全(各平台 id/名 + 地理 + 溯源 + 淘宝/美团原料 URL), +attrs(JSONB)兜底存灵活明细,免得每多记一个字段就迁移 schema。 + +⚠️ 数据质量:跨平台"同一家店"的连接来自 agent 的 LLM 店铺匹配,匹配错则一行里连错店。 +本表是 append-only 原始记录(每比价一行、trace_id 幂等防重试重复),清洗/归一二期再做。 +已接通**淘宝**(id_taobao=shopId)、**美团**(meituan_poi_id_str,非稳定主键、单列存)、 +**京东**(id_jd=storeId + jd_vender_id,均稳定数字主键;3.cn 短链反查)。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + JSON, + DateTime, + Float, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + +# PG 上用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 price_observation / comparison_record)。 +_JSON = JSON().with_variant(JSONB(), "postgresql") + + +class StoreMapping(Base): + __tablename__ = "store_mapping" + __table_args__ = ( + # 一次比价(trace)只记一条:pricebot 重试 / 客户端 replay 重复上报时幂等去重。 + # 一次淘宝比价 = 一个目标淘宝店 → 一行映射(源 + 各平台身份压在同一行)。 + UniqueConstraint("trace_id", name="uq_store_mapping_trace"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # ===== 跨平台店铺身份(同一家物理店在各平台的 id/名;按比价角色稀疏填充)===== + # id_taobao = 分享短链解析出的 shopId(淘宝当目标、走完取 id 流程才有); + # name_taobao = 店铺页 a11y content_desc "店铺标题:xxx" 剥前缀。 + id_taobao: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + name_taobao: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 美团:无同款 share→稳定id 机制(poi_id_str 每次变,见下方 meituan_poi_id_str),id_meituan + # 留给将来 CPS API 的稳定 poi_id;name-only 时仅 name_meituan(源平台店名来自 intent/agent 匹配名)。 + id_meituan: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + name_meituan: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 京东:已接通 share→id(3.cn 短链反查)。id_jd = storeId(门店稳定数字主键,同 taobao shopId→ + # id_taobao);venderId(deeplink 还需)单列存 jd_vender_id。name_jd = 店铺页店名。 + id_jd: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + name_jd: Mapped[str | None] = mapped_column(String(128), nullable=True) + + # ===== 地理(同名店异地区分 / 地理分桶匹配的主要燃料)===== + city: Mapped[str | None] = mapped_column(String(64), nullable=True) + 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) + # 淘宝门店地址(店铺页 a11y 抓到才有;比经纬度更利于人工/LLM 匹配) + taobao_address: Mapped[str | None] = mapped_column(String(256), nullable=True) + + # ===== 溯源 / 用户画像 ===== + # 源平台(发起比价那家:meituan / taobao_flash / jd_waimai ...) + source_platform: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True) + business_type: Mapped[str] = mapped_column(String(16), nullable=False, default="food") + # pricebot 侧 trace_id:回指原始 trace(溯源)+ 幂等去重键(uq_store_mapping_trace) + trace_id: Mapped[str] = mapped_column(String(64), nullable=False) + 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) + + # ===== 淘宝原料(可复跳 / 可重解析 / 调试;URL 可能很长 → Text)===== + taobao_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # m.tb.cn 短链 + taobao_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 解析出的目标 URL(含 shopId) + taobao_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 et-store/search deeplink + # 淘宝 deeplink 失效标记:比价撞"页面出错了"降级页时被置(pricebot server→server invalidate), + # NULL=有效。lookup 反查过滤掉非 NULL 的淘宝候选,不再返回坏 deeplink(重搜会写新行覆盖)。 + taobao_deeplink_invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + # ===== 美团原料(同淘宝;dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink)===== + # ⚠️ poi_id_str 每次分享重新加密、非稳定主键(调研文档 §八), 故单列存"可复跳的一次性票据", + # 不进 id_meituan —— 后者留给将来 CPS API 拿到的稳定数字 poi_id。 + meituan_poi_id_str: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + meituan_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # dpurl.cn 短链 + meituan_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 302 落地 menu URL(含 poi_id_str) + meituan_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 imeituan:// 店内搜索 deeplink + + # ===== 京东原料(秒送;3.cn 短链 → 跟随重定向反查 venderId+storeId → openapp.jdmobile:// deeplink)===== + # storeId 进 id_jd(稳定店主键);venderId 单列存(deeplink 模板 venderId+storeId 都要,且 venderId + # 是商家维度、可跨门店,与门店 storeId 分开记)。其余三列与 taobao_*/meituan_* 平行。 + jd_vender_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + jd_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # 3.cn 短链 + jd_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 反查出的目标 openapp.jdmobile:// deeplink + jd_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 pages/search 店内搜索 deeplink + # 京东 deeplink 失效标记(同 taobao_deeplink_invalid_at):撞"当前门店超出配送范围"页时被置,NULL=有效。 + jd_deeplink_invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + # 灵活字段兜底(免得加字段就迁移) + attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + + 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/user.py b/app/models/user.py index 67d6600..1161ba1 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -27,6 +27,10 @@ class User(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) phone: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False) + # 对外展示的账号 ID:11 位纯数字、首位非 1(与手机号天然区分——手机号都以 1 开头)、全局唯一、 + # 创建时随机生成、不可变、不参与登录(登录仍走 phone)。生成见 repositories/user._gen_username。 + username: Mapped[str] = mapped_column(String(11), unique=True, index=True, nullable=False) + # 注册渠道:jverify / sms。后续加 wechat / apple 时扩 register_channel: Mapped[str] = mapped_column(String(20), nullable=False, default="jverify") diff --git a/app/models/wallet.py b/app/models/wallet.py index 8c5b849..bb71b4c 100644 --- a/app/models/wallet.py +++ b/app/models/wallet.py @@ -41,6 +41,21 @@ class CoinAccount(Base): class CoinTransaction(Base): __tablename__ = "coin_transaction" + __table_args__ = ( + # 可重复任务(如 task_enable_notification)按 ref_id 序号(task_key:N)去重,挡并发/连点 + # 重复发放——读-算-写无锁,两个并发 claim 会都算出同一序号双倍发钱(见 task.claim_task)。 + # 仅 task_* 且 ref_id 非空生效;一次性任务 ref_id=task_key 本就每人一条,纳入无害; + # 不影响 signin / exchange / withdraw 等其它 biz_type。 + Index( + "ux_coin_transaction_task_ref", + "user_id", + "biz_type", + "ref_id", + unique=True, + sqlite_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"), + postgresql_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"), + ), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py index 9841ae9..adfc7fd 100644 --- a/app/repositories/ad_ecpm.py +++ b/app/repositories/ad_ecpm.py @@ -23,8 +23,14 @@ def create_ecpm_record( ad_session_id: str | None = None, adn: str | None = None, slot_id: str | None = None, + app_env: str | None = None, + our_code_id: str | None = None, ) -> AdEcpmRecord: - """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。""" + """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。 + + app_env(prod/test)与 our_code_id(我们后台配置的 104xxx 代码位)供广告收益报表按 + 应用/代码位聚合;与 adn(实际填充子渠道)/slot_id(底层 rit)是两组不同口径。 + """ if ad_session_id: existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id) if existing is not None: @@ -35,6 +41,8 @@ def create_ecpm_record( ad_session_id=ad_session_id, adn=adn, slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, ecpm_raw=ecpm_raw, report_date=cn_today().isoformat(), ) diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index ef43b42..f5912ac 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -16,6 +16,10 @@ from app.repositories import wallet as crud_wallet FEED_REWARD_UNIT_SECONDS = 10 +# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数 +# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。 +# 与 rewards.AD_ECPM_MAX_FEN(eCPM 钳顶)合起来,把单事件可铸金币锁进有限区间。 +FEED_MAX_DURATION_SECONDS = 120 def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None: @@ -38,15 +42,14 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int: ).scalar_one() -def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int, today: str) -> int: - """按每个 10 秒单位逐份计算奖励,LT 使用当天累计奖励份序号。""" +def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int) -> 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() @@ -67,9 +70,19 @@ def grant_feed_reward( adn: str | None = None, slot_id: str | None = None, feed_scene: str | None = None, + app_env: str | None = None, + our_code_id: str | None = None, + aborted: bool = False, ) -> AdFeedRewardRecord: - """完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。 + """比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。 + 发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份)。 + - aborted=True(用户中途 ✕ 关闭):整场不发,记 status='closed_early' 留痕(原因可查)。 + - 总时长不足 10 秒(unit_count==0):记 status='too_short' 不发。 + - 命中当日条数上限:记 status='capped' 不发。 + duration_seconds 是整场累计秒数。一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 + FEED_MAX_DURATION_SECONDS 限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 + AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。 feed_scene:点位场景(comparison/coupon/welfare),仅做归类落库,不参与发奖计算。 """ existing = _find_by_event(db, client_event_id) @@ -77,9 +90,30 @@ def grant_feed_reward( return existing today = cn_today().isoformat() - safe_duration = max(0, min(duration_seconds, 24 * 60 * 60)) + # 客户端上报时长先钳到 FEED_MAX_DURATION_SECONDS,防伪造超长时长刷份数(见常量注释)。 + safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS)) unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS + # 用户中途关闭广告:整场不发(全程不关才发),留一条 closed_early 记录原因。优先级最高。 + if aborted: + rec = AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=unit_count, + ad_session_id=ad_session_id, + ecpm_raw=ecpm, + adn=adn, + slot_id=slot_id, + feed_scene=feed_scene, + app_env=app_env, + our_code_id=our_code_id, + coin=0, + status="closed_early", + ) + return _commit_record(db, rec, client_event_id) + if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db): rec = AdFeedRewardRecord( client_event_id=client_event_id, @@ -92,12 +126,34 @@ def grant_feed_reward( adn=adn, slot_id=slot_id, feed_scene=feed_scene, + app_env=app_env, + our_code_id=our_code_id, coin=0, status="capped", ) return _commit_record(db, rec, client_event_id) - coin = _unit_reward_total(db, user_id, ecpm, unit_count, today) + # 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。 + if unit_count == 0: + rec = AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=0, + ad_session_id=ad_session_id, + ecpm_raw=ecpm, + adn=adn, + slot_id=slot_id, + feed_scene=feed_scene, + app_env=app_env, + our_code_id=our_code_id, + coin=0, + status="too_short", + ) + return _commit_record(db, rec, client_event_id) + + coin = _unit_reward_total(db, user_id, ecpm, unit_count) if coin > 0: crud_wallet.grant_coins( db, user_id, coin, @@ -115,6 +171,8 @@ def grant_feed_reward( adn=adn, slot_id=slot_id, feed_scene=feed_scene, + app_env=app_env, + our_code_id=our_code_id, coin=coin, status="granted", ) diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index e018ec0..f9fb16f 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -54,6 +54,20 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int: ).scalar_one() +def _granted_cumulative(db: Session, user_id: int) -> int: + """账号累计已发奖的看视频次数(不按天重置)——LT 因子(因子2)用它定"第 N 次广告"。 + 与 _granted_today 区别仅在去掉 reward_date 过滤;每日次数上限/冷却仍按当日统计。""" + return db.execute( + select(func.count()) + .select_from(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.status == "granted", + AdRewardRecord.reward_scene == "reward_video", + ) + ).scalar_one() + + def grant_ad_reward( db: Session, user_id: int, @@ -76,6 +90,16 @@ def grant_ad_reward( today = cn_today().isoformat() + # 按 ad_session_id 匹配客户端 eCPM 上报:既用于缺 eCPM 时回退取值,也把「来源」 + # (我们的应用 app_env + 我们配置的代码位 our_code_id)回填到发奖记录,供广告收益报表聚合。 + # S2S 回调本身不带这俩;查不到(未上报 eCPM)则留空。 + ecpm_rec = ( + crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id) + if ad_session_id else None + ) + src_app_env = ecpm_rec.app_env if ecpm_rec is not None else None + src_code_id = ecpm_rec.our_code_id if ecpm_rec is not None else None + # #3 每日上限:当前产品只保留发奖次数上限(默认 500 次)。旧的观看时长闸保留字段, # 但 DAILY_AD_WATCH_SECONDS_LIMIT=0 时视为停用,不能命中 capped。 over_time = ( @@ -88,23 +112,24 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=0, status="capped", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, + app_env=src_app_env, our_code_id=src_code_id, ) return _commit_record(db, rec, trans_id) - ecpm_raw = ecpm - if not ecpm_raw and ad_session_id: - ecpm_rec = crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id) - ecpm_raw = ecpm_rec.ecpm_raw if ecpm_rec is not None else None + ecpm_raw = ecpm or (ecpm_rec.ecpm_raw if ecpm_rec is not None else None) if not ecpm_raw: rec = AdRewardRecord( trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None, + app_env=src_app_env, our_code_id=src_code_id, ) return _commit_record(db, rec, trans_id) - coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_today(db, user_id, today) + 1) + # LT 因子(因子2)按"账号累计第 N 次看广告"递减(2.0→1.0),不再按天重置; + # 每日次数上限/冷却仍按当日统计(over_count 用 _granted_today)。 + coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_cumulative(db, user_id) + 1) # 发金币 + 记一笔,同事务 crud_wallet.grant_coins( @@ -115,6 +140,55 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=coin, status="granted", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw, + app_env=src_app_env, our_code_id=src_code_id, + ) + return _commit_record(db, rec, trans_id) + + +def record_reward_noshow( + db: Session, + user_id: int, + *, + ad_session_id: str, + ecpm: str | None = None, + adn: str | None = None, + slot_id: str | None = None, + app_env: str | None = None, + our_code_id: str | None = None, + reward_scene: str = "reward_video", +) -> AdRewardRecord: + """客户端上报「激励视频展示了但用户提前关/跳过、未触发发奖」,落一条 coin=0 status='closed_early' + 记录,供广告收益报表把「不发金币的原因」也呈现出来(只留痕,不发币)。 + + 幂等键 trans_id = 'noreward:{ad_session_id}'(每次展示唯一)。若同一 ad_session_id 已有 granted + 记录(S2S 已发奖,正常路径),说明用户其实看完了 → 跳过不写、原样返回那条,避免与正常发奖重复。 + app_env/our_code_id 由客户端直接带上(它本就持有);查不到 user 抛 UnknownUserError。 + """ + if db.get(User, user_id) is None: + raise UnknownUserError + # 同一次展示已正常发奖 → 不再记 closed_early(防与 S2S granted 重复) + granted = db.execute( + select(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.ad_session_id == ad_session_id, + AdRewardRecord.status == "granted", + ) + .limit(1) + ).scalar_one_or_none() + if granted is not None: + return granted + + trans_id = f"noreward:{ad_session_id}" + existing = _find_by_trans(db, trans_id) + if existing is not None: + return existing + + rec = AdRewardRecord( + trans_id=trans_id, user_id=user_id, coin=0, status="closed_early", + reward_date=cn_today().isoformat(), reward_name=None, raw=None, + reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, + app_env=app_env, our_code_id=our_code_id, ) return _commit_record(db, rec, trans_id) diff --git a/app/repositories/app_config.py b/app/repositories/app_config.py index 2cd8c2c..40bfc80 100644 --- a/app/repositories/app_config.py +++ b/app/repositories/app_config.py @@ -63,3 +63,79 @@ def list_all(db: Session) -> list[dict]: } ) return out + + +# ── 最新 App 版本(OTA 检查更新)────────────────────────────────────────────── +# 版本信息不是"运营可配置项",不进 CONFIG_DEFS:它由发布流程(发车)/应急整组写入、客户端读取做 +# OTA,语义是"发布系统状态"而非"运营调参"。故走下面专用读写,直接操作 AppConfig 表(复用表不复用 repo)。 +APP_VERSION_KEY = "latest_app_version" + + +def get_app_version(db: Session) -> dict | None: + """读最新 App 版本信息(OTA)。无记录返回 None(客户端视为无更新)。""" + row = db.get(AppConfig, APP_VERSION_KEY) + return row.value if row is not None else None + + +def set_app_version(db: Session, data: dict, *, commit: bool = True) -> AppConfig: + """整组原子覆盖最新版本信息(发布流程/应急写,非 admin,故 updated_by_admin_id 留空)。""" + row = db.get(AppConfig, APP_VERSION_KEY) + if row is None: + row = AppConfig(key=APP_VERSION_KEY, value=data, updated_by_admin_id=None) + db.add(row) + else: + row.value = data + if commit: + db.commit() + db.refresh(row) + else: + db.flush() + return row + + +# ── 穿山甲广告配置(admin 可配,客户端动态拉)────────────────────────────────── +# 同 app_version:结构化 dict,复用 AppConfig 表不进 CONFIG_DEFS(它是一组关联配置,不是散项)。 +# 默认值 = 客户端当前内置的硬编码 ID(AdConfig.kt),空库时下发默认 = 维持现状。 +# reward_mkey 是 GroMore S2S 验签密钥(机密),只后端验签用、绝不下发客户端(见 platform/ad-config)。 +AD_CONFIG_KEY = "ad_config" +_AD_CONFIG_DEFAULTS: dict[str, Any] = { + "app_id": "5830519", # 穿山甲应用ID(正式) + "reward_code_id": "104099389", # 福利页激励视频位 + "compare_feed_code_id": "104090333", # 比价信息流位 + "coupon_feed_code_id": "104090333", # 领券信息流位(初始同比价,运营可拆) + "reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*) + "reward_enabled": True, # 福利激励视频开关 + "compare_ad_enabled": True, # 比价广告开关 + "coupon_ad_enabled": True, # 领券广告开关 +} + + +def get_ad_config(db: Session) -> dict: + """读广告配置。DB 无则返回默认(=客户端内置值,维持现状);DB 有则与默认 merge + (新增字段向后兼容,老记录缺的字段用默认补)。""" + row = db.get(AppConfig, AD_CONFIG_KEY) + merged = dict(_AD_CONFIG_DEFAULTS) + if row is not None and isinstance(row.value, dict): + merged.update(row.value) + return merged + + +def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True) -> AppConfig: + """admin 写广告配置。与现有值 merge(部分更新),只接受已知字段(防脏键)。""" + row = db.get(AppConfig, AD_CONFIG_KEY) + merged = dict(_AD_CONFIG_DEFAULTS) + if row is not None and isinstance(row.value, dict): + merged.update(row.value) + merged.update({k: v for k, v in data.items() if k in _AD_CONFIG_DEFAULTS}) + if row is None: + row = AppConfig(key=AD_CONFIG_KEY, value=merged, updated_by_admin_id=admin_id) + db.add(row) + else: + row.value = merged + row.updated_by_admin_id = admin_id + if commit: + db.commit() + db.refresh(row) + else: + db.flush() + return row diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index 640ffa0..09fa555 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -90,6 +90,21 @@ def upsert_record( items=[it.model_dump(exclude_none=True) for it in payload.items], comparison_results=[r.model_dump() for r in payload.comparison_results], skipped_dish_names=list(payload.skipped_dish_names), + # 客户端环境 / 性能(debug,客户端上报;旧客户端为 None) + device_model=payload.device_model, + device_manufacturer=payload.device_manufacturer, + rom_vendor=payload.rom_vendor, + rom_name=payload.rom_name, + rom_version=payload.rom_version, + android_version=payload.android_version, + android_sdk=payload.android_sdk, + app_version=payload.app_version, + app_version_code=payload.app_version_code, + source_app_version=payload.source_app_version, + longitude=payload.longitude, + latitude=payload.latitude, + total_ms=payload.total_ms, + step_count=payload.step_count, raw_payload=payload.model_dump(), **derived, ) diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index c5012b2..e3e2765 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -9,7 +9,7 @@ import logging from datetime import date, datetime from zoneinfo import ZoneInfo -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -31,11 +31,13 @@ def today_cn() -> date: # ===== 弹窗频控(coupon_prompt_engagement)===== -def has_engaged_today(db: Session, device_id: str) -> bool: - """这台设备今天是否已对领券引导窗表达过意向(领或拒)。有 = 不再弹。""" +def has_engaged_today(db: Session, device_id: str, package: str) -> bool: + """这台设备今天**这个 App** 是否已对领券引导窗表达过意向(领/拒/弹出)。有 = 该 App 不再弹。 + 频控按 (device, package, 日):美团弹过不影响淘宝/京东今天各自仍弹一次。""" row = db.execute( select(CouponPromptEngagement.id).where( CouponPromptEngagement.device_id == device_id, + CouponPromptEngagement.package == package, CouponPromptEngagement.engage_date == today_cn(), ) ).first() @@ -43,13 +45,18 @@ def has_engaged_today(db: Session, device_id: str) -> bool: def mark_engagement( - db: Session, device_id: str, user_id: int | None, engage_type: str + db: Session, device_id: str, package: str, user_id: int | None, engage_type: str ) -> None: - """记今日意向(claim_started / dismissed)。(device, 今天) 唯一,幂等 upsert。""" + """记今日意向(shown / claim_started / dismissed)。(device, package, 今天) 唯一,幂等 upsert。 + + engage_type 升级口径(同一 (device,package,日) 多次调,只覆盖 type,不新增行): + shown(自动弹出)→ claim_started(点一键领取)/ dismissed(点关闭)。判断只看"有没有这条"。 + """ today = today_cn() row = db.execute( select(CouponPromptEngagement).where( CouponPromptEngagement.device_id == device_id, + CouponPromptEngagement.package == package, CouponPromptEngagement.engage_date == today, ) ).scalar_one_or_none() @@ -59,19 +66,20 @@ def mark_engagement( row.user_id = user_id else: db.add(CouponPromptEngagement( - device_id=device_id, user_id=user_id, + device_id=device_id, package=package, user_id=user_id, engage_date=today, engage_type=engage_type, )) try: db.commit() except IntegrityError: - # 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。 + # 并发下另一请求刚插了同 (device, package, 日) → 唯一约束撞,回滚忽略(本就幂等)。 db.rollback() def reset_today_engagement(db: Session, device_id: str) -> int: - """删这台设备今天的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。 - 删后 has_engaged_today → false,今天又能弹。返回删除行数。""" + """删这台设备今天**所有 App** 的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。 + 删后各 App has_engaged_today → false,今天又都能弹。返回删除行数。 + (不按 package 过滤:重置是"把今天清干净从头测",清全部 App 最符合预期。)""" result = db.execute( delete(CouponPromptEngagement).where( CouponPromptEngagement.device_id == device_id, @@ -123,6 +131,19 @@ def mark_completed_today( db.rollback() +def reset_today_completion(db: Session, device_id: str) -> int: + """删这台设备今天的"已完成"记录(开发设置「重置今日领券弹窗状态」全重置时调)。 + 删后 has_completed_today → false,首页「去领取」卡恢复可点。返回删除行数。""" + result = db.execute( + delete(CouponDailyCompletion).where( + CouponDailyCompletion.device_id == device_id, + CouponDailyCompletion.complete_date == today_cn(), + ) + ) + db.commit() + return result.rowcount or 0 + + # ===== 领券记录(coupon_claim_record)===== def record_claims( @@ -185,3 +206,29 @@ def record_claims( ) return 0 return written + + +# ===== 累计领券数(「我的」页战绩卡「领取优惠券 X 张」)===== + +def sum_claimed_count(db: Session, user_id: int) -> int: + """该用户累计领到的优惠券张数。口径(2026-06-15 用户定):SUM(claimed_count) —— + 各成功领券记录的 pricebot 展示张数(claimed_count 列,存的是 display_count)之和, + 与领券完成时给用户看的「本次领了 N 张」同源。 + + - 只算 status ∈ {success, already_claimed}:already_claimed=今日已领过,协议里算「已领到」; + failed / skipped 不计。 + - claimed_count 为 0 的保持 0:那是同 count_group 合并去重项(pricebot 只让一条出数),不重复计; + 为 NULL 的兜底成 1(成功领到至少 1 张;实际 pricebot to_dict 恒下发 display_count,NULL 基本不出现)。 + - 维度 user_id:登录态领的券才归入。登录前匿名领的(user_id 为空)不算(产品可接受)。 + """ + total = db.execute( + select( + func.coalesce( + func.sum(func.coalesce(CouponClaimRecord.claimed_count, 1)), 0 + ) + ).where( + CouponClaimRecord.user_id == user_id, + CouponClaimRecord.status.in_(("success", "already_claimed")), + ) + ).scalar_one() + return int(total or 0) diff --git a/app/repositories/cps_link.py b/app/repositories/cps_link.py new file mode 100644 index 0000000..00b9c1f --- /dev/null +++ b/app/repositories/cps_link.py @@ -0,0 +1,96 @@ +"""CPS 群发短链 + 点击 数据访问(用户侧跳转端点 + admin 生成/统计 共用)。 + +短码生成去掉易混字符;点击按 (ip, ua) 近似去重 UV。点击量级小、admin 低频,统计在 +Python 侧聚合,跨 PG/SQLite 无方言坑(与 admin cps repo 同风格)。 +""" +from __future__ import annotations + +import secrets +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.cps_link import CpsClick, CpsLink + +# 去掉易混字符 0/O/1/l/I,运营/用户肉眼复制不易错 +_CODE_ALPHABET = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + +def _gen_code(n: int = 8) -> str: + return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(n)) + + +def get_by_code(db: Session, code: str) -> CpsLink | None: + return db.execute(select(CpsLink).where(CpsLink.code == code)).scalar_one_or_none() + + +def create_link( + db: Session, *, group_id: int, activity_id: int, sid: str | None, target_url: str, + platform: str = "meituan", commit: bool = True, +) -> CpsLink: + """生成唯一短码并存库。冲突重试 5 次,仍冲突用 12 位兜底(概率近 0)。""" + code = _gen_code() + for _ in range(5): + if get_by_code(db, code) is None: + break + code = _gen_code() + else: + code = _gen_code(12) + link = CpsLink( + code=code, group_id=group_id, activity_id=activity_id, sid=sid, + target_url=target_url, platform=platform, + ) + db.add(link) + if commit: + db.commit() + db.refresh(link) + else: + db.flush() + return link + + +def record_click( + db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None, + event_type: str = "visit", openid: str | None = None, +) -> None: + """记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。 + openid: 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计。""" + db.add(CpsClick( + link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type, + ip=ip, ua=(ua[:500] if ua else None), openid=openid, + )) + db.commit() + + +def click_stats_by_group( + db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None, +) -> dict[int, dict]: + """按 group_id 聚合点击 → {group_id: {pv, uv, copy_pv, copy_uv}}。 + visit 计入 pv/uv,copy(淘宝复制口令) 计入 copy_pv/copy_uv;uv 按 (ip, ua) 近似去重。""" + stmt = select(CpsClick) + if date_from is not None: + stmt = stmt.where(CpsClick.clicked_at >= date_from) + if date_to is not None: + stmt = stmt.where(CpsClick.clicked_at <= date_to) + clicks = list(db.execute(stmt).scalars().all()) + + agg: dict[int, dict] = {} + seen_visit: dict[int, set] = {} + seen_copy: dict[int, set] = {} + for c in clicks: + a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0, "copy_pv": 0, "copy_uv": 0}) + key = (c.ip, c.ua) + if c.event_type == "copy": + a["copy_pv"] += 1 + s = seen_copy.setdefault(c.group_id, set()) + if key not in s: + s.add(key) + a["copy_uv"] += 1 + else: + a["pv"] += 1 + s = seen_visit.setdefault(c.group_id, set()) + if key not in s: + s.add(key) + a["uv"] += 1 + return agg diff --git a/app/repositories/cps_wx_user.py b/app/repositories/cps_wx_user.py new file mode 100644 index 0000000..9fde31e --- /dev/null +++ b/app/repositories/cps_wx_user.py @@ -0,0 +1,43 @@ +"""cps_wx_user 数据访问:按 openid upsert。""" +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.cps_wx_user import CpsWxUser + + +def get_by_openid(db: Session, openid: str) -> CpsWxUser | None: + return db.execute( + select(CpsWxUser).where(CpsWxUser.openid == openid) + ).scalar_one_or_none() + + +def upsert( + db: Session, *, openid: str, code: str | None = None, group_id: int | None = None, + nickname: str | None = None, headimgurl: str | None = None, unionid: str | None = None, +) -> CpsWxUser: + """按 openid upsert。 + + - 首次:建行,记来源 code/group。 + - 已存在:仅在传入非 None 时更新昵称/头像/unionid(base 阶段为 None,不覆盖已有画像); + 并刷新 last_seen(显式 set,因 onupdate 仅在字段有变更时触发)。 + """ + u = get_by_openid(db, openid) + if u is None: + u = CpsWxUser( + openid=openid, first_code=code, first_group_id=group_id, + nickname=nickname, headimgurl=headimgurl, unionid=unionid, + ) + db.add(u) + else: + if nickname is not None: + u.nickname = nickname + if headimgurl is not None: + u.headimgurl = headimgurl + if unionid is not None: + u.unionid = unionid + u.last_seen = func.now() + db.commit() + db.refresh(u) + return u diff --git a/app/repositories/launch_confirm_sample.py b/app/repositories/launch_confirm_sample.py new file mode 100644 index 0000000..4d0b226 --- /dev/null +++ b/app/repositories/launch_confirm_sample.py @@ -0,0 +1,35 @@ +"""启动确认窗兜底样本落库:单条 insert(都上报、不去重,by design)。""" +from __future__ import annotations + +import logging +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.launch_confirm_sample import LaunchConfirmSample +from app.schemas.launch_confirm_sample import LaunchConfirmSampleIn + +logger = logging.getLogger("shagua.launch_confirm") + + +def _trunc(s: Optional[str], n: int) -> Optional[str]: + """截断到列长上限(PG varchar 超长会报错;繁体长标题等需要)。空 → None。""" + return s[:n] if s else None + + +def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int: + """写入一条样本,返回新行 id。""" + row = LaunchConfirmSample( + trace_id=_trunc(payload.trace_id, 64), + device_id=_trunc(payload.device_id, 64), + host_package=_trunc(payload.host_package, 128), + target_app=_trunc(payload.target_app, 128), + system_locale=_trunc(payload.system_locale, 32), + exec_success=bool(payload.exec_success), + dialog_title=_trunc(payload.dialog_title, 256), + payload=payload.payload, + ) + db.add(row) + db.commit() + db.refresh(row) + return row.id diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py index 688652e..87128b7 100644 --- a/app/repositories/ops_marquee.py +++ b/app/repositories/ops_marquee.py @@ -6,9 +6,16 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条 种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、 feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。 -脱敏名风格:手机尾号(138****5678)+ 中文昵称(省钱**、小张**、吃货**达人)混合,看起来像真实 -异质用户群。真实条按 user_id 确定性合成(同用户恒定);种子留空 / 旧「用户****xxx」模板名一律随机 -合成(自愈历史种子,无需迁移)。 +脱敏名规则(全 feed 统一,按字符算、中英文皆适用): + - 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 / SaveKing→S******g), + 2 字「首+星」(阿强→阿*),1 字「用户+该字」(喵→用户喵); + - 没昵称 → 合成一个假名(见下)再脱敏,少数露「用户+id 后 3 位」(用户********618)。 +真实条:设过昵称的按真实昵称脱敏;当前多数用户没设昵称,直接展示「用户****」会清一色,故给它们合成 +假名——后期真实昵称多了,真实条会直接用真实昵称,合成占比自然下降。种子 / 兜底同样合成、避开同屏撞名; +种子留空 / 旧「用户****xxx」模板名一律重新合成(自愈历史种子,无需迁移)。 +假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾), +组合空间上万、中文为主英文为辅,脱敏后像真实异质用户群。**真实条按 `user_id` 播种确定性合成**(同一用户 +每次展示恒定、不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充。不依赖外部库。 """ from __future__ import annotations @@ -21,6 +28,7 @@ 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 +from app.models.user import User # feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」) _rng = random.Random() @@ -36,59 +44,111 @@ _REAL_ROWS_TTL_SECONDS = 30 _REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用 _real_rows_cache: dict = {"at": None, "rows": None} -# ===== 脱敏名生成:手机尾号 + 中文昵称 混合,看起来像真实异质用户群(替代旧的「用户********xxx」统一模板)===== -_PHONE_2ND = "3456789" # 手机号第 2 位(13x~19x) -_SURNAMES = "王李张刘陈杨黄赵周吴徐孙马朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾肖田董袁潘蒋蔡余杜叶程苏魏吕丁任沈姚卢" -_NICK_WORDS = [ - "省钱", "吃货", "羊毛", "薅羊毛", "爱比价", "精打细算", "持家", "干饭", - "美食控", "外卖", "剁手", "捡漏", "搞钱", "打工人", "摸鱼", "养生", - "佛系", "躺平", "暴富", "锦鲤", "夜宵", "奶茶", "热爱生活", "会过日子", +# ===== 用户标识脱敏 + 没真实昵称时的假名合成 ===== +# 脱敏规则(中英文皆适用,按字符算):有昵称→≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 / +# SaveKing→S******g)、2 字「首+星」(阿强→阿*)、1 字「用户+该字」(喵→用户喵);没昵称→用户+8星+id 后 3 位。 +# 没真实昵称时(当前多数用户 / 种子 / 兜底)合成假名:本地姓/名字池组合出真名 + 网络昵称语料(中/英) +# 混播再走同一套脱敏,像真实异质用户群。后期真实昵称多了,真实条直接用真实昵称,这里占比自然下降。 +_ANON_STARS = "*" * 8 # 无昵称匿名串固定 8 星(用户********xxx) + +# 中文真名组合池:姓(百家姓常见 ~100)× 名字单字(~120)拼「姓 + 1~2 字」,组合空间上万。 +# 脱敏后只露「姓*」或「姓*末」,故无需真实姓名库——组合够多即看着各异(替代原 Faker zh_CN)。 +_SURNAME_ZH = list( + "王李张刘陈杨赵黄周吴徐孙胡朱高林何郭马罗梁宋郑谢韩唐冯于董萧程曹袁邓许傅沈曾彭吕苏卢蒋蔡贾丁" + "魏薛叶阎余潘杜戴夏钟汪田任姜范方石姚谭廖邹熊金陆郝孔白崔康毛邱秦江史顾侯邵孟龙万段钱汤尹黎" + "易常武乔贺赖龚文施洪丰房邢" +) +_GIVEN_ZH = list( + "伟芳娜秀英敏静丽强磊军洋勇艳杰娟涛明超霞平刚桂兰华健世广义兴良海山仁波宁贵福生龙元全国胜学祥" + "才发新利清飞彬富顺信子昌成康星光天达安岩中茂进有坚和彪博诚先敬震振壮会思群豪心邦承乐绍功松善" + "厚庆民友裕河哲亮政谦亨奇之轮翰朗伯宏言若鸣朋斌栋维启克伦翔旭鹏泽晨辰士以建家致树炎德行时泰盛雄" +) +# 英文名池:与英文昵称互补,偶尔带数字尾(jay87)更像注册用户名(替代原 Faker en_US)。 +_FIRST_EN = [ + "Alex", "Chris", "Sam", "Jamie", "Taylor", "Jordan", "Casey", "Morgan", "Riley", "Jessie", + "Robin", "Drew", "Lee", "Max", "Kim", "Ray", "Dana", "Pat", "Terry", "Jay", + "Kelly", "Sky", "Ash", "Nico", "Remy", "Quinn", "Reese", "Sage", "Toni", "Val", +] + +# 网络昵称语料:补一份「省钱小能手 / SaveKing」式网络昵称更像真实 app 用户。想更丰富往里加即可。 +_NICK_ZH = [ + "省钱小能手", "薅羊毛专业户", "吃货一枚", "干饭人", "奶茶续命", "精打细算过日子", + "会过日子的人", "捡漏王", "外卖救星", "养生少年", "持家有道", "比价狂魔", + "月光族逆袭", "摸鱼达人", "暴富锦鲤", "美食猎人", "折扣猎手", "隐形贫困人口", + "打工不打烊", "佛系青年", "元气满满", "一只小馋猫", "爱吃的小朋友", "省钱才是硬道理", +] +_NICK_EN = [ + "SaveKing", "DealHunter", "FoodieLife", "BudgetBoss", "CouponQueen", "ThriftyOne", + "SnackLover", "BargainHero", "PennyWise", "SmartShopper", "DiscountNinja", "HungryPanda", + "MoneyMaster", "CheapEats", "SaverPro", "FrugalFox", "NoodleKing", "BubbleTeaFan", + "GoldenSaver", "ValueHunter", "SnackAttack", "LazyFoodie", ] -_NICK_PREFIX = ["小", "大", "老", "阿", "一只", "爱吃的", "快乐的"] -_NICK_SUFFIX = ["达人", "小能手", "侠", "党", "一族", "星人", "鸭", "酱"] -_LETTER_U = "ABCDEFGHJKLMNPQRSTUVWXYZ" -_LETTER_L = "abcdefghijkmnpqrstuvwxyz" -def _phone_name(rng: random.Random) -> str: - """手机尾号式:1[3-9]X****XXXX(合成、非真实号)。""" - head = "1" + rng.choice(_PHONE_2ND) + str(rng.randint(0, 9)) - return f"{head}****{rng.randint(0, 9999):04d}" +def _mask_nickname(nick: str) -> str: + """昵称脱敏(按字符,中英文皆可):≥3 字→首+(隐藏字数个)星+末(省钱小能手→省***手 / SaveKing→S******g); + 2 字→首+星(阿强→阿*);1 字→「用户」+该字(喵→用户喵)。调用方需保证 nick 已 strip 且非空。""" + n = len(nick) + if n >= 3: + return nick[0] + "*" * (n - 2) + nick[-1] + if n == 2: + return nick[0] + "*" + return "用户" + nick -def _nickname(rng: random.Random) -> str: - """昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。""" - p = rng.randint(0, 11) - if p in (0, 1, 2): - return rng.choice(_NICK_WORDS) + "**" # 省钱** - if p in (3, 4): - return rng.choice(_NICK_WORDS) + "**" + rng.choice(_NICK_SUFFIX) # 吃货**达人 - if p in (5, 6): - return rng.choice(_SURNAMES) + "**" # 张** - if p in (7, 8): - return rng.choice(_NICK_PREFIX) + rng.choice(_SURNAMES) + "**" # 小张** - if p in (9, 10): - return rng.choice(_NICK_WORDS) + rng.choice(_LETTER_U) + "**" # 中英混 省钱A** - return rng.choice(_LETTER_U) + "**" + rng.choice(_LETTER_L) # 纯字母(约 1/12) +def _anon_by_id(user_id: int) -> str: + """没昵称匿名:固定「用户」+ 8 星 + id 后 3 位(不足 3 位前补 0):用户********618。""" + return "用户" + _ANON_STARS + f"{user_id % 1000:03d}" -def _realistic_name(rng: random.Random) -> str: - """~45% 手机尾号 + ~55% 中文昵称,混合出真实异质感。""" - return _phone_name(rng) if rng.random() < 0.45 else _nickname(rng) +def _synth_full_name(rng: random.Random) -> str: + """合成一个完整(未脱敏)假名:中文真名(姓+1~2字) / 中文网络昵称 / 英文昵称 / 英文名(可带数字尾) 混播, + 中文为主(~70%)英文为辅(~30%)。**全程用传入 rng** → 同种子复现、不同种子各异、组合空间上万。""" + r = rng.random() + if r < 0.35: + # 中文真名:姓 + 1~2 个名字字(60% 概率两字),脱敏后露「姓*」或「姓*末」 + given = rng.choice(_GIVEN_ZH) + (rng.choice(_GIVEN_ZH) if rng.random() < 0.6 else "") + return rng.choice(_SURNAME_ZH) + given # 王伟 / 李秀兰 + if r < 0.70: + return rng.choice(_NICK_ZH) # 中文网络昵称:省钱小能手 + if r < 0.85: + return rng.choice(_NICK_EN) # 英文昵称:SaveKing + base = rng.choice(_FIRST_EN) # 英文名,半数带数字尾:Jay87 / Alex + return base + (str(rng.randint(1, 99)) if rng.random() < 0.5 else "") -def _mask_user(user_id: int) -> str: - """真实用户脱敏:按 user_id 确定性合成一个名(同用户恒定、不暴露真实信息)。""" - return _realistic_name(random.Random(user_id)) +def _synth_masked_name(rng: random.Random) -> str: + """合成一条「已脱敏」假名(供没真实昵称的真实用户 / 种子 / 兜底用)。用传入 rng 决定一切随机。""" + full = _synth_full_name(rng).strip() + return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}" + + +def _mask_real(nickname: str | None, user_id: int) -> str: + """真实用户脱敏:设过昵称→昵称脱敏(中英文皆可);没昵称→**按 user_id 播种确定性合成假名**再脱敏 + (同一用户每次展示恒定、刷新不变脸,不同用户各异),避免无昵称用户清一色「用户****xxx」; + 少数(~15%)露「用户+真实 id 后 3 位」。当前多数用户没昵称,后期真实昵称多了占比下降。""" + nick = (nickname or "").strip() + if nick: + return _mask_nickname(nick) + # 关键:按 user_id 播种本地 rng → 同一用户的合成名跨请求/刷新恒定(0.15 抽签也用它)。 + seeded = random.Random(user_id) + return _anon_by_id(user_id) if seeded.random() < 0.15 else _synth_masked_name(seeded) + + +def _synth_name(rng: random.Random) -> str: + """合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 3 位(用户********618)。""" + if rng.random() < 0.15: + return "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}" + return _synth_masked_name(rng) def _unique_name(used: set[str]) -> str: - """随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名用),防同屏撞名。""" + """随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名 / 兜底用),防同屏撞名。""" for _ in range(60): - n = _realistic_name(_rng) + n = _synth_name(_rng) if n not in used: return n - return _realistic_name(_rng) + str(_rng.randint(0, 99)) # 兜底(几乎不会到) + return "用户" + _ANON_STARS + f"{_rng.randint(0, 999):03d}" # 兜底:数字尾号天然好去重 def _validate_amount(min_cents: int, max_cents: int) -> None: @@ -116,8 +176,9 @@ _FALLBACK_MAX_CENTS = 3500 # ===== 用户侧:轮播 feed(真实 + 种子混播) ===== -def _recent_real_rows(db: Session) -> list[tuple[int, int]]: - """近期 success 且省到钱的 (user_id, saved_cents),按 created_at desc;带 ~30s 进程内缓存。 +def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]: + """近期 success 且省到钱的 (user_id, saved_cents, nickname),按 created_at desc;带 ~30s 进程内缓存。 + join user 取真实昵称用于脱敏(无昵称则展示层走「用户+id 后 3 位」)。 返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。 """ now = datetime.now(CN_TZ) @@ -125,7 +186,8 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int]]: if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS: return cached rows = db.execute( - select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents) + select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents, User.nickname) + .join(User, User.id == ComparisonRecord.user_id) .where( ComparisonRecord.status == "success", ComparisonRecord.saved_amount_cents > 0, @@ -134,7 +196,7 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int]]: .order_by(ComparisonRecord.created_at.desc()) .limit(_REAL_ROWS_FETCH_CAP) ).all() - out = [(int(uid), int(sc)) for uid, sc in rows] + out = [(int(uid), int(sc), nick) for uid, sc, nick in rows] _real_rows_cache["rows"], _real_rows_cache["at"] = out, now return out @@ -155,12 +217,12 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]: items: list[dict] = [] used_names: set[str] = set() seen_users: set[int] = set() - for uid, sc in rows: + for uid, sc, nick in rows: if uid in seen_users: continue seen_users.add(uid) - name = _mask_user(uid) - used_names.add(name) # 真实名按 user_id 稳定;偶发撞名可接受 + name = _mask_real(nick, uid) + used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受 items.append({"masked_user": name, "saved_amount_cents": int(sc)}) if len(items) >= limit: break diff --git a/app/repositories/signin.py b/app/repositories/signin.py index 3d0927e..c65ce38 100644 --- a/app/repositories/signin.py +++ b/app/repositories/signin.py @@ -1,7 +1,7 @@ """签到 CRUD:状态查询 + 执行签到。 -14 天循环规则: - - 昨天签过 → 今天 cycle_day = 昨天 % 14 + 1,streak += 1(连续) +7 天循环规则(2026-06 由 14 天改 7 天一轮,周期长度统一取 SIGNIN_CYCLE_LEN): + - 昨天签过 → 今天 cycle_day = 昨天 % SIGNIN_CYCLE_LEN + 1,streak += 1(连续) - 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置) 今天的 cycle_day 决定发多少金币(档位来自 rewards.get_signin_rewards,运营后台可改)。 """ @@ -33,7 +33,7 @@ class AlreadyBoostedError(Exception): class LastCycleDayBoostBlockedError(Exception): - """14 天循环最后一天不允许签到膨胀。""" + """循环最后一天(第 SIGNIN_CYCLE_LEN 天)不允许签到膨胀。""" @dataclass @@ -77,7 +77,8 @@ def get_status(db: Session, user_id: int) -> SigninStatus: today_signed = last is not None and last.signin_date == today if today_signed: - today_cycle_day = last.cycle_day + # 14→7 改制遗留:老记录 cycle_day 可能 >SIGNIN_CYCLE_LEN,归一化到 1..LEN 防 rewards_list 越界 + today_cycle_day = (last.cycle_day - 1) % SIGNIN_CYCLE_LEN + 1 consecutive_days = last.streak else: today_cycle_day = _next_cycle_day(last, today) diff --git a/app/repositories/store_mapping.py b/app/repositories/store_mapping.py new file mode 100644 index 0000000..ed59cfc --- /dev/null +++ b/app/repositories/store_mapping.py @@ -0,0 +1,212 @@ +"""平台店铺映射落库:一次比价(trace)一行,各目标平台解析出 id 就 upsert 进同一行。 + +合并键 = trace_id(唯一约束)。一次跨平台比价 = 一个 trace = 一个真实店铺:淘宝腿解析出 +shopId 先建行(填淘宝列),京东腿(将来)解析出 id 再 upsert 进**同一行**(填京东列)。 + +合并策略 = 填空(fill-the-blanks):只写该行当前为 NULL 的列,绝不覆盖已有非空值。保证后到 +的平台只填自己那几列、动不了先到平台的数据;共享列(geo / source / 溯源)先到先得。 +不依赖 ON CONFLICT,跨方言(PG / SQLite dev)都安全;并发撞唯一约束则回滚后转走合并路径。 + +⚠️ 一行里 id_taobao 与 id_jd 共存只表示"两条腿搜同一个源店名各自匹配到了某家店",是 +name-match 置信度、非已核实同一实体。作为 append-only 原始资产留存,跨平台精确匹配由下游做。 +""" +from __future__ import annotations + +import logging +import math + +from sqlalchemy import func, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.models.store_mapping import StoreMapping +from app.schemas.store_mapping import StoreMappingIn + +logger = logging.getLogger("shagua.store_mapping") + +# 源平台 → 该平台店名所在列(缓存查询的匹配键)。镜像 pricebot reporter 的同名表。 +_SOURCE_NAME_COLUMN = { + "meituan": "name_meituan", + "meituan_waimai": "name_meituan", + "jd_waimai": "name_jd", + "jd_waimai_standalone": "name_jd", + "taobao_flash": "name_taobao", +} + +# 源平台 → 它自己对应的目标 key(查缓存时排除"源平台自己", 不会复用源平台的店铺 id)。 +_SOURCE_TARGET_KEY = { + "meituan": "meituan", "meituan_waimai": "meituan", + "jd_waimai": "jd", "jd_waimai_standalone": "jd", + "taobao_flash": "taobao", +} + +# upsert 填空时可写的列。不含: trace_id(合并键)/ business_type(非空默认)/ +# id(主键)/ created_at(server_default)。各平台只会带自己那几列非空, 其余为 None 不动。 +_MERGE_COLUMNS = ( + "source_platform", + "id_taobao", "name_taobao", "id_meituan", "name_meituan", "id_jd", "name_jd", + "city", "geohash", "lng", "lat", "taobao_address", + "source_device_id", "source_user_id", + "taobao_share_url", "taobao_resolved_url", "taobao_deeplink", + "meituan_poi_id_str", "meituan_share_url", "meituan_resolved_url", "meituan_deeplink", + "jd_vender_id", "jd_share_url", "jd_resolved_url", "jd_deeplink", + "attrs", +) + + +def _find(db: Session, trace_id: str) -> StoreMapping | None: + return db.execute( + select(StoreMapping).where(StoreMapping.trace_id == trace_id) + ).scalar_one_or_none() + + +def _merge_fill_blanks(existing: StoreMapping, payload: StoreMappingIn) -> list[str]: + """把 payload 里非空、且 existing 当前为 NULL 的列填进去。返回被填的列名(空=无变化)。""" + filled: list[str] = [] + for col in _MERGE_COLUMNS: + new = getattr(payload, col) + if new is not None and getattr(existing, col) is None: + setattr(existing, col, new) + filled.append(col) + return filled + + +def upsert(db: Session, payload: StoreMappingIn) -> tuple[int, int | None]: + """落一行跨平台店铺映射,返回 (created, row_id)。 + created=1 新建该 trace 的行 / 0 合并进已存在行(填空,不覆盖)。""" + existing = _find(db, payload.trace_id) + if existing is None: + # 首写: 把 payload 全部可合并列灌进去(逐列 setattr 而非硬编码构造器, 否则首写的是 + # 美团/京东腿时它们的列会漏 —— 不在硬编码列表里就丢)。trace_id/business_type 是键/默认, 显式给。 + row = StoreMapping( + trace_id=payload.trace_id, + business_type=payload.business_type, + ) + for col in _MERGE_COLUMNS: + setattr(row, col, getattr(payload, col)) + db.add(row) + try: + db.commit() + except IntegrityError: + # 并发: 另一个请求刚插了同 trace → 撞唯一约束。回滚后转合并路径填空。 + db.rollback() + existing = _find(db, payload.trace_id) + if existing is None: + raise + logger.warning("store_mapping 并发冲突 trace=%s, 转填空合并", payload.trace_id) + else: + db.refresh(row) + return 1, row.id + + # 已存在(或并发回退到此): 填空合并, 只写当前 NULL 的列 + filled = _merge_fill_blanks(existing, payload) + if filled: + db.commit() + db.refresh(existing) + logger.info("store_mapping 合并 trace=%s 填列=%s", payload.trace_id, filled) + return 0, existing.id + + +def mark_taobao_deeplink_invalid(db: Session, shop_id: str) -> int: + """把所有 id_taobao=shop_id 的行标记淘宝 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。 + + 按 shopId 标记**所有**行 —— 同一个坏 shopId(撞淘宝"页面出错了"降级页)可能散在多次比价的 + 多行里, 全标掉才能让后续 lookup 不再返回它。幂等: 已标记的行(invalid_at 非 NULL)跳过。""" + result = db.execute( + update(StoreMapping) + .where( + StoreMapping.id_taobao == shop_id, + StoreMapping.taobao_deeplink_invalid_at.is_(None), + ) + .values(taobao_deeplink_invalid_at=func.now()) + ) + db.commit() + return result.rowcount or 0 + + +def mark_jd_deeplink_invalid(db: Session, store_id: str) -> int: + """把所有 id_jd=store_id 的行标记京东 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。 + + 同 mark_taobao_deeplink_invalid:按 storeId 标记**所有**行(撞京东"当前门店超出配送范围"页), + 全标掉才能让后续 lookup 不再返回它。幂等:已标记的行(invalid_at 非 NULL)跳过。""" + result = db.execute( + update(StoreMapping) + .where( + StoreMapping.id_jd == store_id, + StoreMapping.jd_deeplink_invalid_at.is_(None), + ) + .values(jd_deeplink_invalid_at=func.now()) + ) + db.commit() + return result.rowcount or 0 + + +# ============================================================ +# 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接 +# deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。 +# ============================================================ + +def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + """两点球面距离(km)。仅用于同名候选里挑最近, 精度够用。""" + r = 6371.0 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lng2 - lng1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * r * math.asin(math.sqrt(a)) + + +def _pick_best(rows: list[StoreMapping], lat: float | None, lng: float | None) -> StoreMapping: + """同名 + 含目标 id 的候选里挑一条:有入参 geo 且有候选带 geo → 取最近;否则取 created_at 最新。""" + if lat is not None and lng is not None: + geod = [r for r in rows if r.lat is not None and r.lng is not None] + if geod: + return min(geod, key=lambda r: _haversine_km(lat, lng, r.lat, r.lng)) + return max(rows, key=lambda r: r.created_at) + + +# 目标 key → (该平台店铺 id 列, 组装返回 payload 的函数)。pricebot 拿 id 现拼 deeplink。 +_TARGETS = { + "taobao": ("id_taobao", lambda r: {"shop_id": r.id_taobao, "deeplink": r.taobao_deeplink}), + "jd": ("id_jd", lambda r: {"store_id": r.id_jd, "vender_id": r.jd_vender_id, "deeplink": r.jd_deeplink}), + "meituan": ("meituan_poi_id_str", lambda r: {"poi_id_str": r.meituan_poi_id_str, "deeplink": r.meituan_deeplink}), +} + + +def lookup_nearest( + db: Session, source_platform: str, store_name: str, + lat: float | None = None, lng: float | None = None, +) -> dict: + """按"源平台店名"反查各目标平台已沉淀的店铺 id。返回 {target_key: {id..., deeplink, row_id, ...}}。 + - 匹配键 = 源平台对应的 name 列 == store_name(精确)。 + - 每个目标**分别**取"含该目标 id 的同名候选里最近一条"(淘宝 id / 京东 id 可能在不同行)。 + - 排除源平台自己(不复用源平台的店铺 id)。命中为空 = 没缓存, pricebot 走现场反查老路。""" + name_col = _SOURCE_NAME_COLUMN.get(source_platform) + if not name_col or not store_name: + return {} + rows = db.execute( + select(StoreMapping).where(getattr(StoreMapping, name_col) == store_name) + ).scalars().all() + if not rows: + return {} + src_key = _SOURCE_TARGET_KEY.get(source_platform) + out: dict = {} + for tgt, (id_attr, make_payload) in _TARGETS.items(): + if tgt == src_key: + continue # 不返回源平台自己 + cands = [r for r in rows if getattr(r, id_attr)] + if tgt == "taobao": + # 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。 + cands = [r for r in cands if r.taobao_deeplink_invalid_at is None] + elif tgt == "jd": + # 同上:失效的京东 deeplink(撞"当前门店超出配送范围"被 invalidate)整条排除。 + cands = [r for r in cands if r.jd_deeplink_invalid_at is None] + if not cands: + continue + best = _pick_best(cands, lat, lng) + payload = make_payload(best) + payload["row_id"] = best.id + if best.lat is not None and best.lng is not None and lat is not None and lng is not None: + payload["dist_km"] = round(_haversine_km(lat, lng, best.lat, best.lng), 3) + out[tgt] = payload + return out diff --git a/app/repositories/task.py b/app/repositories/task.py index 9b592b6..309f5e8 100644 --- a/app/repositories/task.py +++ b/app/repositories/task.py @@ -7,14 +7,31 @@ from __future__ import annotations from dataclasses import dataclass -from sqlalchemy import select +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core import rewards from app.models.task import UserTask +from app.models.wallet import CoinTransaction from app.repositories import wallet as crud_wallet +def _notification_grant_times(db: Session, user_id: int) -> int: + """打开消息提醒已领取次数 = 该 biz_type 的【正向】金币流水条数(可重复任务不写 user_task)。 + + 限 amount>0 只数发放笔,避免日后出现该 biz_type 的回滚/冲正负向流水时把次数算大、减半档位错。 + """ + biz = f"task_{rewards.TASK_ENABLE_NOTIFICATION}" + return db.execute( + select(func.count()).select_from(CoinTransaction).where( + CoinTransaction.user_id == user_id, + CoinTransaction.biz_type == biz, + CoinTransaction.amount > 0, + ) + ).scalar_one() or 0 + + class UnknownTaskError(Exception): """task_key 不在已知任务表里。""" @@ -31,14 +48,30 @@ class TaskState: def list_tasks(db: Session, user_id: int) -> list[TaskState]: - """返回所有已知一次性任务及其领取状态。""" + """返回所有已知任务及其领取状态。 + + 打开消息提醒是可重复任务:claimed 恒 False,coin = 下一次(减半后)的金额; + 客户端按「通知权限是否开启」决定展不展示(开着就隐藏,不在这里判断)。 + 其余为一次性任务,user_task 唯一约束去重。 + """ task_rewards = rewards.get_task_rewards(db) stmt = select(UserTask.task_key).where(UserTask.user_id == user_id) claimed_keys = set(db.execute(stmt).scalars().all()) - return [ - TaskState(task_key=key, coin=coin, claimed=key in claimed_keys) - for key, coin in task_rewards.items() - ] + result: list[TaskState] = [] + for key, coin in task_rewards.items(): + if key == rewards.TASK_ENABLE_NOTIFICATION: + times = _notification_grant_times(db, user_id) + exhausted = times >= rewards.notification_max_claims(coin) + result.append( + TaskState( + task_key=key, + coin=rewards.notification_reward(coin, times), + claimed=exhausted, # 领满(减半到底)后置 True,客户端据此可隐藏 + ) + ) + else: + result.append(TaskState(task_key=key, coin=coin, claimed=key in claimed_keys)) + return result def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]: @@ -50,6 +83,27 @@ def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]: if task_key not in task_rewards: raise UnknownTaskError + # 打开消息提醒:可重复领取,每次减半。不写 user_task(不去重),已领次数用金币流水条数算; + # ref_id 带序号(task_key:N)让流水可读、且 (user_id,biz_type,ref_id) 唯一索引能挡并发/连点 + # 重复发放。减半到底(notification_max_claims)后不再可领。客户端只在通知权限关闭时展示+触发。 + if task_key == rewards.TASK_ENABLE_NOTIFICATION: + base = task_rewards[task_key] + times = _notification_grant_times(db, user_id) + if times >= rewards.notification_max_claims(base): + raise AlreadyClaimedError + coin = rewards.notification_reward(base, times) + try: + acc, _ = crud_wallet.grant_coins( + db, user_id, coin, + biz_type=f"task_{task_key}", ref_id=f"{task_key}:{times + 1}", + ) + db.commit() + except IntegrityError as e: + # 并发/连点:另一个请求已抢先发了同一序号(撞唯一索引)。回滚,按已领处理,不双发。 + db.rollback() + raise AlreadyClaimedError from e + return coin, acc.coin_balance + existing = db.execute( select(UserTask).where( UserTask.user_id == user_id, UserTask.task_key == task_key diff --git a/app/repositories/user.py b/app/repositories/user.py index 697d7e8..7753e33 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -4,6 +4,8 @@ """ from __future__ import annotations +import secrets +import string from datetime import datetime, timezone from sqlalchemy import select @@ -12,6 +14,50 @@ from sqlalchemy.orm import Session from app.models.user import User +# ===== 创建时分配的标识:用户名(对外展示账号 ID)+ 默认昵称 ===== + +# 用户名 = 11 位纯数字,首位 2-9(避前导 0、避 1 与手机号区分),后 10 位 0-9。 +# 空间 8×10^10,创建时随机生成 + 查重去重,全局唯一、不可变、不参与登录。 +_USERNAME_FIRST = "23456789" +_USERNAME_DIGITS = "0123456789" +_USERNAME_LEN = 11 + +# 默认昵称 = 9 位大小写字母 + 数字随机串(创建时给,用户可后续改;不要求唯一)。 +_NICKNAME_ALPHABET = string.ascii_letters + string.digits +_NICKNAME_LEN = 9 + + +def _gen_username() -> str: + """随机一个 11 位纯数字、首位非 0/1 的用户名(不保证唯一,唯一性由 _gen_unique_username 兜)。""" + return secrets.choice(_USERNAME_FIRST) + "".join( + secrets.choice(_USERNAME_DIGITS) for _ in range(_USERNAME_LEN - 1) + ) + + +def _gen_nickname() -> str: + """随机一个 9 位字母+数字的默认昵称。""" + return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(_NICKNAME_LEN)) + + +def get_user_by_username(db: Session, username: str) -> User | None: + return db.execute( + select(User).where(User.username == username) + ).scalar_one_or_none() + + +def _gen_unique_username(db: Session) -> str: + """生成一个库里尚不存在的用户名(仿 invite.ensure_code 的碰撞重试)。 + + 8×10^10 空间下碰撞极罕见,查重 + 重试足够;并发下的残留碰撞由 username 唯一约束 + 兜底(commit 抛 IntegrityError,与现有 phone 并发同号同级别,概率 ~ 用户数/8e10)。 + """ + for _ in range(8): + uname = _gen_username() + if get_user_by_username(db, uname) is None: + return uname + raise RuntimeError("生成用户名连续碰撞,请重试") + + def get_user_by_id(db: Session, user_id: int) -> User | None: return db.get(User, user_id) @@ -36,6 +82,8 @@ def upsert_user_for_login( if user is None: user = User( phone=phone, + username=_gen_unique_username(db), + nickname=_gen_nickname(), register_channel=register_channel, last_login_at=now, ) @@ -63,14 +111,27 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User: def soft_delete_account(db: Session, user: User) -> None: - """注销账号:软删除 + 匿名化。 + """注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定。 - 把 phone 改成 `deleted_` 释放唯一约束,允许同号码重新注册成全新账号; + 把 phone 改成 `deleted_` 释放手机号唯一约束,允许同号码重新注册成全新账号; 清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行 (登录接口校验 status==active)。 + + ⚠️ 注销必须连同释放 user 上其余唯一约束/外部身份,否则 deleted 行永久占着槽位, + 对应资源再也无法被复用: + - wechat_openid: 不清 → 这个微信再也无法被任何账号绑定(提现通道彻底锁死); + - invite_code: 不清 → 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind 的 + status==active 校验兜住,清掉更干净)。 + 资金安全(未提现现金 / 在审提现单)由调用方 delete_account 端点前置校验,这里只匿名化。 """ user.status = "deleted" user.phone = f"deleted_{user.id}" user.nickname = None user.avatar_url = None + # 等价"解绑微信":释放 openid 唯一槽 + 清微信昵称头像 + user.wechat_openid = None + user.wechat_nickname = None + user.wechat_avatar_url = None + # 释放邀请码唯一槽 + user.invite_code = None db.commit() diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 7be86b4..a38ac88 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -78,9 +78,15 @@ class WithdrawNotReviewable(Exception): """提现单当前状态不可审核(非 reviewing,可能已被处理过)。""" -def get_or_create_account(db: Session, user_id: int, *, commit: bool = True) -> CoinAccount: - """取用户金币账户,不存在则建一个空账户。""" - acc = db.get(CoinAccount, user_id) +def get_or_create_account( + db: Session, user_id: int, *, commit: bool = True, lock: bool = False +) -> CoinAccount: + """取用户金币账户,不存在则建一个空账户。 + + lock=True 时对已存在的账户行加 SELECT FOR UPDATE(读-算-写余额的调用方串行化,防并发 + 双写余额错位,如 admin set 模式连点);默认 False 不改 C 端发奖行为。SQLite 下为 no-op。 + """ + acc = db.get(CoinAccount, user_id, with_for_update=True) if lock else db.get(CoinAccount, user_id) if acc is None: acc = CoinAccount( user_id=user_id, @@ -334,6 +340,35 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict: return info +def get_cash_balance_cents(db: Session, user_id: int) -> int: + """只读查现金余额(分)。账户不存在返回 0,**不创建账户**——注销前置校验用, + 不该给一个即将被删除的用户凭空建一行空账户(get_or_create_account 会建)。""" + val = db.execute( + select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id) + ).scalar_one_or_none() + return val or 0 + + +def has_reviewing_withdraw(db: Session, user_id: int) -> bool: + """用户是否有「待审核(reviewing)」的提现单。 + + 只看 reviewing:这类单还没打款,用户确认解绑时会被立即退回现金 + (refund_reviewing_withdraws_on_unbind),解绑前据此提示用户知情确认。 + pending 单转账已在途(execute 已过 openid 闸),解绑影响不到、照常打款,故不计入。 + """ + return ( + db.execute( + select(WithdrawOrder.id) + .where( + WithdrawOrder.user_id == user_id, + WithdrawOrder.status == "reviewing", + ) + .limit(1) + ).first() + is not None + ) + + def unbind_wechat_openid(db: Session, user_id: int) -> None: """解绑微信:清空 openid。已绑定才清,未绑定为幂等空操作。""" user = db.get(User, user_id) @@ -346,6 +381,33 @@ def unbind_wechat_openid(db: Session, user_id: int) -> None: db.commit() +def refund_reviewing_withdraws_on_unbind(db: Session, user_id: int) -> int: + """解绑微信时,把该用户所有「待审核(reviewing)」提现单立即退回现金并置终态。 + + reviewing 单还没打款(钱在 create_withdraw 时已从现金扣进待审核单),解绑后这笔无法 + 打款到微信,直接退回现金最干净:用户即时拿回钱、后台不再挂死单(不必等管理员审核)。 + 复用 _refund_withdraw(退现金 + 流水 + 幂等)。返回退掉的单数。 + """ + orders = ( + db.execute( + select(WithdrawOrder).where( + WithdrawOrder.user_id == user_id, + WithdrawOrder.status == "reviewing", + ) + ) + .scalars() + .all() + ) + for order in orders: + _refund_withdraw( + db, + order, + reason="用户解绑微信,待审核提现自动退回", + remark="解绑微信,提现已退回现金余额", + ) + return len(orders) + + _OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$") @@ -381,7 +443,8 @@ def _add_cash(db: Session, user_id: int, amount_cents: int) -> int: def _refund_withdraw( - db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed" + db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed", + remark: str | None = None, ) -> None: """退回现金 + 写 withdraw_refund 流水 + 单子置终态。幂等:已是退款终态不重复退。 @@ -389,6 +452,7 @@ def _refund_withdraw( - "failed" (默认):微信转账失败/取消 → 自动退款 - "rejected":管理员审核拒绝 → 退款(走 reject_withdraw) 两种都已退款,用 in 判定防重复退(并发/对账/拒绝重复点)。 + remark:给用户可见的现金流水文案;省略则按 final_status 取默认(解绑退回等场景可自定义)。 """ if order.status in ("failed", "rejected"): return # 防重复退款(并发/对账与查单/重复拒绝同时触发) @@ -413,7 +477,10 @@ def _refund_withdraw( biz_type="withdraw_refund", ref_id=order.out_bill_no, # 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason - remark="提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回", + remark=( + remark + or ("提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回") + ), created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), ) ) diff --git a/app/schemas/ad.py b/app/schemas/ad.py index 4144dd2..b7feccf 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -57,6 +57,12 @@ class EcpmReportIn(BaseModel): ) adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle") slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") + app_env: str | None = Field( + None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" + ) + our_code_id: str | None = Field( + None, max_length=64, description="我们后台配置的代码位 ID(AdConfig 的 104xxx,非底层 rit)" + ) class EcpmReportOut(BaseModel): @@ -115,17 +121,18 @@ class TestGrantOut(BaseModel): class FeedRewardIn(BaseModel): - """信息流广告完成后结算奖励。 + """比价/领券一整场信息流(轮播多条)结束后结算奖励。 - 每展示满 10 秒累计一份奖励,视频完成后一次性入账。client_event_id 用于客户端超时重试幂等。 + 规则:全程不关广告才发,金额按整场**总观看时长**折份(每 10 秒 1 份)。client_event_id 用于 + 客户端超时重试幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。 """ client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id") ad_session_id: str | None = Field( None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id" ) - ecpm: str = Field(..., description="本条信息流广告 eCPM,按分/千次展示处理(SDK getEcpm 原值,非元)") - duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数") + ecpm: str = Field(..., description="本场信息流 eCPM(代表值,按分/千次展示处理;SDK getEcpm 原值,非元)") + duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)") adn: str | None = Field(None, description="实际投放 ADN") slot_id: str | None = Field(None, description="实际展示代码位") feed_scene: str | None = Field( @@ -134,11 +141,48 @@ class FeedRewardIn(BaseModel): description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);" "比价与领券共用同一信息流代码位,需客户端在各调用点显式标注,缺省=未分类", ) + app_env: str | None = Field( + None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" + ) + our_code_id: str | None = Field( + None, max_length=64, description="我们后台配置的代码位 ID(AdConfig 的 104xxx,非底层 rit)" + ) + aborted: bool = Field( + False, description="用户中途 ✕ 关闭广告(未走完比价):整场不发,记 closed_early" + ) class FeedRewardOut(BaseModel): granted: bool = Field(..., description="本次是否入账。达上限时 false") - status: str = Field(..., description="granted / capped") + status: str = Field(..., description="granted / capped / too_short / closed_early") coin: int = Field(..., description="本次发放金币") unit_count: int = Field(..., description="按 10 秒折算出的奖励份数") daily_limit: int = Field(..., description="每日信息流展示次数上限") + + +class RewardNoShowIn(BaseModel): + """激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。 + + 供广告收益报表把「有展示、没发金币」的原因也记录下来。user_id 由 JWT 取,不在 body。 + """ + + ad_session_id: str = Field( + ..., min_length=8, max_length=64, description="本次展示会话 id(与 ecpm 上报、S2S extra 一致)" + ) + watched_seconds: int = Field(0, ge=0, description="关闭前已观看秒数(仅留痕参考,不入库)") + ecpm: str | None = Field(None, description="本次展示 eCPM(分/千次,SDK getEcpm 原值);可空") + adn: str | None = Field(None, description="实际投放 ADN") + slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") + app_env: str | None = Field( + None, max_length=16, description="我们的穿山甲应用环境:prod / test" + ) + our_code_id: str | None = Field( + None, max_length=64, description="我们后台配置的代码位 ID(104xxx)" + ) + + +class RewardNoShowOut(BaseModel): + ok: bool = Field(..., description="是否处理成功(落库或幂等命中)") + status: str = Field( + ..., description="closed_early(已留痕) / granted(该次其实已发奖,跳过未写)" + ) diff --git a/app/schemas/auth.py b/app/schemas/auth.py index a5eb077..600baaf 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -18,6 +18,8 @@ class UserOut(BaseModel): model_config = ConfigDict(from_attributes=True) # 允许直接 UserOut.model_validate(orm_user) id: int + # 对外展示的账号 ID:11 位纯数字、首位非 1、全局唯一、创建时分配、不可变。区别于登录用的 phone。 + username: str phone: str nickname: str | None = None avatar_url: str | None = None diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 2a6ba5f..5f7306c 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -24,6 +24,13 @@ class ComparisonItemIn(BaseModel): specs: list[str] | None = None +class AppliedCouponIn(BaseModel): + """单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。""" + + name: str + amount: float + + class ComparisonResultIn(BaseModel): """逐平台对比项(来自 done.params.comparison_results)。price 单位:元。""" @@ -40,6 +47,10 @@ class ComparisonResultIn(BaseModel): # 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。 # None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。 coupon_name: str | None = None + # 多券明细 [{name, amount}](全口径: 平台红包+商家券+满减+配送减免, amount 单位元正数)。 + # 跟 coupon_saved 并存, 是更丰富的明细; 空=没抠到 → 前端回退单券路径。 + # 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。 + applied_coupons: list[AppliedCouponIn] = Field(default_factory=list) class ComparisonRecordIn(BaseModel): @@ -58,6 +69,10 @@ class ComparisonRecordIn(BaseModel): # 明细 items: list[ComparisonItemIn] = Field(default_factory=list) comparison_results: list[ComparisonResultIn] = Field(default_factory=list) + # 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/ + # unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列), + # admin「卡在哪一步」从这里读。list[dict] 宽松存(结构由 pricebot 定,只作 debug 展示)。 + platform_results: list = Field(default_factory=list) skipped_dish_count: int | None = None skipped_dish_names: list[str] = Field(default_factory=list) total_dish_count: int | None = None @@ -71,6 +86,23 @@ class ComparisonRecordIn(BaseModel): # 时分秒前端拼不出,必须由后端透传)。 trace_url: str | None = Field(None, description="本次比价公网调试链接") + # ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)===== + # 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。 + device_model: str | None = None + device_manufacturer: str | None = None + rom_vendor: str | None = Field(None, description="vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung") + rom_name: str | None = Field(None, description="OriginOS/ColorOS/HyperOS/MIUI/HarmonyOS...") + rom_version: int | None = None + android_version: str | None = None + android_sdk: int | None = None + app_version: str | None = None + app_version_code: int | None = None + source_app_version: str | None = Field(None, description="被操控的源平台 App 版本") + longitude: float | None = None + latitude: float | None = None + total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)") + step_count: int | None = Field(None, description="agent loop 总步数") + # ===== 读取出参 ===== diff --git a/app/schemas/coupon_state.py b/app/schemas/coupon_state.py index fd66cb8..236d311 100644 --- a/app/schemas/coupon_state.py +++ b/app/schemas/coupon_state.py @@ -7,16 +7,30 @@ from pydantic import BaseModel class CouponPromptDismissIn(BaseModel): """客户端拒绝/关闭领券引导窗的通知体。 - server 据此记一条今日 engagement(dismissed)→ 今天这台设备不再弹引导窗。 + server 据此记一条今日 engagement(dismissed)→ 今天**这个 App** 不再弹引导窗。 + 频控按 (device, package, 日):各 App 独立。package 缺省 ""(老客户端兼容,退化为全局态)。 MVP 不鉴权,按 device_id 判断;user_id 登录态带上就一并记(资产),可空。 """ device_id: str + package: str = "" + user_id: int | None = None + + +class CouponPromptShownIn(BaseModel): + """客户端弹出领券引导窗即上报(记 shown)。 + + 弹出那刻就记一条今日 engagement(shown)→ 今天**这个 App** 不再自动弹(频控主判据, + 管跨重装;本地 SP 兜后台抖动)。后续用户点领取/拒绝再把 type 升级成 claim_started/dismissed。 + """ + + device_id: str + package: str user_id: int | None = None class CouponPromptShouldShowOut(BaseModel): - """切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。""" + """切到外卖 App 时是否还应弹领券引导窗。今天**这个 App** 已 engage(弹/领/拒)过 → false。""" should_show: bool @@ -25,3 +39,13 @@ class CouponCompletedTodayOut(BaseModel): """这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。""" completed: bool + + +class CouponStatsOut(BaseModel): + """「我的」页战绩卡「领取优惠券 X 张」数据源:该登录用户累计领到的券数。 + + 口径(2026-06-15 用户定):SUM(claimed_count) —— 各成功领券记录的 pricebot 展示张数之和, + 与领券完成时给用户看的「本次领了 N 张」同源。详见 repositories.coupon_state.sum_claimed_count。 + """ + + coupon_count: int diff --git a/app/schemas/launch_confirm_sample.py b/app/schemas/launch_confirm_sample.py new file mode 100644 index 0000000..e2ed223 --- /dev/null +++ b/app/schemas/launch_confirm_sample.py @@ -0,0 +1,26 @@ +"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。""" +from __future__ import annotations + +from pydantic import BaseModel + + +class LaunchConfirmSampleIn(BaseModel): + """一次 agent 兜底的完整样本。 + + 字段一律宽松可空:落盘是辅助数据,绝不能因个别字段缺失就拒收(否则连累 pricebot 兜底)。 + """ + + trace_id: str | None = None + device_id: str | None = None + host_package: str | None = None + target_app: str | None = None + system_locale: str | None = None + exec_success: bool = False + dialog_title: str | None = None + payload: dict | None = None + + +class LaunchConfirmSampleOut(BaseModel): + """落库结果。""" + + id: int diff --git a/app/schemas/platform.py b/app/schemas/platform.py index ec8619c..4c97bd6 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -1,7 +1,7 @@ """首页平台级展示数据 schemas(客户端首页门面数字)。""" from __future__ import annotations -from pydantic import BaseModel +from pydantic import BaseModel, Field class PlatformStatsOut(BaseModel): @@ -22,3 +22,52 @@ class SavingsFeedItem(BaseModel): class SavingsFeedOut(BaseModel): items: list[SavingsFeedItem] + + +class AppFlagsOut(BaseModel): + """客户端拉取的运营 feature flag(不鉴权,登录前也能拉)。客户端缓存后按需读。""" + + comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch) + + +class AdConfigPublicOut(BaseModel): + """下发给客户端的穿山甲广告配置(不鉴权,客户端缓存后用)。 + + ⚠️ 不含 reward_mkey(GroMore 验签密钥,机密,只后端验签用,绝不下发)。 + """ + + app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读) + reward_code_id: str # 福利页激励视频位 + compare_feed_code_id: str # 比价信息流位 + coupon_feed_code_id: str # 领券信息流位 + reward_enabled: bool # 福利激励视频开关 + compare_ad_enabled: bool # 比价广告开关 + coupon_ad_enabled: bool # 领券广告开关 + + +class AppVersionOut(BaseModel): + """最新 App 版本信息(OTA 检查更新,不鉴权)。 + + 客户端用 latest_version_code(整数,单调递增)与本机 versionCode 比较,**不要**用 version_name 字符串比。 + latest_version_code=0 表示后台尚未配置任何版本 → 客户端一律视为"已是最新"。 + """ + + latest_version_code: int = 0 # 最新版 versionCode;0=未配置(无更新) + latest_version_name: str = "" # 展示用,如 "0.1.4" + apk_url: str = "" # 下载链接(发车产出的永久版本化链接) + update_note: str = "" # 更新说明,弹窗展示 + min_supported_version_code: int = 0 # 本机低于此版本=强制更新;0=不强更(全可选) + apk_size_bytes: int = 0 # 包大小(字节),展示"约 xMB" + apk_sha256: str = "" # APK 完整性校验(客户端下载后比对,防损坏/劫持) + + +class AppVersionWriteIn(BaseModel): + """内部写入最新版本(发布流程/应急,X-Internal-Secret 校验)。整组原子覆盖。""" + + latest_version_code: int = Field(gt=0) + latest_version_name: str = Field(min_length=1) + apk_url: str = Field(min_length=1) + update_note: str = "" + min_supported_version_code: int = 0 + apk_size_bytes: int = 0 + apk_sha256: str = "" diff --git a/app/schemas/store_mapping.py b/app/schemas/store_mapping.py new file mode 100644 index 0000000..c9a20d9 --- /dev/null +++ b/app/schemas/store_mapping.py @@ -0,0 +1,76 @@ +"""平台店铺映射内部上报的收发模型。 + +pricebot 在淘宝比价拿到 shopId 后 server→server POST 一行映射。所有字段可空(按比价 +角色稀疏填充),server 端只校验共享密钥 + 幂等(trace_id)落库。 +""" +from __future__ import annotations + +from pydantic import BaseModel + + +class StoreMappingIn(BaseModel): + """一次比价的跨平台店铺映射上报体(一行)。""" + + trace_id: str + business_type: str = "food" + source_platform: str | None = None + + # 跨平台身份(按角色稀疏填充) + id_taobao: str | None = None + name_taobao: str | None = None + id_meituan: str | None = None + name_meituan: str | None = None + id_jd: str | None = None + name_jd: str | None = None + + # 地理 + city: str | None = None + geohash: str | None = None + lng: float | None = None + lat: float | None = None + taobao_address: str | None = None + + # 溯源 + source_device_id: str | None = None + source_user_id: int | None = None + + # 淘宝原料(可复跳 / 可重解析 / 调试) + taobao_share_url: str | None = None + taobao_resolved_url: str | None = None + taobao_deeplink: str | None = None + + # 美团原料(poi_id_str 非稳定主键, 单列存; 见 model 注释) + meituan_poi_id_str: str | None = None + meituan_share_url: str | None = None + meituan_resolved_url: str | None = None + meituan_deeplink: str | None = None + + # 京东原料(venderId 单列存, storeId 进 id_jd; 见 model 注释) + jd_vender_id: str | None = None + jd_share_url: str | None = None + jd_resolved_url: str | None = None + jd_deeplink: str | None = None + + attrs: dict | None = None + + +class StoreMappingOut(BaseModel): + """上报结果。inserted=1 为新建该 trace 行,0 为合并进已存在行(填空,不覆盖); + row_id 为该 trace 对应行 id。""" + + inserted: int + row_id: int | None = None + + +class StoreMappingInvalidateIn(BaseModel): + """标记某平台某 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报)。""" + + platform: str # 目前只支持 "taobao" + shop_id: str # 失效的店铺 id(淘宝 = shopId = id_taobao) + + +class StoreMappingInvalidateOut(BaseModel): + """标记结果。affected = 本次新标记失效的行数(按 shopId 标记所有匹配行)。""" + + ok: bool + affected: int diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index 8977faf..2cb6027 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -109,8 +109,16 @@ class BindWechatResultOut(BaseModel): wechat_avatar_url: str | None = None +class UnbindWechatRequest(BaseModel): + # 有进行中提现单时,首次解绑会被拦(needs_confirm),用户确认后带 force=true 再调一次才真解绑。 + force: bool = Field(False, description="跳过「进行中提现」二次确认,强制解绑") + + class UnbindWechatResultOut(BaseModel): bound: bool = False + # 有进行中提现单且未 force → True:本次未解绑(bound 仍 True),客户端弹确认弹窗。 + needs_confirm: bool = False + message: str | None = None class WithdrawRequest(BaseModel): diff --git a/app/services/pricebot_llm_calls.py b/app/services/pricebot_llm_calls.py new file mode 100644 index 0000000..1c44bac --- /dev/null +++ b/app/services/pricebot_llm_calls.py @@ -0,0 +1,38 @@ +"""按 trace_id 同机拉 pricebot 的本次比价 LLM 调用明细(供 admin 比价记录 debug)。 + +pricebot 在 chat() 收口处把每次 LLM input/output 落盘,暴露内部接口 +GET /api/internal/llm_calls/{trace_id}(X-Internal-Secret 校验,与本服务 INTERNAL_API_SECRET 同值)。 +app-server 收到比价记录上报后同机拉一次,落 comparison_record.llm_calls。 + +best-effort:拉不到(pricebot 未部署该版本 / 旧 trace 无明细 / 网络)一律返 [],绝不阻断 +比价记录上报。多实例下用 pick_pricebot(trace_id) 选对实例(同 trace 落同进程,明细在 +该进程磁盘),与比价透传同一致性 hash 口径。 +""" +from __future__ import annotations + +import logging + +import httpx + +from app.core.config import settings +from app.core.pricebot_router import pick_pricebot + +logger = logging.getLogger("shagua.pricebot_llm") + + +def fetch_llm_calls(trace_id: str) -> list[dict]: + """返回该次比价的 LLM 调用明细列表(每条 {scene,model,input_messages,output,usage,latency_ms,error}); + 未配密钥 / 无 trace_id / 拉取失败 → []。""" + secret = settings.INTERNAL_API_SECRET + if not secret or not trace_id: + return [] + base = pick_pricebot(trace_id).rstrip("/") + url = f"{base}/api/internal/llm_calls/{trace_id}" + try: + resp = httpx.get(url, headers={"X-Internal-Secret": secret}, timeout=5.0) + if resp.status_code == 200: + return resp.json().get("calls", []) or [] + logger.warning("fetch_llm_calls trace=%s status=%s", trace_id, resp.status_code) + except Exception as e: # noqa: BLE001 — best-effort,任何异常都不该影响上报 + logger.warning("fetch_llm_calls trace=%s failed: %s", trace_id, e) + return [] diff --git a/data/media/taobao_landing.jpg b/data/media/taobao_landing.jpg new file mode 100644 index 0000000..740de7b Binary files /dev/null and b/data/media/taobao_landing.jpg differ diff --git a/docs/README.md b/docs/README.md index 7e9f966..f14588f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,16 +1,22 @@ # 后端文档库(docs/) -`shaguabijia-app-server` 的文档都在这里。各文档作用: +`shaguabijia-app-server` 的文档都在这里。结构:**根目录放总览,其余按领域/用途分目录**。 -| 文档 / 目录 | 作用 | +## 根目录 + +| 文档 | 作用 | |---|---| -| [后端技术实现.md](./后端技术实现.md) | **后端技术方案**:业务概览、分层架构与目录、登录链路、美团 CPS、领券透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"看这份。 | -| [api/](./api/) | **API 接口文档**(目录),采用"索引 + 一接口一文件",结构见下。想查"某个接口的协议"看这里。 | -| [integrations/](./integrations/) | **集成层实现文档**(目录):穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 | -| [数据库迁移.md](./数据库迁移.md) | **Alembic 迁移指南**:clone 后如何建表、日常升级、新增迁移、迁移文件命名约定。想"把数据库跑起来 / 改表结构"看这份。 | -| [待办与技术债.md](./待办与技术债.md) | **待办与技术债账本**:记"现在先简化、以后要补"的事 + 跨前后端技术债(P1 鉴权/用户绑定、引擎移植待办等),免遗忘。想知道"还欠什么、以后要补什么"看这份。 | -| [看广告赚金币上线清单.md](./看广告赚金币上线清单.md) | **看广告发奖上线 checklist**:跨前后端,记上线前必做(GroMore 回调配置、清理调试脚手架、端到端验收)。上线"看广告赚金币"前对照这份。 | -| [邀请功能-实现原理与本地测试.md](./邀请功能-实现原理与本地测试.md) | **好友邀请(invite-mvp)实现原理 + 跨前后端具体实现 + 本地内网测试方法**:剪贴板 deferred-deeplink 归因、双方各发 1 万金币、落地页 dl.html、起本地后端 + 编 debug 包 + 两台真机走全链路 + 上线前还差什么。要本地测 / 接手邀请功能看这份。 | +| [后端技术实现.md](./后端技术实现.md) | **后端技术方案总览**:业务概览、分层架构与目录、登录链路、美团 CPS、领券/比价透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"先看这份。 | +| README.md | 本文件:文档库索引/传送门。 | + +## 子目录 + +| 目录 | 作用 | +|---|---| +| [api/](./api/) | **API 接口文档**:"索引 + 一接口一文件"(组织方式见下)。想查"某个接口的协议"看这里。 | +| [database/](./database/) | **数据库文档**:每张表一个文件(表结构/字段/索引/约定) + 两份迁移指南——[数据库迁移.md](./database/数据库迁移.md)(Alembic 操作:clone 后建表/日常升级/新增迁移/多 head 排查) + [postgres-migration.md](./database/postgres-migration.md)(SQLite → PostgreSQL 切换步骤,配套 `scripts/init_postgres.py`)。想"把库跑起来 / 改表结构 / 查某张表"看这里。 | +| [integrations/](./integrations/) | **集成层实现文档**:穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 | +| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试)。 | ## api/ 目录是怎么组织的(传送门式) diff --git a/docs/api/README.md b/docs/api/README.md index 7ff8981..642afa1 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-07(金币数值体系一期:签到膨胀 + 信息流广告结算) +> 最后更新:2026-06-18(补录比价透传端点 `intent/precoupon/step`、`intent/step`、`trace/finalize` 与 `meituan/top-sales` 到总览;上一次功能更新 2026-06-07 金币数值体系一期) > 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。 --- @@ -23,9 +23,13 @@ | 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) | | 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) | | 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) | -| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend) ||| -| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) | -| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md) | +| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团) | +| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend;下按 Phase 流程列,均不鉴权) ||| +| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) | +| 12a | `POST /api/v1/intent/precoupon/step` | 无 | Phase 0 意图识别前先用券,仅美团源(透传,无单独文档) | +| 12b | `POST /api/v1/intent/step` | 无 | Phase 1 多帧意图识别,仅淘宝源,循环到 done(透传,无单独文档) | +| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md)(Phase 2 步进) | +| 13a | `POST /api/v1/trace/finalize` | 无 | 比价 trace 收尾上云,终止/未识别拿 trace_url(透传,无单独文档) | | **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) ||| | 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) | | 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) | @@ -63,6 +67,7 @@ | 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) | +| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) | | **用户资料**(前缀 `/api/v1/user`) ||| | 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | | 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) | @@ -83,30 +88,32 @@ | A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) | | A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin-user-status.md) | | A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin-user-coins.md) | -| A8 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) | -| A9 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) | -| A10 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) | -| A11 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) | -| A12 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) | -| A13 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) | -| A14 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) | -| A15 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) | -| 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) | -| A27 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | +| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin-user-cash.md) | +| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) | +| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) | +| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) | +| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) | +| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) | +| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) | +| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) | +| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) | +| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) | +| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) | +| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) | +| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) | +| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) | +| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) | +| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) | +| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | +| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | +| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | +| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | +| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | +| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) | > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 -> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。 +> `coupon/step` 及外卖比价的 `intent/recognize`、`intent/precoupon/step`、`intent/step`、`price/step`、`trace/finalize` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见 `app/api/v1/compare.py`)。 > 福利相关业务接口(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-ecpm-report.md b/docs/api/ad-ecpm-report.md index b29fefb..6359b00 100644 --- a/docs/api/ad-ecpm-report.md +++ b/docs/api/ad-ecpm-report.md @@ -7,11 +7,13 @@ | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| -| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 | -| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 | +| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `feed`(信息流) / `draw`(历史 Draw 信息流) 等 | +| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;激励视频与穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配。**信息流轮播每条展示用各自独立 id**(不复用比价会话 id,否则 `uq_ad_ecpm_record_session` 去重只留一条) | | `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 | -| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` | +| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle`(聚合后实际填充的子渠道) | | `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) | +| `app_env` | str\|null | 否 | **我们的**穿山甲应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) | +| `our_code_id` | str\|null | 否 | **我们后台配置的**代码位 ID(`AdConfig` 的 104xxx,**非** `slot_id` 的底层 rit) | `user_id` 不在 body 里——由 JWT 取(Bearer),防伪造。 @@ -23,7 +25,7 @@ | `ok` | bool | 落库即 `true` | ## 说明 -客户端在广告**展示后**(`onAdShow` 读 `getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。 +客户端在广告**展示后**(`onAdShow` 读 `getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。激励视频每次展示上报一条;**信息流轮播每条展示各上报一条**(每条独立 `ad_session_id`),作为「广告收益报表」展示条数/收益的数据源(见 [admin-ad-revenue-report](./admin-ad-revenue-report.md))。`app_env` + `our_code_id` 供报表按「我们的应用 / 我们配置的代码位」聚合,与 `adn`/`slot_id`(底层填充渠道/rit)是两组不同口径。 - 普通激励视频发奖会先用 S2S 回调自带 `ecpm`;若缺失,再按 `ad_session_id` 读取本接口上报的 eCPM;两边都没有则不发并记录异常。 - **best-effort**:客户端 fire-and-forget,但普通激励视频若 S2S 缺 eCPM,这条上报会成为发奖依据。 diff --git a/docs/api/ad-feed-reward.md b/docs/api/ad-feed-reward.md index 0510a0f..23516a7 100644 --- a/docs/api/ad-feed-reward.md +++ b/docs/api/ad-feed-reward.md @@ -1,6 +1,6 @@ # POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币 -点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账。 +点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。 ## 鉴权 @@ -12,24 +12,27 @@ |---|---|---:|---| | `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | | `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 | -| `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | -| `duration_seconds` | int | 是 | 实际展示/播放秒数 | -| `adn` | string\|null | 否 | 实际投放 ADN | -| `slot_id` | string\|null | 否 | 实际展示代码位 | +| `ecpm` | string | 是 | 本场信息流 eCPM 代表值(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | +| `duration_seconds` | int | 是 | **整场累计观看秒数**(轮播各条相加) | +| `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充的子渠道) | +| `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) | +| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) | +| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit);供广告收益报表按代码位聚合金币 | +| `aborted` | bool | 否 | 用户中途 ✕ 关闭广告(未走完比价):整场不发,仅记 `closed_early`。默认 `false` | ## 响应 | 字段 | 类型 | 说明 | |---|---|---| -| `granted` | bool | 本次是否入账;达上限时为 `false` | -| `status` | string | `granted` / `capped` | -| `coin` | int | 本次发放金币 | +| `granted` | bool | 本次是否入账;未发(任一非 granted 状态)时为 `false` | +| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途关闭) | +| `coin` | int | 本次发放金币;非 granted 为 0 | | `unit_count` | int | 按 10 秒折算出的奖励份数 | | `daily_limit` | int | 每日信息流展示次数上限,默认 500 | ## 计算口径 -- 奖励份数:`duration_seconds // 10`。 +- 奖励份数:`整场总时长 // 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`。 diff --git a/docs/api/ad-pangle-callback.md b/docs/api/ad-pangle-callback.md index d32e90f..3d8e13c 100644 --- a/docs/api/ad-pangle-callback.md +++ b/docs/api/ad-pangle-callback.md @@ -22,7 +22,7 @@ GroMore 以 GET 回调,关键参数: | `ecpm` | string | 本次广告 eCPM(同上,可用于收益分析) | | `sign` | string | 签名,见下 | -**验签**:`sign = SHA256("{m-key}:{trans_id}")` 十六进制(只签 `trans_id`,其余参数不参与)。算法细节、m-key 来源、为什么这样设计 → 见集成文档 [integrations/pangle](../integrations/pangle.md)。 +**验签**:`sign = SHA256("{m-key}:{trans_id}")` 十六进制(只签 `trans_id`,其余参数不参与)。多激励位共用同一回调 URL → 服务端把各位的 m-key 都配上,`verify_callback_sign_any` 逐个试、任一过即接受。算法细节、m-key 配置项(`PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD`)、为什么这样设计 → 见集成文档 [integrations/pangle](../integrations/pangle.md)。 ## 出参 响应 `200`,**响应体必须是 `{"is_verify": bool, "reason": int}`**(GroMore 规范)。 diff --git a/docs/api/ad-reward-noshow.md b/docs/api/ad-reward-noshow.md new file mode 100644 index 0000000..a36b69b --- /dev/null +++ b/docs/api/ad-reward-noshow.md @@ -0,0 +1,41 @@ +# POST /api/v1/ad/reward-noshow — 激励视频提前关闭/未发奖留痕 + +激励视频**展示了但用户提前关闭/跳过、未触发 S2S 发奖**时,客户端 best-effort 上报一条留痕记录, +让运营后台「广告数据」能呈现「有展示、没发金币」的原因。**不发金币**。 + +## 鉴权 + +需要 Bearer token。`user_id` 由 JWT 取,不在 body。 + +## 请求体 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `ad_session_id` | string | 是 | 本次展示会话 id(与 eCPM 上报、S2S `extra.ad_session_id` 一致),8-64 字符 | +| `watched_seconds` | int | 否 | 关闭前已观看秒数(仅留痕参考,**不入库**)。默认 0 | +| `ecpm` | string\|null | 否 | 本次展示 eCPM(穿山甲 getEcpm 原值,分/千次展示) | +| `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充子渠道) | +| `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) | +| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) | +| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit) | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `ok` | bool | 是否处理成功(落库或幂等命中) | +| `status` | string | `closed_early`(已留痕) / `granted`(该次其实已发奖,跳过未写) | + +## 说明 + +- 在 `onAdClose` 时若本次**未触发发奖**(无 `onRewardVerify`/`onRewardArrived`)调用。 +- **幂等**:写入 `ad_reward_record`,`trans_id = noreward:{ad_session_id}`(每次展示唯一),重试不重复。 +- **防与正常发奖重复**:若同一 `ad_session_id` 已有 `status='granted'` 记录(S2S 已发,说明用户其实看完了), + 则跳过不写,返回 `status='granted'`。 +- best-effort:客户端 fire-and-forget,失败只 log,不影响主流程。 +- 信息流的「用户中途关闭」走 [ad-feed-reward](./ad-feed-reward.md) 的 `aborted=true`(记 `closed_early`),不走本接口。 + +## 数据写入 + +- `ad_reward_record` 新增一行(`coin=0`、`status='closed_early'`、`reward_scene='reward_video'`)。 +- **不**写 `coin_account` / `coin_transaction`(不发币)。 diff --git a/docs/api/admin-ad-coin-audit.md b/docs/api/admin-ad-coin-audit.md index 8b69e71..2956994 100644 --- a/docs/api/admin-ad-coin-audit.md +++ b/docs/api/admin-ad-coin-audit.md @@ -25,16 +25,18 @@ eCPM元 = getEcpm分 ÷ 100 | `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 | | `user_id` | int | 全部 | 只看某用户;不传=所有用户 | | `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 | - | `limit` | int(1~500) | 100 | 返回明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) | + | `limit` | int(1~500) | 100 | **展示**明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) | + | `only_mismatch` | bool | false | 只展示不一致(✗)行;统计数仍按全量,不受影响 | - 出参 `200`:`AdCoinAuditOut` | 字段 | 类型 | 说明 | |---|---|---| | `date` | string | 审计日期 | | `formula` | object | 当前公式参数快照(见下) | - | `total` | int | 返回明细条数 | - | `mismatch_count` | int | 其中 `matched=false` 的条数;**=0 说明全部按公式发放** | - | `items` | `AdCoinAuditRow[]` | 明细(见下) | + | `total` | int | 该筛选下复算**总条数**(全量,不受 `limit`/`only_mismatch` 影响) | + | `mismatch_count` | int | **全量**不一致条数(截断前统计,可信);**=0 说明全部按公式发放** | + | `truncated` | bool | 展示集是否被 `limit` 截断(true=还有未返回的明细,请缩小范围或调大 `limit`) | + | `items` | `AdCoinAuditRow[]` | 展示明细(见下);`only_mismatch=true` 时只含 ✗ 行 | ### AdCoinFormulaOut(`formula`) | 字段 | 类型 | 说明 | diff --git a/docs/api/admin-ad-revenue-report.md b/docs/api/admin-ad-revenue-report.md new file mode 100644 index 0000000..bccfd8f --- /dev/null +++ b/docs/api/admin-ad-revenue-report.md @@ -0,0 +1,108 @@ +# Admin 广告收益报表 + +> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md) + +按 **用户 × 日期 × 广告类型 × 我们的应用 × 我们的代码位** 聚合,回答「每个用户某天、每类广告(激励视频 / 信息流 / 历史 Draw)分别**看了多少条**、**收益多少**、按现算法**发了多少金币**、广告来自**哪个应用的哪个代码位**」。**纯只读**,不发币、不改数据,也**不改发奖逻辑**。 + +相关表:[ad_ecpm_record](../database/ad_ecpm_record.md)、[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。 + +## 数据来源(三流合并,聚合键 = user × ad_type × app_env × our_code_id) + +| 指标 | 来源表 | 口径 | +|---|---|---| +| 展示条数 `impressions` | `ad_ecpm_record` | 每行 = 客户端一次广告展示。激励视频每次展示上报一条;**信息流轮播每条展示各上报一条**(每条独立 `ad_session_id`) | +| 收益 `revenue_yuan` | `ad_ecpm_record` | `Σ(eCPM元 ÷ 1000)`,即每条展示预估收益累加(eCPM 原值是分,÷100 转元;÷1000 是每千次→单次)。**预估口径,非结算;测试应用多为 0** | +| 应发/实发金币 `expected_coin`/`actual_coin` | `ad_reward_record`(reward_video)+ `ad_feed_reward_record`(feed) | **复用金币审计逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,不另写公式),按同维度求和;`matched = 应发==实发`。**只读复算,不改发奖** | +| 来源应用/代码位 `app_env`/`our_code_id` | 上述各表回填 | `prod`(傻瓜比价)/`test`(测试);代码位是**我们后台配的 104xxx**,非底层 rit | +| 底层渠道 `adns` | `ad_ecpm_record` | 实际填充的 ADN 子渠道集合(pangle/gdt/...),附加参考 | + +展示与金币来自不同表,做**并集**:有展示无金币(用户中途关、未达发奖)、有金币无展示(未上报 eCPM)各自成行。 + +## GET /admin/api/ad-revenue-report — 聚合报表 + +- 入参(均 query,可选): + | 参数 | 类型 | 默认 | 说明 | + |---|---|---|---| + | `date_from` | string | 今天 | 起始日 北京时间 `YYYY-MM-DD` | + | `date_to` | string | =`date_from` | 结束日 北京时间 `YYYY-MM-DD`,**闭区间**;单日时与 `date_from` 相同 | + | `user_id` | int | 全部 | 只看某用户;不传=所有用户 | + | `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 | + | `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** | + | `limit` | int(1~1000) | 500 | **展示**明细组数(截断;`total`/`total_*`/`daily` 按全量统计不受影响) | + + 约束:`date_to` 不早于 `date_from`、区间最长 **92 天**、日期须 `YYYY-MM-DD`,否则 `422`。 + +- 出参 `200`:`AdRevenueReportOut` + | 字段 | 类型 | 说明 | + |---|---|---| + | `date_from` / `date_to` | string | 报表起止日期(闭区间) | + | `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受 `limit` 影响) | + | `total` | int | 聚合组**总数**(全量,不受 `limit` 影响) | + | `truncated` | bool | 明细是否被 `limit` 截断 | + | `total_impressions` | int | 全量展示条数合计 | + | `total_revenue_yuan` | float | 全量收益合计(元) | + | `total_expected_coin` | int | 全量应发金币合计 | + | `total_actual_coin` | int | 全量实发金币合计 | + | `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) | + | `items` | `AdRevenueRow[]` | 聚合明细(按 日期→用户→类型→代码位 排序) | + +### AdRevenueDaily(`daily[]` — 按天趋势) +| 字段 | 类型 | 说明 | +|---|---|---| +| `date` | string | 北京时间 `YYYY-MM-DD` | +| `impressions` | int | 当天展示条数合计 | +| `revenue_yuan` | float | 当天预估收益合计(元) | +| `expected_coin` | int | 当天应发金币合计 | +| `actual_coin` | int | 当天实发金币合计 | + +### AdRevenueRow(`items[]`) +| 字段 | 类型 | 说明 | +|---|---|---| +| `report_date` | string | 该组所属日期 北京时间 `YYYY-MM-DD` | +| `user_id` | int | | +| `ad_type` | string | `reward_video` / `feed` / `draw` | +| `app_env` | string \| null | 我们的应用:`prod`(傻瓜比价)/`test`(测试);旧数据为空 | +| `our_code_id` | string \| null | 我们配置的代码位 104xxx;旧数据为空 | +| `hour` | int \| null | 北京时间小时 0–23(`granularity=hour` 时有值;按天为 null) | +| `impressions` | int | 展示条数 | +| `revenue_yuan` | float | 收益(元),预估口径 | +| `expected_coin` | int | 应发金币(公式复算,与金币审计同源) | +| `actual_coin` | int | 实发金币(实际入账) | +| `matched` | bool | 该组应发==实发(组内任一条不符则 false) | +| `adns` | string[] | 底层填充 ADN 子渠道集合 | +| `impression_records` | `AdRevenueImpression[]` | 该组**逐条展示明细**(前端展开下钻);只要有展示就非空 | +| `records` | `AdRevenueRecord[]` | 该组**逐条发奖复算明细**(前端展开下钻);纯展示无发奖的组为空 | + +### AdRevenueImpression(`items[].impression_records[]` — 展开「展示明细」) +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | ad_ecpm_record 主键 | +| `created_at` | datetime | | +| `ecpm` | string | 本次展示 eCPM 原始值(分/千次展示) | +| `revenue_yuan` | float | 本次展示预估收益(元)= eCPM元 ÷ 1000 | +| `adn` | string \| null | 实际填充 ADN 子渠道 | +| `slot_id` | string \| null | 底层 mediation rit(非我们配置的广告位 ID) | + +### AdRevenueRecord(`items[].records[]` — 展开「发奖明细」) +还原金币审计的逐条列,与发奖同一复算口径。 +| 字段 | 类型 | 说明 | +|---|---|---| +| `record_id` | int | 发奖记录主键 | +| `created_at` | datetime | | +| `status` | string | `granted` / `capped` / `ecpm_missing` | +| `ecpm` | string \| null | 本次采用的 eCPM 原始值 | +| `ecpm_factor` | float \| null | 因子1(eCPM 档);非 granted 为空 | +| `units` | int | 折算份数:激励视频恒 1;信息流 = 满 10 秒份数 | +| `lt_index_start` / `lt_index_end` | int \| null | 占用「账号累计第几份」的起止 | +| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2(LT)起止值 | +| `expected_coin` | int | 应发金币 | +| `actual_coin` | int | 实发金币 | +| `matched` | bool | 该条复算与实发是否一致 | + +## 说明与局限 + +- **展示 vs 发奖分离**:信息流轮播一会话可展示多条(都计入 `impressions`),但发奖仍按现规则(一会话发一次),`coin` 不因展示条数变化——这是有意设计(用户中途关只记展示不发奖)。 +- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。 +- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。 +- **收益是预估**:基于客户端上报的 eCPM,非穿山甲后台结算值;以后台报表为结算权威。 +- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。 diff --git a/docs/api/admin-feedbacks-list.md b/docs/api/admin-feedbacks-list.md index 2a3d002..af0460f 100644 --- a/docs/api/admin-feedbacks-list.md +++ b/docs/api/admin-feedbacks-list.md @@ -1,4 +1,4 @@ -# GET /admin/api/feedbacks — 反馈工单列表(游标分页) +# GET /admin/api/feedbacks — 反馈工单列表(offset 分页 + 筛选/排序) > 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin`) | [← 返回 API 索引](./README.md) @@ -7,8 +7,13 @@ |---|---|---|---|---| | `status` | string | ❌ | null | 反馈状态,精确匹配:`new`(待处理) / `handled`(已处理);传空/不传则不筛 | | `user_id` | int | ❌ | null | 按提交用户 id 精确筛 | +| `content` | string | ❌ | null | 反馈内容模糊匹配(ilike,≤100 字) | +| `created_from` | datetime | ❌ | null | 提交时间 ≥(ISO,统一按 UTC 比较) | +| `created_to` | datetime | ❌ | null | 提交时间 ≤(ISO,统一按 UTC 比较) | +| `sort_by` | string | ❌ | `id` | 排序列:`id` / `created_at` | +| `sort_order` | string | ❌ | `desc` | `asc` / `desc` | | `limit` | int | ❌ | 20 | 1–100 | -| `cursor` | int | ❌ | null | 上一页 next_cursor(按 feedback id 倒序,查 `id < cursor`) | +| `cursor` | int | ❌ | null | 上一页 next_cursor(**offset 分页**,cursor=offset) | ## 出参 响应 `200`:`{ items: FeedbackOut[], next_cursor: int|null }`(`next_cursor=null` 表示末页) @@ -26,9 +31,10 @@ ## 错误码 - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) -- `422` `limit` 超出 1–100 范围 / 字段类型不合法 +- `422` `limit` 超出 1–100 范围 / `sort_by`·`sort_order` 不在允许集 / 字段类型不合法 ## 说明 -- 游标分页约定:结果按 feedback `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。 -- `status` / `user_id` 均为精确匹配,可叠加。 +- **offset 分页**(同 [admin-users-list](./admin-users-list.md)):`cursor` 即 offset,传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。改用 offset 是为了在任意列排序下游标语义统一,代价是翻页期间数据变动可能错位一条(admin 低频可接受)。 +- 排序:`sort_by`(id/created_at)× `sort_order`(asc/desc),恒以 `id` 同向兜底次序。 +- `status` / `user_id` 精确匹配、`content` 模糊、`created_from`/`created_to` 时间范围,均可叠加。 - 关联表 [feedback](../database/feedback.md);截图为相对路径,经 `GET /media/feedback/` 静态读。 diff --git a/docs/api/admin-user-cash.md b/docs/api/admin-user-cash.md new file mode 100644 index 0000000..c9ea739 --- /dev/null +++ b/docs/api/admin-user-cash.md @@ -0,0 +1,34 @@ +# POST /admin/api/users/{user_id}/cash — 手动增减/设值现金(带审计) + +> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md) + +主要用于给无现金用户直接发钱、好让其测试提现链路。 + +## 入参 +- 路径:`user_id`(int) +- **application/json**: +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 | +| `amount_cents` | int | ✓ | `delta` 模式:现金变动(分,正=发放,负=扣减,不可为 0);`set` 模式:目标现金值(分,须 ≥ 0) | +| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) | + +## 出参 +响应 `200`:`OkResponse` = `{ "ok": true }` + +## 错误码 +- `400`(`delta`)`amount_cents == 0`(`detail: "amount_cents 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后现金为负(当前余额 N 分)"`) +- `400`(`set`)目标值为负(`detail: "目标现金值不能为负"`);或目标值等于当前余额(`detail: "当前现金已为 N 分,无需调整"`) +- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) +- `403` 角色不足(需 `finance` 或 `super_admin`) +- `404` 用户不存在(`detail: "用户不存在"`) +- `422` 缺 `amount_cents`/`reason`、`reason` 长度不在 1–128、`mode` 非 `delta`/`set`、字段类型不合法 / `user_id` 非整数 + +## 说明 +- 金额单位一律为**分**(`*_cents`);本接口只动现金余额,不涉及金币。 +- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。 +- 扣减保护:实际写入的 `delta < 0` 时若扣减后现金余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。 +- 现金变动写流水 [cash_transaction](../database/cash_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:`(截断至 128 字)。 +- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。 +- 现金变动 + 审计在同一事务原子提交(改钱必留痕)。 +- 关联用户表 [user](../database/user.md);现金账户 [coin_account](../database/coin_account.md);现金流水 [cash_transaction](../database/cash_transaction.md)。 diff --git a/docs/api/admin-user-coins.md b/docs/api/admin-user-coins.md index c04f5dc..c126afc 100644 --- a/docs/api/admin-user-coins.md +++ b/docs/api/admin-user-coins.md @@ -1,4 +1,4 @@ -# POST /admin/api/users/{user_id}/coins — 手动增减金币(带审计) +# POST /admin/api/users/{user_id}/coins — 手动增减/设值金币(带审计) > 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md) @@ -7,23 +7,26 @@ - **application/json**: | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| -| `amount` | int | ✓ | 金币变动(个数):正=增加,负=扣减;不可为 0 | +| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 | +| `amount` | int | ✓ | `delta` 模式:金币变动(正=增加,负=扣减,不可为 0);`set` 模式:目标金币值(须 ≥ 0) | | `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) | ## 出参 响应 `200`:`OkResponse` = `{ "ok": true }` ## 错误码 -- `400` `amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`) +- `400`(`delta`)`amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`) +- `400`(`set`)目标值为负(`detail: "目标金币值不能为负"`);或目标值等于当前余额(`detail: "当前金币已为 N,无需调整"`) - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) - `403` 角色不足(需 `finance` 或 `super_admin`) - `404` 用户不存在(`detail: "用户不存在"`) -- `422` 缺 `amount`/`reason`、`reason` 长度不在 1–128、字段类型不合法 / `user_id` 非整数 +- `422` 缺 `amount`/`reason`、`reason` 长度不在 1–128、`mode` 非 `delta`/`set`、字段类型不合法 / `user_id` 非整数 ## 说明 - 金币 = 个数(非现金);本接口只动金币余额,不涉及现金(`*_cents`)。 -- 扣减保护:`amount < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护)。 -- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 增加为 `admin_grant`、扣减为 `admin_deduct`,`remark = admin:`(截断至 128 字)。 -- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount, balance_after, reason}`,并记录操作 IP。 +- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。 +- 扣减保护:实际写入的 `delta < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。 +- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:`(截断至 128 字)。 +- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount(=实际差值), balance_after, reason}`;set 模式额外带 `{mode:"set", target, before}`。并记录操作 IP。 - 金币变动 + 审计在同一事务原子提交(改钱必留痕)。 - 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);金币流水 [coin_transaction](../database/coin_transaction.md)。 diff --git a/docs/api/admin-users-list.md b/docs/api/admin-users-list.md index 287e057..f4ec2ba 100644 --- a/docs/api/admin-users-list.md +++ b/docs/api/admin-users-list.md @@ -1,4 +1,4 @@ -# GET /admin/api/users — 用户列表(筛选+分页) +# GET /admin/api/users — 用户列表(筛选+排序+分页) > 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md) @@ -8,8 +8,13 @@ | `phone` | string | ❌ | null | 手机号**前缀**匹配(`phone LIKE '<值>%'`) | | `register_channel` | string | ❌ | null | 注册渠道,精确匹配 | | `status` | string | ❌ | null | 用户状态,精确匹配:`active` / `disabled` / `deleted` | +| `nickname` | string | ❌ | null | 昵称**模糊**匹配(`ILIKE '%<值>%'`) | +| `created_from` / `created_to` | datetime | ❌ | null | 注册时间范围(ISO 8601;按 UTC 比较) | +| `last_login_from` / `last_login_to` | datetime | ❌ | null | 最近登录时间范围(ISO 8601;按 UTC 比较) | +| `sort_by` | string | ❌ | `id` | 排序列:`id` / `created_at` / `last_login_at` | +| `sort_order` | string | ❌ | `desc` | `asc` / `desc` | | `limit` | int | ❌ | 20 | 1–100 | -| `cursor` | int | ❌ | null | 上一页 next_cursor(按 user id 倒序,查 `id < cursor`) | +| `cursor` | int | ❌ | null | 上一页 next_cursor(**offset 偏移量**) | ## 出参 响应 `200`:`{ items: AdminUserListItem[], next_cursor: int|null }`(`next_cursor=null` 表示末页) @@ -28,10 +33,11 @@ ## 错误码 - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) -- `422` `limit` 超出 1–100 范围 / 字段类型不合法 +- `422` `limit` 超出 1–100 范围 / `sort_by`·`sort_order` 不在白名单 / 日期格式不合法 / 字段类型不合法 ## 说明 -- 游标分页约定:结果按 user `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。 -- `phone` 为前缀匹配(`LIKE '<值>%'`),`register_channel` / `status` 为精确匹配;三者可叠加。 +- **分页为 offset 偏移式**(为支持任意列排序):`cursor` 传上一页返回的 `next_cursor`(实为 offset);`next_cursor=null` 即末页。代价:翻页期间数据变动可能错位一条,admin 低频场景可接受(同提现列表)。 +- 排序:`sort_by` 限 `id`/`created_at`/`last_login_at`,非法值回落 `id`;同值再按 `id` 同向兜底,次序稳定。 +- 筛选:`phone` 前缀、`nickname` 模糊(`ILIKE`)、`register_channel`/`status` 精确、`created_*`/`last_login_*` 时间范围;均可叠加。 - 关联用户表 [user](../database/user.md)。 - 历史明细(金币流水、提现、比价、反馈等)不在本列表,走各自带 `user_id` 过滤的分页接口。 diff --git a/docs/api/admin-wallet-cash-transactions.md b/docs/api/admin-wallet-cash-transactions.md index f5f5da5..e3942be 100644 --- a/docs/api/admin-wallet-cash-transactions.md +++ b/docs/api/admin-wallet-cash-transactions.md @@ -25,7 +25,7 @@ | `biz_type` | string | 业务类型 | | `ref_id` | string \| null | 关联业务 ID | | `remark` | string \| null | 备注 | -| `created_at` | datetime | 创建时间(ISO 8601 UTC) | +| `created_at` | datetime | 创建时间(北京 wall-clock naive,非 UTC;前端按字面显示,不做时区换算) | ## 错误码 - `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`) diff --git a/docs/api/admin-wallet-coin-transactions.md b/docs/api/admin-wallet-coin-transactions.md index e4ca061..fc40802 100644 --- a/docs/api/admin-wallet-coin-transactions.md +++ b/docs/api/admin-wallet-coin-transactions.md @@ -25,7 +25,7 @@ | `biz_type` | string | 业务类型 | | `ref_id` | string \| null | 关联业务 ID | | `remark` | string \| null | 备注 | -| `created_at` | datetime | 创建时间(ISO 8601 UTC) | +| `created_at` | datetime | 创建时间(北京 wall-clock naive,非 UTC;前端按字面显示,不做时区换算) | ## 错误码 - `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`) diff --git a/docs/api/compare-intent-recognize.md b/docs/api/compare-intent-recognize.md index 1b58626..51cb30e 100644 --- a/docs/api/compare-intent-recognize.md +++ b/docs/api/compare-intent-recognize.md @@ -18,7 +18,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。典型含 `result` 外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。 -⚠️ **MVP 阶段不鉴权**(同 `coupon/step`):`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../待办与技术债.md) P1。 +⚠️ **MVP 阶段不鉴权**(同 `coupon/step`):`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../guides/待办与技术债.md) P1。 **相关配置**: - `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`) diff --git a/docs/api/compare-record-report.md b/docs/api/compare-record-report.md index 162cf2b..21085a4 100644 --- a/docs/api/compare-record-report.md +++ b/docs/api/compare-record-report.md @@ -5,7 +5,7 @@ 比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。 > ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。 -> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../待办与技术债.md) P1)。 +> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../guides/待办与技术债.md) P1)。 ## 入参(JSON body) diff --git a/docs/api/coupon-step.md b/docs/api/coupon-step.md index 333aae5..809183d 100644 --- a/docs/api/coupon-step.md +++ b/docs/api/coupon-step.md @@ -17,7 +17,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。 是产品"一键领券"的接入点,**前端已接通**:首页「去领取」→ `CouponPromptDialog` 确认 → 权限检查 → 无障碍引擎 `PriceBotService.startCouponClaim()` → 循环调本接口,后端逐张券下发 launch/wait/done。 -⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer ` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../待办与技术债.md) P1。 +⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer ` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../guides/待办与技术债.md) P1。 **相关配置**: - `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`) diff --git a/docs/api/feedback.md b/docs/api/feedback.md index 8b65588..e59e157 100644 --- a/docs/api/feedback.md +++ b/docs/api/feedback.md @@ -6,9 +6,9 @@ **multipart/form-data**: | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| -| `content` | string | ✓ | 反馈正文,**1-2000 字**(strip 后) | -| `contact` | string | ✓ | 联系方式(微信/QQ/手机号),**1-128 字**,便于回访 | -| `images` | file[] | ✗ | 截图,**最多 4 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) | +| `content` | string | ✓ | 反馈正文,**1-200 字**(strip 后) | +| `contact` | string | ✗ | 联系方式(微信/QQ/手机号),**≤128 字**。原型改版后客户端已不再采集、不传该字段(后端默认空串);保留字段兼容旧端 | +| `images` | file[] | ✗ | 截图,**最多 6 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) | ## 出参 响应 `200`: @@ -29,9 +29,9 @@ > 不返回上传的 image URL——这是给运营后台看的,客户端通常不需要。 ## 错误码 -- `400` 内容为空 / 内容超 2000 字 / 联系方式为空 / 联系方式超 128 字 / 图片超 4 张 / 单图非法(空/过大/格式不对) +- `400` 内容为空 / 内容超 200 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对) - `401` 未带 token / token 无效或过期 / 用户被禁用 -- `422` 缺 `content` 或 `contact` 字段 +- `422` 缺 `content` 字段 ## 说明 - **反馈绑用户**:`feedback.user_id = current_user.id`,便于回访 diff --git a/docs/api/platform-savings-feed.md b/docs/api/platform-savings-feed.md index 321dc69..511e8c4 100644 --- a/docs/api/platform-savings-feed.md +++ b/docs/api/platform-savings-feed.md @@ -2,7 +2,11 @@ > 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无** | [← 返回 API 索引](./README.md) -客户端首页顶部「用户****xxx 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。 +客户端首页顶部「某用户 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。 + +**用户标识脱敏规则(全 feed 统一)**:① 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→`省***手`)、2 字「首+星」(阿强→`阿*`)、1 字「用户+该字」(喵→`用户喵`);② 没昵称 → 按 `user_id` **确定性合成一个假昵称再脱敏**(同用户恒定,避免无昵称用户清一色 `用户****`),少数露「用户」+ 8 星 + id 后 3 位(`用户********618`)。 + +> 合成假名走**本地语料组合生成**(无外部库):中文真名(姓池 ×名字字池 拼「姓+1~2字」) / 中文网络昵称 / 英文昵称 / 英文名(可带数字尾) 混播,中文为主英文为辅,组合空间上万。真实条按 `user_id` 播种 → 同一用户名字恒定、不同用户各异(刷新/翻页不变脸,同屏几乎不撞名);种子/兜底用运行时随机源出多样。当前真实用户大多没设昵称,故走合成。 ## 入参 | 参数 | 类型 | 说明 | @@ -15,15 +19,15 @@ | 字段 | 类型 | 说明 | |---|---|---| | `items` | list | 轮播条目数组(最多 `limit` 条) | -| `items[].masked_user` | string | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)混合风格 | +| `items[].masked_user` | string | 脱敏用户名:昵称/合成名脱敏(`省***手` / `王*` / `SaveK**g`)或匿名 `用户********618` | | `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` **确定性合成**手机尾号 / 中文昵称混合脱敏名(同用户恒定)。 +- 真实条:`comparison_record` 中 `status='success'` 且 `0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**JOIN `user` 表取昵称 → 仅 user 表内用户的记录入选**(孤儿/已删用户记录不计,设计如此;当前用户量少,缺口由种子补足),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);有昵称按真实昵称脱敏,无昵称按 `user_id` 播种**确定性合成**脱敏名(同用户恒定、刷新不变脸)。 - 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空或旧 `用户****xxx` 模板名则**随机合成混合风格名并避开同屏撞名**(自愈历史种子),金额在 `[min_cents, max_cents]` 取**长尾随机值**(小额居多、偶尔大额,固定金额则 min==max)。 - 兜底:真实 + 种子仍凑不满 `limit`(种子被全停用 / 太少)时,用**内置合成条补满**,保证轮播既不空也不稀疏。 - **`time` 为合成的「最近」时间**(从当前北京时间往前**随机抖动**递减,首条几十秒前,其余多数 1~5 分钟、偶尔扎堆或较长):社会证明轮播保证永远像刚发生且节奏自然不机械,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。 -- 因含随机(抽取 / 金额 / 合成名),**每次请求结果都不同**——这是轮播想要的鲜活感。 +- 因含随机(种子抽取 / 金额 / 展示时间 / 种子合成名),**每次请求结果都不同**——这是轮播想要的鲜活感;但**真实用户的脱敏名按 `user_id` 恒定**,同一用户跨请求不变脸(避免「同一人换张脸」露馅)。 - **性能**:首页人人都看、真实数据变化慢,真实记录原始行带 **~30s 进程内缓存**(展示层随机仍每次重算,只省 DB 往返;新记录最多晚 30s 进轮播)。查询走复合索引 `ix_comparison_status_created (status, created_at)`,避免随数据量增大全表扫。 - 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。 diff --git a/docs/api/tasks-claim.md b/docs/api/tasks-claim.md index 64b8a21..5ef1f2c 100644 --- a/docs/api/tasks-claim.md +++ b/docs/api/tasks-claim.md @@ -19,7 +19,9 @@ ## 错误码 - `404` 未知任务(`unknown task`) -- `409` 任务已领取(`task already claimed`) +- `409` 任务已领取(`task already claimed`)。一次性任务为已领过;可重复任务(`enable_notification`)为 + 并发/连点撞同一序号、或已「减半到底领满」不再可领。客户端遇 `409` 视为本次无发放(不再加币)。 ## 说明 发奖端 gate 由后端把控(如「打开消息提醒」须客户端确认权限已开后才调)。金币已计入余额。 +可重复任务每次领取金额逐次减半,按 `(user_id, biz_type, ref_id)` 唯一索引去重挡并发重复发放。 diff --git a/docs/api/tasks-list.md b/docs/api/tasks-list.md index 63cc73d..d826f1d 100644 --- a/docs/api/tasks-list.md +++ b/docs/api/tasks-list.md @@ -13,8 +13,13 @@ | 字段 | 类型 | 说明 | |---|---|---| | `task_key` | string | 任务标识,如 `enable_notification`(打开消息提醒) | -| `coin` | int | 完成可得金币 | -| `claimed` | bool | 是否已领取 | +| `coin` | int | 下一次完成可得金币(可重复任务为减半后的当前档) | +| `claimed` | bool | 是否已领取(可重复任务恒 `false`,仅「减半到底领满」后置 `true`) | ## 说明 -福利页一次性任务行(如「打开消息提醒」)的状态源。领取走 [tasks-claim](./tasks-claim.md)。 +福利页任务行的状态源。领取走 [tasks-claim](./tasks-claim.md)。 + +`enable_notification`(打开消息提醒)是**可重复领取**任务:每次开启通知发一次,金额逐次减半 +(750→375→188→…→1),`coin` 返回下一次的金额;客户端按「通知权限是否开启」决定展不展示 +(开着就隐藏)。`claimed` 平时恒 `false`,减半到底(领满,约 11 次)后置 `true`,客户端据此隐藏。 +其余为一次性任务,`claimed` 领过即 `true`。 diff --git a/docs/api/wallet-unbind-wechat.md b/docs/api/wallet-unbind-wechat.md index daba1da..01211dc 100644 --- a/docs/api/wallet-unbind-wechat.md +++ b/docs/api/wallet-unbind-wechat.md @@ -5,14 +5,24 @@ > 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)。 ## 入参 -无(用户由 token 确定)。 +请求体 `UnbindWechatRequest`(**可选**:旧客户端可不带 body,等价 `force=false`): + +| 字段 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `force` | bool | `false` | 跳过「有待审核提现」二次确认,强制解绑。首次解绑传 `false`;命中 `needs_confirm` 后用户确认,再带 `true` 调一次才真解绑 | ## 出参 响应 `200`:`UnbindWechatResultOut` | 字段 | 类型 | 说明 | |---|---|---| -| `bound` | bool | 固定 `false` | +| `bound` | bool | 解绑成功为 `false`;命中二次确认(本次未解绑)时为当前真实绑定态(通常 `true`) | +| `needs_confirm` | bool | `true` = 有待审核提现且未 `force`,**本次未解绑**,客户端应弹确认弹窗 | +| `message` | string \| null | `needs_confirm=true` 时的提示文案,直接展示给用户 | ## 说明 清空当前用户的 `wechat_openid` / `wechat_nickname` / `wechat_avatar_url`。幂等:未绑定时调用也返回 `bound=false`。解绑后该微信可被其他账号绑定。 + +**有待审核提现时的二次确认**:用户有 `reviewing`(待审核)状态的提现单且未带 `force=true` 时,**首次解绑被拦下**——不清 openid,返回 `bound`(真实绑定态)+ `needs_confirm=true` + `message`。客户端据此弹确认弹窗,用户确认后带 `force=true` 再调一次即放行解绑。 + +> 只拦 `reviewing`:这类单还没打款,用户确认解绑(`force=true`)时**立即退回现金余额**(写 `withdraw_refund` 流水、单子置终态),后台不再挂单、不必等管理员审核。`pending`(打款在途)单转账已发起、解绑影响不到、照常打款到微信,故**不拦**。 diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index 5e298d4..281d20f 100644 --- a/docs/database/OVERVIEW.md +++ b/docs/database/OVERVIEW.md @@ -2,7 +2,7 @@ > 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。 > 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。 -> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **23 张业务表** + `alembic_version`(框架的迁移版本指针)。 +> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **37 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 5 张)是两个独立子系统。 --- @@ -16,6 +16,16 @@ | 比价战绩里程碑(逐档领金币) | [`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 UI)比价 done 后客观价格沉淀 | [`price_observation`](./price_observation.md) | **pricebot server→server 内部上报**;平台/门店视角的到手价事实,匿名也记,与 `comparison_record`(用户视角)互补 | +| (无 App UI)跨平台门店身份映射 | [`store_mapping`](./store_mapping.md) | **pricebot 内部上报**;同店在淘宝/美团/京东的 id/名/deeplink,供下次比价 `lookup` 反查省掉现场搜店 | + +### 领券(每日领券联动 · 今日状态) +| App 位置 / 动作 | 表 | 说明 | +|---|---|---| +| 领券**过程**(看屏→领券) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;结果回 app-server 才落下面三张表 | +| 切外卖 App 时是否弹领券引导窗 | [`coupon_prompt_engagement`](./coupon_state.md) | 今天 engage 过(点领/点拒)就不再弹;判断维度 device_id | +| 首页「去领取」卡是否置灰 | [`coupon_daily_completion`](./coupon_state.md) | 今天跑完整轮(到 done)就置灰;判断维度 device_id | +| 每张券领取结果留痕 | [`coupon_claim_record`](./coupon_state.md) | 资产/画像/排查/CPS;当前**不参与**判断 | ### 钱包 / 福利(看广告赚钱闭环) | App 位置 / 动作 | 表 | 说明 | @@ -23,7 +33,7 @@ | 资产卡 / 钱包余额 | [`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 天循环发币;签到后看广告可膨胀一次 | +| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 | | 一次性任务(开消息提醒等) | [`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 秒累计一份奖励,完成后一次性入账 | @@ -38,6 +48,21 @@ | 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 | | 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 | +### 好友邀请(注册增长) +| App 位置 / 动作 | 表 | 说明 | +|---|---|---| +| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效,邀请人+被邀请人各发 1 万金币;`invitee_user_id` 唯一=幂等防重复发奖 | +| 落地页访问指纹(剪贴板归因兜底) | [`invite_fingerprint`](./invite_fingerprint.md) | 剪贴板没拿到码时,用 (ip+机型+屏幕) 7 天内反查邀请人 | + +### 美团 CPS 群发联盟(私域社群比价,运营后台驱动 · 群发选品→点击→对账漏斗) +| 后台/用户动作 | 表 | 说明 | +|---|---|---| +| 运营建可推广活动 | [`cps_activity`](./cps_activity.md) | 预存券/物料(美团 actId / 淘宝淘口令 / 京东链接) | +| 运营建微信推广群 | [`cps_group`](./cps_group.md) | 一群一行,`sid`=美团二级渠道追踪位 | +| 后台批量生成群发短链 | [`cps_link`](./cps_link.md) | 群×活动 → `/c/{code}`,运营复制到微信群 | +| 用户点短链(落地/复制口令) | [`cps_click`](./cps_click.md) | 记 visit/copy 事件,统计 PV/UV | +| 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 | + ### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权) | 后台模块 | 表 | 说明 | |---|---|---| @@ -76,6 +101,15 @@ | 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 | | 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 | | 提交反馈 `POST /feedback` | `feedback`(C) | | +| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) + `coin_account`(U×2) + `coin_transaction`(C `invite_inviter` + `invite_invitee`) | 同事务;`invitee_user_id` 唯一幂等,双方各发 1 万金币 | +| 落地页归因 `POST /invite/landing-track` | `invite_fingerprint`(C) | 剪贴板归因兜底线索,登录后用 (ip+机型+屏幕) 反查 | +| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, package, 北京日)` 幂等;best-effort | +| 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort | +| 领券跑完 `POST /api/v1/coupon/step`(action.command=done) | `coupon_daily_completion`(C/U) | `(device_id, 北京日)` 幂等;best-effort | +| 拒绝领券引导窗 `POST /api/v1/coupon/prompt/dismiss` | `coupon_prompt_engagement`(C/U `dismissed`) | 同上;客户端通知(透传链路看不到拒绝) | +| 重置今日弹窗 `POST /api/v1/coupon/prompt/reset`(开发) | `coupon_prompt_engagement`(**D** 今日条) | 删后今天又能弹 | + +> 领券三表写库**全 best-effort**:`/coupon/step` 里写失败只 `logger.warning`、不连累领券返回;**判断只看 `device_id`**,`user_id` 可空旁路(资产留痕)。 ### admin 端(管理员触发,均额外写一条 `admin_audit_log`) | 后台操作 | 写入 | 操作 | @@ -88,7 +122,19 @@ | 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 | | 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | | -> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U。 +> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U(领券 `/prompt/reset`、`/completed-today/reset` 是开发用删除,非业务流程)。 + +### CPS 群发联盟 & pricebot 内部上报(非 C 端用户、非 admin 常规管理触发) +| 触发 | 写入 | 操作 | +|---|---|---| +| 运营后台建活动 / 建群 | `cps_activity`(C/U) / `cps_group`(C/U) | `app/admin/routers/cps.py`;群含美团平台才分配 `sid` | +| 后台批量生成短链 `POST /admin/api/cps/referral-links` | `cps_link`(C) | 每 群×活动 一条;美团经 `sid` 转链拿 `target_url`(同群同活动重复生成产生多条) | +| 用户点群发短链 `GET /c/{code}` / `POST /c/{code}/copy` | `cps_click`(C `visit`/`copy`) | 公开端点不鉴权;`group_id`/`sid` 从 link 冗余进来免 join | +| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order` 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) | +| pricebot 比价 done 内部上报 `POST /internal/price-observation` | `price_observation`(C 批量) | `(trace_id,platform,scope)` 幂等;**不走 JWT、靠 `X-Internal-Secret`**(未配→503) | +| pricebot 比价 done 内部上报 `POST /internal/store-mapping` | `store_mapping`(C/U 填空合并) | `trace_id` 幂等;另有 `lookup` 反查 + `invalidate` deeplink 失效标记(淘宝/京东) | + +> CPS 5 表与本 App `user` **无关**(被推广群的用户在美团下单,不是本 App 注册用户);`price_observation`/`store_mapping` 的 `source_user_id` 是可空旁路(链路不鉴权,匿名也记)。 --- @@ -98,6 +144,8 @@ - **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`(可空:关联记录被删后仍留上报历史)。 +- **邀请两表** → `user.id`:`invite_relation.inviter_user_id`、`invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`。 +- **CPS 5 表 + 比价沉淀 2 表均无硬 FK**(全靠 `sid`/`trace_id` 语义对齐,见下)。 ### 语义 join key(无 FK 约束,靠业务字段对齐 —— 排障/对账必看) - **`coin_transaction.ref_id` 指向随 `biz_type` 变**: @@ -122,7 +170,10 @@ - **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。 - **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。 - **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。 +- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。 - **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。 +- **CPS 群发 5 表全靠 `sid` / id 语义串联(无硬 FK)**:`cps_link.group_id`→`cps_group.id`、`cps_link.activity_id`→`cps_activity.id`、`cps_click.link_id`→`cps_link.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid` ≈ `cps_group.sid` ≈ `cps_link.sid` ≈ `cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。 +- **比价沉淀两表(`price_observation` / `store_mapping`)**:`trace_id` 软指 pricebot work_logs(与 `comparison_record.trace_id` 同源但不互 join,各存各视角);`source_user_id` / `source_device_id` 软指用户/设备(可空,匿名也记)。`store_mapping.lookup` 靠**店名字符串精确相等** + geo 取最近,非 id 级 join。 ### ER 关系(文字版) ``` @@ -136,6 +187,13 @@ user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,d comparison_record ─1:N─ price_report (comparison_record_id, 可空) admin_user ─1:N─ admin_audit_log app_config (独立, 无外键, key 为主键) +coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record + (独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联) +user ─1:N─ invite_relation (inviter_user_id 硬 FK); invitee_user_id ─1:1─ user (唯一硬 FK) +user ─1:N─ invite_fingerprint (inviter_user_id 硬 FK) +cps_activity / cps_group ──语义(无FK)──▶ cps_link ─1:N─ cps_click + (CPS 5 表自成子系统; cps_order 经 sid 归群对账, 与本 App user 无关) +price_observation / store_mapping (独立, 无硬 FK; 维度=trace_id, source_user/device 仅软关联; pricebot 内部上报) ``` --- diff --git a/docs/database/README.md b/docs/database/README.md index c42b59d..20364ca 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -3,16 +3,13 @@ > 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。 > ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。 > 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。 - -> 最后更新:2026-06-10(新增 `onboarding_completion` 新手引导完成表 → 23 张业务表) -======= - +> 最后更新:2026-06-17(补全 9 张此前缺文档的表:CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping`;并全表 review 对齐 model——`signin_record` 改 7 天循环、`coupon_prompt_engagement` 加 `package` 列按 App 频控、`user` 补 `username`/`invite_code`/`debug_trace_enabled` 等;含 [OVERVIEW 总览](./OVERVIEW.md)) > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 --- -## 表总览(23 张业务表 + `alembic_version` 框架表) +## 表总览(37 张业务表 + `alembic_version` 框架表) ### 账号 / 反馈 | 表 | 用途 | 模型 | 文档 | @@ -21,6 +18,12 @@ | `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) | | `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) | +### 好友邀请(注册增长) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `invite_relation` | 邀请绑定关系(注册即生效,双方各发1万金币;`invitee_user_id` 唯一=幂等防重复发奖) | `models/invite.py` | [详情](./invite_relation.md) | +| `invite_fingerprint` | 剪贴板归因失败时的指纹兜底(落地页记 ip+屏幕+机型,登录后反查邀请人) | `models/invite_fingerprint.py` | [详情](./invite_fingerprint.md) | + ### 钱包 / 福利(看广告赚钱闭环) | 表 | 用途 | 模型 | 文档 | |---|---|---|---| @@ -29,7 +32,7 @@ | `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_record` | 签到记录(7 天循环) | `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) | @@ -45,11 +48,33 @@ | `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) | | `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) | +### 比价数据沉淀(pricebot server→server 内部上报,不走用户鉴权) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `price_observation` | 价格观测(平台/门店视角的到手价事实,匿名也记;`comparison_record` 的客观对照) | `models/price_observation.py` | [详情](./price_observation.md) | +| `store_mapping` | 跨平台「同一家店」id/名/deeplink 身份映射(淘宝/美团/京东,供 lookup 反查省现场搜店) | `models/store_mapping.py` | [详情](./store_mapping.md) | + +### 领券(每日领券联动 · 今日状态) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+package+日) | `models/coupon_state.py` | [详情](./coupon_state.md) | +| `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) | +| `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) | + ### 美团 CPS 券缓存 | 表 | 用途 | 模型 | 文档 | |---|---|---|---| | `meituan_coupon` | 美团 CPS 券本地缓存(智能推荐/销量榜从库出,定时 ETL 灌入) | `models/meituan_coupon.py` | [详情](./meituan_coupon.md) | +### 美团 CPS 群发联盟(私域社群比价 · 群发选品→点击→对账漏斗) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `cps_activity` | 可推广活动池(运营预存的券/物料:美团 actId / 淘宝淘口令 / 京东链接) | `models/cps_activity.py` | [详情](./cps_activity.md) | +| `cps_group` | 推广微信群(`sid`=美团二级渠道追踪位,订单按 sid 归群对账) | `models/cps_group.py` | [详情](./cps_group.md) | +| `cps_link` | 群发短链 `/c/{code}`(群×活动生成,302 跳/淘宝落地页) | `models/cps_link.py` | [详情](./cps_link.md) | +| `cps_click` | 短链点击事件流(visit/copy,PV/UV 漏斗源) | `models/cps_link.py` | [详情](./cps_click.md) | +| `cps_order` | 美团联盟 query_order 拉回的对账订单(按 sid 归群算佣金) | `models/cps_order.py` | [详情](./cps_order.md) | + ### 首页门面数据 | 表 | 用途 | 模型 | 文档 | |---|---|---|---| diff --git a/docs/database/ad_ecpm_record.md b/docs/database/ad_ecpm_record.md index 127636f..61def90 100644 --- a/docs/database/ad_ecpm_record.md +++ b/docs/database/ad_ecpm_record.md @@ -7,17 +7,19 @@ ## 用在哪 / 增删改查 - **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。 - **U / D**:无。 -- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助。当前无面向 C 端用户的读接口。 +- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助;广告收益报表 [admin-ad-revenue-report](../api/admin-ad-revenue-report.md) 的展示条数/收益数据源(按 `app_env`/`our_code_id` 聚合)。当前无面向 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 信息流);各类型各自上报,不强行统一代码位 | -| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 | -| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) | +| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `feed`(信息流)/ `draw`(历史 Draw 信息流);各类型各自上报,不强行统一代码位 | +| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;激励视频用于 S2S 缺 `ecpm` 时匹配发奖。**信息流轮播每条展示用各自独立 id**(不复用比价会话 id,否则 UNIQUE 去重只留一条) | +| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`;聚合后实际填充的子渠道) | | `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) | +| `app_env` | String(16) | nullable | **我们的**穿山甲应用环境:`prod`(傻瓜比价正式)/`test`(测试应用);旧数据 NULL。广告收益报表按它聚合「来源应用」 | +| `our_code_id` | String(64) | nullable | **我们后台配置的**代码位 ID(`AdConfig` 的 104xxx,**非** `slot_id` 底层 rit);旧数据 NULL | | `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(穿山甲 getEcpm 原值,单位**分/千次展示**);后端 ÷100 转元参与金币公式 | | `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | diff --git a/docs/database/ad_feed_reward_record.md b/docs/database/ad_feed_reward_record.md index 5ab9e94..f6edbfb 100644 --- a/docs/database/ad_feed_reward_record.md +++ b/docs/database/ad_feed_reward_record.md @@ -1,5 +1,7 @@ # ad_feed_reward_record — 信息流广告奖励记录 +> 模型 `app/models/ad_feed_reward.py` · 仓库 `app/repositories/ad_feed_reward.py` · 接口 `POST /api/v1/ad/feed-reward`(信息流广告结算) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + 点位 2 的奖励记录。每条记录对应客户端完成的一次信息流广告展示/播放事件。 ## 字段 @@ -11,13 +13,15 @@ | `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 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 | 实际展示/播放秒数 | +| `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` | +| `adn` | String(32) | nullable | 实际投放 ADN(聚合后实际填充子渠道) | +| `slot_id` | String(64) | nullable | 实际展示代码位(底层 mediation rit) | +| `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试),客户端 feed-reward 上报;旧数据 NULL。广告收益报表金币侧按它聚合 | +| `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx,客户端上报;旧数据 NULL | +| `coin` | Integer | NOT NULL | 实发金币,非 `granted` 时为 0 | +| `status` | String(16) | NOT NULL | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途 ✕ 关闭,全程未看完) | | `created_at` | DateTime | index, NOT NULL | 创建时间 | ## 约束 diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md index 380967c..dc6eaf9 100644 --- a/docs/database/ad_reward_record.md +++ b/docs/database/ad_reward_record.md @@ -5,7 +5,7 @@ 每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、签到膨胀等场景;`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`、不发币。否则按 eCPM 公式发币。 +- **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`、不发币。否则按 eCPM 公式发币。另:`POST /ad/reward-noshow`(`record_reward_noshow`,Bearer)在用户提前关/未发奖时记一行 `status='closed_early'`、`coin=0` 留痕(同 session 已 granted 则跳过)。 - **U / D**:无。 - **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。 @@ -13,13 +13,15 @@ | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等) | +| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等)。`closed_early` 留痕记录无 S2S 交易号,用合成键 `noreward:{ad_session_id}` | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) | | `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频;`signin_boost` 签到膨胀 | | `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` | | `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 | -| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/业务不满足时为 0 | -| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `not_signed`/`already_boosted`/`last_day` | +| `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 | +| `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx(同上回填) | +| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/`closed_early`/业务不满足时为 0 | +| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day`/ `unknown_scene`(回调 `reward_scene` 不在支持集合,只留痕不发) | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) | diff --git a/docs/database/app_config.md b/docs/database/app_config.md index 947831c..95e7aac 100644 --- a/docs/database/app_config.md +++ b/docs/database/app_config.md @@ -14,8 +14,8 @@ ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / 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) | +| `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` / `signin_boost_coin` / `withdraw_auto_reconcile_enabled` / `comparing_ad_enabled` | +| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled / comparing_ad_enabled) | | `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 | diff --git a/docs/database/cash_transaction.md b/docs/database/cash_transaction.md index d379fee..f782184 100644 --- a/docs/database/cash_transaction.md +++ b/docs/database/cash_transaction.md @@ -34,6 +34,7 @@ ## 索引与约束 - PK `id`;index `user_id`、`created_at`。 +- 部分唯一索引 `ux_cash_transaction_withdraw_refund_ref`(`ref_id`),条件 `biz_type = 'withdraw_refund' AND ref_id IS NOT NULL`:一笔提现单只能退款一次(按 `out_bill_no` 去重),挡重复退款。 ## 注意 - 退款流水 `remark` 是用户可见文案(区分"未成功自动退"vs"审核未通过退");技术原因记在 `withdraw_order.fail_reason`,不外露。 diff --git a/docs/database/coin_transaction.md b/docs/database/coin_transaction.md index e1289a1..f29958d 100644 --- a/docs/database/coin_transaction.md +++ b/docs/database/coin_transaction.md @@ -10,8 +10,8 @@ | 动作 / 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 /signin/boost` | `signin_boost` | + | 广告 `trans_id`(= `signin_boost_record.ad_ref_id`);无 ad_ref_id 时回退当天日期 ISO 串 | + | 领任务 `POST /tasks/claim` | `task_`(如 `task_enable_notification`) | + | 一次性任务=`user_task.task_key`;可重复任务(`enable_notification`)=带序号 `task_key:N` | | 普通激励视频 S2S 回调 `POST /ad/pangle-callback` | `reward_video`(历史兼容:`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`) | @@ -36,10 +36,11 @@ ## 关系 / Join Key - `user_id` → `user.id`(多对一)。 -- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `signin_boost`→`ad_reward_record.trans_id` / `task_`→`user_task.task_key` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 +- `ref_id` 是**软关联**(无 FK),目标随 `biz_type`:`signin`→签到日(`signin_record.signin_date` ISO) / `signin_boost`→`signin_boost_record.ad_ref_id`(无则当天日期) / `task_`→一次性任务=`user_task.task_key`、可重复任务=`task_key:N` / `reward_video`/`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`。 +- 部分唯一索引 `ux_coin_transaction_task_ref`(`user_id`, `biz_type`, `ref_id`),条件 `biz_type LIKE 'task%' AND ref_id IS NOT NULL`:可重复任务(如打开消息提醒)按 `ref_id` 序号(`task_key:N`)去重,挡并发/连点重复发放;仅 `task_*` 且 `ref_id` 非空生效,不影响 signin/exchange/withdraw 等其它 `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 a9d6c39..7079b6c 100644 --- a/docs/database/comparison_milestone_claim.md +++ b/docs/database/comparison_milestone_claim.md @@ -5,7 +5,7 @@ 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)。 +- **C(插入)**:`POST /compare/milestones/{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` 成功计数对照。 diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index ba47da1..b49cf8e 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -5,6 +5,7 @@ 每完成一次比价(外卖/电商/领券),客户端在 done 帧后用**带 JWT** 的通道上报一条。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。 > 与 `savings_record` 的区别:本表是「每一次**比价行为**的完整明细」(不省钱、甚至失败也记);`savings_record` 是「真正**下单成交**省了多少」。两表独立、互不喂数据。 +> 与 [`price_observation`](./price_observation.md) / `store_mapping` 的区别:本表是**用户视角**(登录后按 `user_id` 存「我的比价记录」);后两张是 server 侧无条件沉淀的**平台/门店视角客观事实**(价格事实 / 跨平台店铺身份映射),与本表 `trace_id` 同源但不互相 join,各存各的视角。 ## 用在哪 / 增删改查 - **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 最便宜),不信客户端自算。 @@ -19,6 +20,7 @@ | `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(关联调试落盘 + 幂等键) | +| `trace_url` | String(512) | nullable | 本次比价公网调试链接(`price.shaguabijia.com/traces/{dir}/`);dir 名含 pricebot 落盘时分秒,前端/server 拼不出必须存。查看接口按 `user.debug_trace_enabled`(或本机 agent 调试 `include_trace`)决定返不返回。旧记录 / 未开上云为 null | | `source_platform_id` / `_name` | String(32) | nullable | 源平台代号 / 中文名 | | `source_package` | String(128) | nullable | 源平台 Android 包名 | | `source_price_cents` | Integer | nullable | 源平台到手价(分) | @@ -32,7 +34,7 @@ | `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`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用) | +| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name,applied_coupons}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用),`applied_coupons`=`[{name,amount}]` 多券明细 | | `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 | | `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | @@ -46,7 +48,7 @@ - 被 `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`;复合 index `ix_comparison_status_created`(`status`, `created_at`)(按 `status='success'` 过滤 + 近期排序的聚合/轮播,避免随数据量退化为全表扫);UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。 ## 注意 - 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。结构化金额列存「分」,`comparison_results.price`/`coupon_saved` 原样存「元」。 diff --git a/docs/database/coupon_state.md b/docs/database/coupon_state.md new file mode 100644 index 0000000..c6559b8 --- /dev/null +++ b/docs/database/coupon_state.md @@ -0,0 +1,126 @@ +# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录) + +> 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事: + +- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, App 包名, 自然日)` 记「今天**这个 App** 是否对领券引导窗表达过**意向**」(弹出即记 `shown` / 点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天**该 App** engage 过就不再弹该 App。频控维度自 2026-06-14 起含 `package`,美团/淘宝/京东各自独立、互不压制。 +- **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。 +- **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。 + +三表共同口径: +- **判断维度是 `device_id`,不是 `user_id`**:券发到的是设备上登录的那个外卖账号,device 比 user 更贴近「哪个登录环境」,且 `device_id` 全链路现成、不依赖领券鉴权(领券 MVP 阶段 `/coupon/step` 不鉴权)。客户端 `getOrCreateDeviceId` 生成存 SP,**卸载重装会变 → 当新设备重新弹一次**(产品预期)。 +- **日期 = `Asia/Shanghai` 自然日**(`claim_date` / `engage_date` / `complete_date`,`repositories/coupon_state.today_cn()`)。每日可领的券(签到/天天红包)靠这天然每天一条。 +- **`user_id` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。 +- **`trace_id` 可空**:回指 pricebot work_logs,供排查(哪次任务领的)。 +- **engagement vs completion vs claim 的区别**:engagement = 用户**表达过意向**(点了领或拒,不管跑没跑完);completion = 这一轮**真跑到了 done**(整套流程走完);claim = **每张券一条**的结果留痕。 + +> 写库全部 **best-effort**:在 `/coupon/step` 里写库失败只 `logger.warning`、**绝不连累领券主流程/返回**(见 `app/api/v1/coupon.py`)。 + +--- + +## coupon_prompt_engagement — 弹窗频控(今日这个 App 是否已对引导窗表达意向) + +`(device_id, package, engage_date)` 唯一,一台设备、一个 App、一天一条;今天**这个 App** engage 过(弹/领/拒)就不再弹该 App。各 App 独立:美团弹过不压淘宝/京东。 + +### 用在哪 / 增删改查 +- **C / U(幂等 upsert)**:`mark_engagement`。三条触发: + - `POST /api/v1/coupon/prompt/shown`(引导窗弹出那刻上报)→ 记 `shown`(频控主判据:弹出即占用今天这个 App 的「一次」,领/拒/无视都算用掉); + - `POST /api/v1/coupon/step` 且 `step==0`(领券首帧=用户已发起领券)→ 记 `claim_started`; + - `POST /api/v1/coupon/prompt/dismiss`(用户点关闭引导窗;server 在透传链路看不到「拒绝」,必须客户端通知)→ 记 `dismissed`。 + - 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。`step` 帧的 `package` 取 step body 的 `package`,缺/空则退化为 `""` 占位(全局态)。 +- **D**:`POST /api/v1/coupon/prompt/reset`(`reset_today_engagement`)—— 删这台设备今天**所有 App** 的 engagement(不按 package 过滤:重置=从头测,清全部 App 最符预期),开发设置「重置今日领券弹窗状态」按钮调;删后今天又都能弹。 +- **R**:`GET /api/v1/coupon/prompt/should-show?device_id=…&package=…`(`has_engaged_today`)→ `should_show = not 今天该 App 已 engage`。客户端切外卖 App 前带 `package` 查(老客户端不带 → `""` 全局态)。 + +### 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `device_id` | String(64) | NOT NULL | 判断/聚合维度;客户端 `getOrCreateDeviceId`,重装会变 | +| `package` | String(64) | NOT NULL, server_default `""` | 触发弹窗的目标 App 包名(`com.sankuai.meituan` / `com.taobao.taobao` / `com.jingdong.app.mall`)。频控维度,各 App 独立。旧行(2026-06-14 改造前)无此值 → 迁移用占位 `""` 填 | +| `user_id` | Integer | index, 可空 | 登录态有就记(资产);不进唯一键、不阻塞判断 | +| `engage_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) | +| `engage_type` | String(16) | NOT NULL | `shown`(自动弹出即记)/ `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天这个 App 有没有这条」,type 不影响弹不弹** | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +### 索引与约束 +- PK `id`;index `user_id`;UNIQUE(`device_id`, `package`, `engage_date`) = `uq_coupon_engage_device_pkg_date`(一台设备、一个 App、一天一条)。 + - 历史:原为 UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`,2026-06-14 迁移 `coupon_engage_per_package` 加入 `package` 维度改成现状(修「任一 App 弹过就把整台设备当天标记、压住其余 App」的 bug)。 + +### 注意 +- `device_id` 重装会变 → 重装当新设备,今天重新弹一次(产品预期)。 +- 判断只看「今天这台设备这个 App 有没有这条」,不看 `engage_type`(弹/领/拒都算 engage 过、都不再弹该 App)。 + +--- + +## coupon_daily_completion — 首页置灰(今日是否已跑完整轮领券) + +`(device_id, complete_date)` 唯一,一台设备一天一条;今天跑完整轮(到 done 帧)就把首页「去领取」卡置灰。 + +### 用在哪 / 增删改查 +- **C / U(幂等 upsert)**:`mark_completed_today`,由 `POST /api/v1/coupon/step` 在 pricebot 返回 `action.command == "done"` 那帧调。pricebot 把中途单券 done 改写成 `wait+continue=true`,只有整套全跑完那帧才保留 `command=="done"`,故 **done 已等价「整轮完成」**。已有今天那条则补 `user_id`/`trace_id`,否则插入。 +- **D**:`POST /api/v1/coupon/completed-today/reset`(`reset_today_completion`)—— 删这台设备今天那条,首页「去领取」卡恢复可点。与 `/prompt/reset` 配套,开发设置「重置今日领券弹窗状态」一键把今日状态全清;MVP 不鉴权。 +- **R**:`GET /api/v1/coupon/completed-today?device_id=…`(`has_completed_today`)→ `completed`。客户端据此把首页「去领取」卡置灰、不可点。 + +### 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `device_id` | String(64) | NOT NULL | 判断维度,与 engagement/claim 一致;客户端两端都用 ANDROID_ID | +| `user_id` | Integer | index, 可空 | 登录态有就记(资产) | +| `complete_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) | +| `trace_id` | String(64) | index, 可空 | 哪次任务跑到 done,回指 pricebot work_logs / 排查 | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +### 索引与约束 +- PK `id`;index `user_id`、`trace_id`;UNIQUE(`device_id`, `complete_date`) = `uq_coupon_completion_device_date`(一台设备一天一条)。 + +### 注意 +- 口径(用户决策 2026-06-10 A 方案):**到 done 即算完成,不管单券成败**——失败/跳过常是无障碍/环境问题,重复点也补不回来。 +- 与 engagement 区别:engagement 是「表达过意向」(点了就记,不管跑没跑完);completion 是「真跑到了 done」。 + +--- + +## coupon_claim_record — 领券记录(每张券一天一条,资产沉淀层) + +`(device_id, coupon_id, claim_date)` 唯一,同设备同券同一天只一条;纯沉淀,**当前不参与判断**,留作以后按券去重 / CPS 归因 / 用户画像的数据源。 + +### 用在哪 / 增删改查 +- **C / U(幂等 upsert)**:`record_claims`,由 `POST /api/v1/coupon/step` 写入。一帧的券结果来自 pricebot 的 `last_coupon_result`(最后一张)+ `action.params.coupon_results`(全量)——**会重复带同一张券**,端点 `_extract_coupon_results` 先**按 `coupon_id` 去重**(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 `status`/`reason`/`claimed_count`/`extra`(以最后一次为准),否则插入。 +- **U / D**:无业务删除。 +- **R**:**当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)。 + +### 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `device_id` | String(64) | NOT NULL | 聚合维度;客户端 `getOrCreateDeviceId`,重装会变 | +| `user_id` | Integer | index, 可空 | 登录态有就记(资产/画像);不进唯一键 | +| `coupon_id` | String(64) | NOT NULL | 券标识(取自 pricebot 结果) | +| `claim_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`);每日可领的券靠它天然每天一条 | +| `status` | String(24) | NOT NULL | `success` / `already_claimed` / `failed` / `skipped`(原样取 pricebot coupon 结果) | +| `vendor` | String(48) | 可空 | 券提供方 | +| `coupon_name` | String(128) | 可空 | 取 pricebot `name` | +| `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count`) | +| `trace_id` | String(64) | index, 可空 | 哪次任务领的,回指 pricebot work_logs / 排查 | +| `reason` | String(255) | 可空 | failed / skipped 原因 | +| `extra` | JSON(PG JSONB) | 可空 | 杂项兜底:券的结构化信息(面额/入口/关键节点摘要等),免得加字段就迁移;当前直接存这帧 pricebot 单券结果 dict | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +### 索引与约束 +- PK `id`;index `user_id`、`trace_id`;UNIQUE(`device_id`, `coupon_id`, `claim_date`) = `uq_coupon_claim_device_coupon_date`;Index(`device_id`, `claim_date`) = `ix_coupon_claim_device_date`(按 device+日 取一天所有券)。 + +### 注意 +- **`extra` 别塞原始无障碍树**(几十 KB → 行膨胀);原始大树看 `trace_id` 指过去的 work_logs。 +- 同批去重很关键:`autoflush=False` 下同 `coupon_id` 两次 `add` 会撞唯一约束、`IntegrityError` 回滚整批(done/单券记录全丢),故端点 `_extract_coupon_results` + 仓库 `seen` 集合双重防御。 +- `extra` 是 `JSON().with_variant(JSONB(), "postgresql")`:PG 用 JSONB(可建 GIN 索引),SQLite 退化通用 JSON(同 `price_observation` / `comparison_record`)。 + +--- + +## 三表共性小结 +- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。 +- 唯一键都含 `device_id` + 某个北京自然日列(engagement 还含 `package`,按 App 频控);`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。 +- 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。 diff --git a/docs/database/cps_activity.md b/docs/database/cps_activity.md new file mode 100644 index 0000000..63d9188 --- /dev/null +++ b/docs/database/cps_activity.md @@ -0,0 +1,40 @@ +# cps_activity — CPS 可推广活动池(运营预存的券/物料) + +> 模型 `app/models/cps_activity.py` · 仓库 `app/admin/repositories/cps.py`(`create_activity` / `list_activities` / `get_activity` / `delete_activity` / `list_activity_images`) · 接口 admin `POST /admin/api/cps/activities`、`GET /admin/api/cps/activities`、`DELETE /admin/api/cps/activities/{id}`、`POST /admin/api/cps/upload-image`、`GET /admin/api/cps/activity-images` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +运营把常推的活动(券)预存成池,生成群发链接时从池里挑,省得每次记物料 ID。数据**全由运营在后台手填/上传**,无外部同步。在 CPS 群发漏斗里它是**最上游的"卖什么"**:活动(本表)× 群([`cps_group`](./cps_group.md))经"批量生成短链"组合出一条条 [`cps_link`](./cps_link.md)。一条活动可被多个群、多次复用生成 link(一对多,无外键)。 + +## 用在哪 / 增删改查 +- **C(插入)**:`POST /admin/api/cps/activities`(`create_activity`,需 `operator` 角色)。按 `platform` 分支校验:`meituan` 必须填 `act_id` 或 `product_view_sign` 之一;`taobao`/`jd` 必须填 `payload`,且 `taobao` 还必须有 `image_url`(落地页图)。淘宝图经 `POST /admin/api/cps/upload-image`(`media.save_cps_image`)上传得绝对 URL,或经 `GET /admin/api/cps/activity-images` 选已有。无幂等键,重复提交建多条。 +- **U(更新)**:**无更新端点**。改活动 = 删了重建。`status`(active/archived)列存在但目前没有切换它的路由(待确认是否前端规划)。 +- **R(读)**:`GET /admin/api/cps/activities`(`list_activities`,游标分页,可按 `platform`/`status` 过滤,登录即可);生成 link 时 `get_activity` 按 id 取;淘宝落地页渲染时 `cps_redirect` 用 `db.get(CpsActivity, link.activity_id)` 取 `image_url`;`list_activity_images` 取所有 distinct 非空 `image_url` 供新建复用。 +- **D(删除)**:`DELETE /admin/api/cps/activities/{id}`(`delete_activity`,`operator`)。**硬删,不级联** `cps_link`——已发出去的短链继续可用;淘宝落地页若取不到活动(被删),`cps_redirect` 用兜底默认图 `/media/taobao_landing.jpg`。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | 被 `cps_link.activity_id` 语义引用(非 FK) | +| `platform` | String(20) | NOT NULL, default `meituan` | `meituan` / `taobao` / `jd`。决定转链/落地方式,也决定下面哪些字段必填 | +| `name` | String(128) | NOT NULL | 活动名,运营自定,后台列表/审计展示 | +| `act_id` | String(64) | nullable | **美团**联盟「我要推广-活动推广」第一列的物料 ID(actId)。转链 `get_referral_link` 入参 | +| `product_view_sign` | String(128) | nullable | **美团**商品券 productViewSign,与 `act_id` 二选一转链(多数活动用 `act_id`) | +| `payload` | Text | nullable | **淘宝**=整段淘口令文本(落地页原样供复制);**京东**=推广链接(落地页 302 跳)。美团不用此列(用 `act_id`)。生成 link 时直接作 `target_url` | +| `image_url` | String(512) | nullable | **淘宝**落地页主视觉图,存绝对 URL(基于 `CPS_REDIRECT_BASE`,跨域 admin-web 可显示)。落地页按活动展示对应图 | +| `status` | String(20) | NOT NULL, default `active` | `active` / `archived`(目前无切换入口,主要靠删) | +| `remark` | String(256) | nullable | 运营备注 | +| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间。**无 `updated_at`**(本表无更新路径) | + +## 关系 / Join Key +- `id` ← `cps_link.activity_id`(一活动多 link,**语义关联,无 ForeignKey**;删活动不级联删 link)。 +- `id` ← `cps_redirect` 渲染淘宝落地页时按 `link.activity_id` 反查本表取 `image_url`。 +- 与 [`cps_group`](./cps_group.md) 无直接列关系:两者在"批量生成短链"(`generate_referral_links`)时按 **platform 是否在群范围内**组合,产物是 [`cps_link`](./cps_link.md)。 + +## 索引与约束 +- PK `id`。 +- **无唯一约束、无额外索引、无外键**(运营低频、量级小,按 id 主键访问即可)。 + +## 注意 +- **`platform` 是多平台演进的核心**:初版(commit `277f9b1`)只有美团,字段就是 `act_id` + `product_view_sign`;`3a40f61` 接淘宝/京东时**复用本表**,只加了 `payload`(异构标识塞一列);`d55f47f` 再加 `image_url` 把淘宝落地页图"做活"(此前是写死一张图)。所以三平台的"卖点"挤在同一张表,靠 `platform` 分流——这是省表的刻意取舍。 +- **美团二选一**:`act_id` 与 `product_view_sign` 对应 `get_referral_link` 的两种转链入参,router 层强制至少填一个。 +- **淘宝必须有图**:`taobao` 落地页是我们自渲染的 H5(`cps_redirect._taobao_landing_html`),没图页面就空,故建活动时强制。`d55f47f` 的迁移把存量淘宝活动的 `image_url` 回填成此前写死的 `/media/taobao_landing.jpg`,无缝衔接。 +- **`image_url` 存绝对 URL 的原因**:落地页域(`CPS_REDIRECT_BASE`)与 admin-web 跨域,存绝对值两边都能取(`media.to_abs_media_url`)。dev 未配 `CPS_REDIRECT_BASE` 时退化为相对路径。 diff --git a/docs/database/cps_click.md b/docs/database/cps_click.md new file mode 100644 index 0000000..18be454 --- /dev/null +++ b/docs/database/cps_click.md @@ -0,0 +1,37 @@ +# cps_click — CPS 短链点击事件(/c/{code} 每次访问/复制记一条) + +> 模型 `app/models/cps_link.py`(`CpsClick`,与 `CpsLink` 同文件) · 仓库 `app/repositories/cps_link.py`(`record_click` / `click_stats_by_group`) · 接口 用户 `GET /c/{code}`(记 `visit`)、`POST /c/{code}/copy`(记 `copy`,`app/api/v1/cps_redirect.py`);统计 admin `GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +群发短链 [`cps_link`](./cps_link.md) 每被点一次就在此记一条。在 CPS 群发漏斗里它是**"用户点了"这一步**:用户点 `/c/{code}` 进落地/被跳转记 `visit`,淘宝落地页点"复制口令"记 `copy`。据此统计点击 PV/UV(按群/活动/时段),配合 [`cps_order`](./cps_order.md) 的下单/佣金构成"点击→下单→佣金"漏斗。`group_id`/`sid` 在本表**冗余一份**(来自 link),统计直接按群聚合、不必 join `cps_link`。 + +## 用在哪 / 增删改查 +- **C(插入)**:`record_click`(`app/repositories/cps_link.py`),由 `cps_redirect` 两处触发——`GET /c/{code}`(`cps_landing`)记 `event_type=visit`;`POST /c/{code}/copy`(`cps_copy`,淘宝落地页点复制按钮 fetch 回调)记 `event_type=copy`。写入时把 link 的 `group_id`/`sid` 冗余进来,`ua` 截断到 500。**记点击失败被 `_record` 吞掉绝不阻断用户跳转/落地**(公网无鉴权,任何人点都要能跳)。无幂等——每次点都写新行(PV 即行数)。 +- **U / D**:**无**。点击是只追加的事件流。 +- **R(读)**:`click_stats_by_group`(`GET /admin/api/cps/stats` 经 `group_stats` 调用)按 `group_id` 聚合 → `{pv, uv, copy_pv, copy_uv}`;UV 按 `(ip, ua)` 近似去重,`visit` 计 pv/uv、`copy` 计 copy_pv/copy_uv。聚合在 Python 侧做(量级小、跨 PG/SQLite 无方言坑)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `link_id` | Integer | index, NOT NULL | 来源短链 = `cps_link.id`(语义关联,非 FK) | +| `group_id` | Integer | index, NOT NULL | **冗余**自 link.group_id;按群聚合点击快,免 join cps_link | +| `sid` | String(64) | index, **nullable** | **冗余**自 link.sid;美团有、淘宝/京东无 | +| `event_type` | String(16) | NOT NULL, default/server_default `visit` | `visit`(进落地页/被 302 跳) / `copy`(淘宝落地页点了"复制口令") | +| `ip` | String(64) | nullable | 客户端 IP(取 `X-Forwarded-For` 首段,否则 `request.client.host`),UV 去重键之一 | +| `ua` | String(512) | nullable | User-Agent,入库截断到 500;UV 去重键之一 | +| `clicked_at` | DateTime(tz) | server_default now(), index, NOT NULL | 点击时间,统计按时间窗过滤 | + +## 关系 / Join Key +- `link_id` → `cps_link.id`(多对一,**语义关联,无 ForeignKey**)。 +- `group_id` → `cps_group.id`、`sid` ≈ `cps_group.sid`——均**冗余字段**,直接按群聚合用,非 FK。 +- 与 [`cps_order`](./cps_order.md) 无直接关联:点击与订单在 `group_stats` 里**各按 `group_id`/`sid` 聚合后并到同一群行**,共同组成漏斗,但两表之间无 join key(无法把某次点击对到某笔订单——CPS 联盟不回传点击 id)。 + +## 索引与约束 +- PK `id`;index `link_id`、`group_id`、`sid`、`clicked_at`。 +- **无唯一约束、无外键**(纯事件流,PV 即行数,允许重复)。 + +## 注意 +- **`event_type` 是接淘宝时新增的**:初版(`277f9b1`)只有 visit(点了就 302 跳美团,记一条即可);`3a40f61` 接淘宝后,淘宝落地页是 H5、用户要点按钮"复制口令"才算转化,故迁移 `cps_v2_platforms` 加 `event_type`(server_default `visit` 回填存量),`copy` 单独统计成 `copy_pv/copy_uv`。同迁移把 `sid` 改 nullable(淘宝/京东点击无 sid)。 +- **`group_id`/`sid` 冗余的理由**:统计是高频读路径,直接 `WHERE group_id=?` 聚合,省掉 `cps_click → cps_link` 的 join;代价是写入时多带两列(`record_click` 从 link 拷)。这与 model docstring 明说的"统计直接按群聚合、不必 join cps_link"一致。 +- **UV 是近似值**:按 `(ip, ua)` 去重,无 Cookie/设备指纹;同一人换网络/浏览器会重复计 UV。够运营看趋势,不作精确口径。 +- **记点击绝不阻断**:`_record` 用 `try/except: pass` 包住,落地/跳转优先级高于统计——丢几条点击可接受,挡住用户领券不可接受。 diff --git a/docs/database/cps_group.md b/docs/database/cps_group.md new file mode 100644 index 0000000..3b741fc --- /dev/null +++ b/docs/database/cps_group.md @@ -0,0 +1,38 @@ +# cps_group — CPS 推广群(一个微信群一条,sid 是美团渠道追踪位) + +> 模型 `app/models/cps_group.py` · 仓库 `app/admin/repositories/cps.py`(`create_group` / `update_group` / `delete_group` / `get_group` / `get_group_by_sid` / `list_groups`、统计 `group_stats`) · 接口 admin `POST/GET/PATCH/DELETE /admin/api/cps/groups`、统计 `GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +每个发券的微信群一条记录。核心字段是 `sid`——**美团联盟二级渠道追踪位**:发链接时塞进推广链接(`get_referral_link`),订单回来按 `sid` 归到这个群对账(`query_order` → [`cps_order`](./cps_order.md))。在 CPS 群发漏斗里它是**"发给谁/在哪个渠道"**:群(本表)× 活动([`cps_activity`](./cps_activity.md))组合生成 [`cps_link`](./cps_link.md);点击([`cps_click`](./cps_click.md))与订单都靠 `sid`/`group_id` 归到本群,统计接口 `group_stats` 即以群为行做"点击→下单→佣金"漏斗。 + +## 用在哪 / 增删改查 +- **C(插入)**:`POST /admin/api/cps/groups`(`create_group`,`operator`)。`platforms` 至少选一个,非法平台 400。**`sid` 仅当 `platforms` 含 `meituan` 时才有**:运营填了就用(校验全局唯一,重复 409),没填则先占位 `tmp`、flush 拿到 id 后回填成 `g`;纯淘宝/京东群 `sid` 恒 `None`(那俩平台不支持 sid)。无业务幂等键(同名可重复建),唯一性只在 `sid` 上。 +- **U(更新)**:`PATCH /admin/api/cps/groups/{id}`(`update_group`,`operator`)。可改 `name`/`platforms`/`member_count`/`status`/`remark`。**`sid` 不可改**(改了会断开历史订单的归群)。 +- **R(读)**:`GET /admin/api/cps/groups`(`list_groups`,游标分页,`keyword` 模糊匹配 name 或 sid、`status` 过滤);生成 link 时 `get_group` 取群拿 `sid`;统计 `group_stats` 遍历所有群、按 `sid` 关联订单、按 `group_id` 关联点击。 +- **D(删除)**:`DELETE /admin/api/cps/groups/{id}`(`delete_group`,`operator`)。**硬删,不级联**已生成的 [`cps_link`](./cps_link.md)(已发链接继续可用);该群历史 [`cps_click`](./cps_click.md)/[`cps_order`](./cps_order.md) 按 `sid`/`group_id` 仍在库,删群后这些 sid 在统计里降级为"未归群"行(`group_id=None`)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | 被 `cps_link.group_id`、`cps_click.group_id` 语义引用(非 FK) | +| `sid` | String(64) | **UNIQUE, index, nullable** | 美团渠道追踪位,仅字母+数字 ≤64(美团限制,schema 正则 `^[A-Za-z0-9]+$`)。**仅含 meituan 的群有**;留空自动 `g`;纯淘宝/京东群为 `None`。订单/点击按它归群 | +| `name` | String(128) | NOT NULL | 群名,运营自定 | +| `platforms` | JSON / JSONB | NOT NULL, default `[]` | 该群发哪些平台(多选):`["meituan","taobao","jd"]`。含 `meituan` 才需要 `sid`。生成 link 时校验"活动平台必须在群范围内" | +| `member_count` | Integer | nullable | 群人数,运营填,作转化率分母(可空) | +| `status` | String(20) | NOT NULL, default `active` | `active` / `archived`。统计 `group_stats` 只列 `active` 群 | +| `remark` | String(256) | nullable | 运营备注 | +| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间(无 `updated_at`) | + +## 关系 / Join Key +- `id` ← `cps_link.group_id`、`cps_click.group_id`(一群多 link/多点击,**语义关联,无 ForeignKey**)。 +- `sid` ← `cps_order.sid`(订单按 sid 归群对账,**语义关联,非 FK**);`sid` ← `cps_link.sid` / `cps_click.sid`(生成 link 时把群 sid 冗余进 link/click)。 +- 与 [`cps_activity`](./cps_activity.md) 无直接列关系,经"批量生成短链"按平台组合。 + +## 索引与约束 +- PK `id`;**UNIQUE+index `sid`**(`uq_cps_group_sid` + `ix_cps_group_sid`)。 +- **无外键**。 + +## 注意 +- **`sid` 从"必填"放宽到"可空"是多平台演进的关键改动**:初版(`277f9b1`)`sid` NOT NULL + UNIQUE(每个美团群必有渠道位);`3a40f61` 接淘宝/京东后,这俩平台**没有 sid 概念**,迁移 `cps_v2_platforms` 把 `sid` 改 nullable,并加 `platforms` 列(存量群 server_default 回填 `["meituan"]`)。理解本表必须记住:**sid 只对美团有意义**,对账/归群全链路只走美团群。 +- **`g` 自动 sid**:运营不填 sid 时服务端生成 `g<群id>`,既保证唯一又人类可读。先写 `tmp` 占位是因为建群事务里 id 还没生成(flush 前拿不到),拿到 id 后二次 flush 回填。 +- **删群不级联**是刻意的:已发到微信群里的短链不能因为后台删群就失效;历史订单/点击也要保留用于对账。代价是删群后其 `sid` 的订单在 `group_stats` 里变成"未归群"独立行(如历史的 `wonderableai`,见 [`cps_order`](./cps_order.md) 注意)。 +- **`platforms` 用 JSON 列**:遵循全库约定 `JSON().with_variant(JSONB(), "postgresql")`——PG 上 JSONB,SQLite 退化通用 JSON。 diff --git a/docs/database/cps_link.md b/docs/database/cps_link.md new file mode 100644 index 0000000..ab28451 --- /dev/null +++ b/docs/database/cps_link.md @@ -0,0 +1,38 @@ +# cps_link — CPS 群发短链(发群用的 /c/{code},外套一层我们的短链) + +> 模型 `app/models/cps_link.py`(`CpsLink`) · 仓库 `app/repositories/cps_link.py`(`create_link` / `get_by_code`) · 接口 admin 生成 `POST /admin/api/cps/referral-links`、用户落地 `GET /c/{code}`(`app/api/v1/cps_redirect.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +群发券链接外套一层我们自己的短链 `/c/{code}`,目的是**统计点击 + 控制落地**。在 CPS 群发漏斗里它是**"发出去的那条链接"**:由 群([`cps_group`](./cps_group.md))× 活动([`cps_activity`](./cps_activity.md))在后台"批量生成短链"产出,运营把 `/c/{code}` 复制到微信群。用户一点 → 记一条 [`cps_click`](./cps_click.md) → 按平台 302 跳 `target_url`(美团短链/京东链接)或返回淘宝落地页。`cps_click` 与 `cps_link` 同文件(`models/cps_link.py`),详见 [`cps_click.md`](./cps_click.md)。 + +## 用在哪 / 增删改查 +- **C(插入)**:`POST /admin/api/cps/referral-links`(`generate_referral_links`,`operator`)批量生成——按 `group_id` + 一组 `activity_ids`,对每个活动调 `_gen_one_link`:美团走 `meituan.get_referral_link(act_id/product_view_sign, sid=群.sid)` 拿短链作 `target_url`(无群 sid 则 400);淘宝/京东直接用活动 `payload` 作 `target_url`。落库走 `create_link`,短码 `code` 由 `_gen_code` 生成(去易混字符,冲突重试 5 次仍冲突用 12 位兜底)。`code` 全局唯一即天然幂等键,但**每次调用都新建 link**(同群同活动重复生成会产生多条不同 code)。 +- **U(更新)**:**无**。link 一旦生成不改(改了等于换链接)。 +- **R(读)**:`GET /c/{code}`(`cps_landing`)落地时 `get_by_code` 取 link → 决定跳转/落地;`POST /c/{code}/copy` 同样按 code 取。后台无 link 列表端点(运营只看生成结果与按群统计)。 +- **D(删除)**:**无删除路径**。删群/删活动都不级联删 link(已发链接保持可用)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | 被 `cps_click.link_id` 语义引用(非 FK) | +| `code` | String(16) | **UNIQUE, index, NOT NULL** | 短码,发群链接 `/c/{code}` 的 code。字母表去掉 `0/O/1/l/I`(肉眼复制不易错),默认 8 位 | +| `group_id` | Integer | index, NOT NULL | 归属群 = `cps_group.id`(语义关联,非 FK) | +| `activity_id` | Integer | index, NOT NULL | 对应活动 = `cps_activity.id`(语义关联,非 FK);淘宝落地页反查活动取图 | +| `sid` | String(64) | index, **nullable** | 渠道追踪位,**仅美团 link 有**(= 群 sid);淘宝/京东无 sid。生成时从群冗余进来 | +| `platform` | String(20) | NOT NULL, default `meituan` | `meituan` / `taobao` / `jd`。决定 `cps_landing` 是 302 跳还是渲染淘宝落地页 | +| `target_url` | String(2048) | NOT NULL | 跳转/落地目标:美团短链 / 京东链接(302 跳) / 淘宝整段淘口令文本(H5 落地页复制) | +| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 生成时间(无 `updated_at`) | + +## 关系 / Join Key +- `group_id` → `cps_group.id`;`activity_id` → `cps_activity.id`;`sid` ≈ `cps_group.sid`——**全是语义关联,无 ForeignKey**(删群/删活动都不报错、不级联)。 +- `id` ← `cps_click.link_id`(一 link 多点击);记点击时 `record_click` 把 link 的 `group_id`/`sid` 冗余进 [`cps_click`](./cps_click.md)。 +- `target_url` 间接关联 [`cps_order`](./cps_order.md):美团 `target_url` 携带群 sid,用户经它下单后订单回来的 `sid` 即此群 sid——但 link 与 order **没有直接列关联**,只经 `sid` 在群维度汇合。 + +## 索引与约束 +- PK `id`;**UNIQUE+index `code`**(`uq_cps_link_code` + `ix_cps_link_code`);index `group_id`、`activity_id`、`sid`。 +- **无外键**。 + +## 注意 +- **`target_url` 从 1024 加长到 2048**:初版(`277f9b1`)只装美团短链(够短),`3a40f61` 接淘宝后要装**整段淘口令文本**(较长),迁移 `cps_v2_platforms` 把 `target_url` 放宽到 2048,同时 `sid` 改 nullable(淘宝/京东 link 无 sid)。 +- **`sid` 冗余进 link 又冗余进 click**:统计时按群聚合点击不必 `join cps_link`(见 `click_stats_by_group` 直接读 `cps_click.group_id`);`sid` 留在 link 上则是为了生成时一次性带齐,跳转无需再查群。 +- **短码去易混字符**:`_CODE_ALPHABET` 排除 `0/O/1/l/I`,因为短链会被运营/用户肉眼复制粘贴,降低抄错率;碰撞重试 5 次后降级 12 位,概率近 0。 +- **三平台落地分叉**(`cps_redirect.cps_landing`):美团/京东 → 302 跳 `target_url`;淘宝 → 不能 302(淘口令不是 URL),改返回自渲染 H5 落地页(图来自活动 `image_url`,按钮复制 `target_url` 淘口令)。code 不存在时兜底跳 `https://www.meituan.com/`。 diff --git a/docs/database/cps_order.md b/docs/database/cps_order.md new file mode 100644 index 0000000..ad022a2 --- /dev/null +++ b/docs/database/cps_order.md @@ -0,0 +1,51 @@ +# cps_order — CPS 对账订单(美团联盟 query_order 拉回的订单明细) + +> 模型 `app/models/cps_order.py` · 仓库 `app/admin/repositories/cps.py`(`reconcile_orders` / `_map_order_fields` / `list_orders`、统计 `group_stats`) · 接口 admin `POST /admin/api/cps/orders/reconcile`、`GET /admin/api/cps/orders`、`GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +从美团联盟 `query_order` 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,美团把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**仅美团有此表**——淘宝/京东无对账 API,统计里对账字段显示 `-`。 + +## 用在哪 / 增删改查 +- **C/U(upsert)**:`POST /admin/api/cps/orders/reconcile`(`reconcile_orders`,需 `finance` 角色)。调 `meituan.query_order(sid?, start_time, end_time, page, limit=100)` 分页拉单(`max_pages=200` 防死循环),每条经 `_map_order_fields` 转字段,按 `order_id` **upsert**:不存在则 insert,存在则逐字段覆盖(订单状态会随时间变 付款→完成→结算/退款,重复拉则更新)。返回 `{fetched, inserted, updated, pages}`。`order_id` 全局唯一即幂等键。 +- **R(读)**:`GET /admin/api/cps/orders`(`list_orders`,游标分页,可按 `sid`/`mt_status` 过滤);统计 `group_stats` 拉全部订单按 `sid` 分桶,算 `order_count`/`gmv`/`est_commission`/`settled_commission` 等。 +- **D**:**无**。 + +## 字段 +> 字段对齐 `query_order` 实测返回:金额(payPrice/profit)是「元」字符串 → 入库 `_yuan_to_cents` 转「分」(全站口径);时间(payTime/updateTime)是秒级时间戳 → `_ts_to_dt` 转 tz-aware UTC。 + +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join / 源字段) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 美团订单号(加密串),`orderId`。upsert 幂等键 | +| `sid` | String(64) | index, **nullable** | 渠道追踪位 = 群 `sid`,源 `sid`。**按它归群聚合**;历史无 sid 订单为空 | +| `act_id` | String(64) | index, nullable | 活动物料 ID,源 `actId`(转字符串) | +| `biz_line` | Integer | nullable | 业务线,源 `businessLine`。`1=外卖` | +| `trade_type` | Integer | nullable | 源 `tradeType`。`1=cps 2=cpa` | +| `pay_price_cents` | Integer | nullable | 付款金额(分),源 `payPrice`(元)。统计算 GMV | +| `commission_cents` | Integer | nullable | **预估佣金**(分),源 `profit`(元)。统计算预估/已结算佣金 | +| `commission_rate` | String(16) | nullable | 佣金率,源 `commissionRate`。`"300"=3%`、`"10"=0.1%`(原样字符串,前端解释) | +| `refund_price_cents` | Integer | nullable | 退款金额(分),源 `refundPrice`(元) | +| `refund_profit_cents` | Integer | nullable | 退款佣金(分),源 `refundProfit`(元) | +| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算 | +| `invalid_reason` | String(128) | nullable | 失效原因,源 `invalidReason` | +| `product_name` | String(512) | nullable | 商品名,源 `productName`(超 500 截断) | +| `pay_time` | DateTime(tz) | index, nullable | 付款时间,源 `payTime`(秒级 ts)。统计按时间窗过滤的就是它 | +| `mt_update_time` | DateTime(tz) | nullable | 美团侧更新时间,源 `updateTime`(秒级 ts) | +| `raw` | JSON / JSONB | NOT NULL, default `{}` | `query_order` 单条原始 dataList,留底排查/补字段 | +| `first_seen` | DateTime(tz) | server_default now(), NOT NULL | 首次拉到入库时间(本地) | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最近 upsert 时间(本地) | + +## 关系 / Join Key +- `sid` ≈ `cps_group.sid`(订单按 sid 归群,**语义关联,无 ForeignKey**)。`group_stats` 即据此把订单挂到群行。 +- 与 [`cps_link`](./cps_link.md)/[`cps_click`](./cps_click.md) **无直接列关联**:下单链路是"美团短链(带 sid)→ 美团 App 下单",订单只回传 `sid`、不回传我们的 `link.code` 或某次点击 id。所以点击与订单只能在**群(sid)维度**汇合,**无法把单笔订单对到单次点击**。 +- **本表无 `user_id`**:CPS 联盟订单是别人(被推广的微信群用户)在美团下的单,与本 App 的 `user` 表无关,纯渠道佣金对账,不归属本 App 用户。 + +## 索引与约束 +- PK `id`;**UNIQUE+index `order_id`**(`uq_cps_order_order_id` + `ix_cps_order_order_id`);index `sid`、`act_id`、`mt_status`、`pay_time`。 +- **无外键**。 + +## 注意 +- **状态决定佣金口径**(`group_stats._order_agg`):`4`取消/`5`风控**不计佣金**(`_INVALID_STATUS`);`6`结算才是佣金**真正到账**(`_SETTLED_STATUS`)。统计区分 `order_count`(有效=非取消非风控)、`settled_count`(已结算)、`canceled_count`,佣金分 `est_commission`(有效单预估)与 `settled_commission`(结算单)。 +- **"未归群"独立行**:`group_stats` 遍历完所有群后,对剩下的、有订单但 `sid` 不属任何现存群的 sid(历史遗留如 `wonderableai`、或别处来源、或群被删),单列一行 `group_id=None`、有对账无点击。所以本表 `sid` 可空/可孤立是设计内的,不是脏数据。 +- **金额/时间统一转换的原因**:`query_order` 返回金额是「元」字符串、时间是秒级 ts;`_yuan_to_cents`(Decimal 防浮点,`"null"`/空 → None)与 `_ts_to_dt`(转 tz-aware UTC,前端按北京展示)在入库时归一,与全站"金额存分、时间存 tz-aware"口径对齐。`raw` 整条留底,字段不够时不用重拉。 +- **upsert 而非 append**:同一订单会被多次拉到(状态变化),按 `order_id` 覆盖即可拿到最新状态;`reconcile` 可重复跑(幂等)。 +- **本表自始至终只服务美团**:初版(`277f9b1`)就定型,`3a40f61` 接淘宝/京东时**没动本表**——那俩平台无对账 API,统计里对账列直接给 `None`(前端显示 `-`)。这是"对账 = 美团专属"的边界。 diff --git a/docs/database/feedback.md b/docs/database/feedback.md index 2261af2..d916b30 100644 --- a/docs/database/feedback.md +++ b/docs/database/feedback.md @@ -2,10 +2,10 @@ > 模型 `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) -App「帮助与反馈」每次提交写一行。`content` 与 `contact` 必填,`images` 为可选截图。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。 +App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 原必填,**原型改版后客户端不再采集,新数据存空串**(列保持 NOT NULL、免迁移,历史数据仍有值);`images` 为可选截图(≤6 张)。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。 ## 用在哪 / 增删改查 -- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + `contact` + 可选 `images`;`create_feedback`)。截图先经 `core.media` 落 `/media/feedback/` 拿相对路径,再随反馈写入,`status='new'`。 +- **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 端当前无"我的反馈列表"读接口。 @@ -16,7 +16,7 @@ App「帮助与反馈」每次提交写一行。`content` 与 `contact` 必填,` | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 提交用户 | | `content` | Text | NOT NULL | 反馈正文 | -| `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机,便于回访) | +| `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机)。客户端改版后不再采集,新数据为空串;列仍 NOT NULL | | `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...`;无图为 NULL | | `status` | String(16) | NOT NULL, default `new` | 取值:`new`(待处理)/ `handled`(已处理) | | `created_at` | DateTime(tz) | server_default now(), index | 提交时间 | diff --git a/docs/database/invite_fingerprint.md b/docs/database/invite_fingerprint.md new file mode 100644 index 0000000..9ea972f --- /dev/null +++ b/docs/database/invite_fingerprint.md @@ -0,0 +1,39 @@ +# invite_fingerprint — 邀请指纹归因(剪贴板兜底) + +> 模型 `app/models/invite_fingerprint.py`(`InviteFingerprint`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/landing-track`(写,无鉴权)、`POST /api/v1/invite/bind`(读,指纹反查分支) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +一行 = 一次落地页访问的设备指纹快照。**剪贴板归因的兜底**:剪贴板 deferred-deeplink 是邀请归因主路径,但从"点下载"到"App 首启"之间剪贴板可能被覆盖、归因丢失。此表在落地页访问时先把 `(inviter, ip, screen, device_model, ua)` 记下来,被邀请人登录后剪贴板没拿到邀请码时,用 `(ip, device_model)` 反查 7 天内最近一条匹配 → 拿出邀请人 → 走 `bind(channel='fingerprint')`。任务来源见 #31 好友邀请任务 3。 + +## 用在哪 / 增删改查 +- **C(插入)**:`POST /api/v1/invite/landing-track`(`landing_track`,**无需鉴权**,浏览器没 token)→ `invite_repo.record_fingerprint()`。B 浏览器打开 `dl.html?ref=<码>` 时 JS POST,服务端从 HTTP 头拿 `ip`(`_client_ip`,优先 `X-Forwarded-For` 第一段)、解析 UA 拿 `device_model`(`_parse_device_model`,Android 正则抓 `;...Build/` 前 token = `Build.MODEL`),连同 JS 上报的 `screen` 一起入库。`record_fingerprint` 自行 commit(调用方此前无其它写)。无效码 / 拿不到 IP 走 silent 返回(`invalid_code`/`no_ip`,不抛 5xx 影响下载流程)。 +- **R(反查)**:`POST /api/v1/invite/bind`(`bind_invite`)的指纹分支 → `resolve_inviter_by_fingerprint()`。仅在请求**无邀请码、有 `fingerprint`** 时触发:`WHERE ip=? AND device_model=? AND created_at > now - INVITE_FP_WINDOW_DAYS(7d)` `ORDER BY created_at DESC LIMIT 1` → 命中则取 `inviter_user_id` 对应用户 → 用其 `invite_code` 走原 `bind()`(同样过幂等/自邀/新人闸/发币逻辑);未命中返回 `fp_not_found`。 +- **U / D**:无。只增不改不删。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 落地页对应的邀请人(由 `?ref=<码>` 解析得到) | +| `ip` | String(64) | NOT NULL | 访问者 IP(服务端从 HTTP 头拿;nginx 反代时走 `X-Forwarded-For`)。入库 `[:64]`。**反查参与匹配** | +| `device_model` | String(64) | NOT NULL, default `""` | 设备型号(如 `PJF110`、`23046PNC9C`)。浏览器侧由 UA 正则解析,客户端侧 = `Build.MODEL`,跨端对齐。入库 `[:64]`。**反查参与匹配** | +| `screen` | String(32) | NOT NULL, default `""` | 屏幕分辨率 `"1080x2400"`(浏览器 `screen.w×h`,客户端 `DisplayMetrics`)。入库 `[:32]`。**只入库不反查**(见注意) | +| `user_agent` | Text | NOT NULL, default `""` | 完整 UA 串(debug / 排查"匹配不上"用,不参与匹配)。入库 `[:2000]` 防异常长 UA | +| `created_at` | DateTime(tz) | server_default now(), index | 落地页访问时间(= 7 天窗口判据 + 反查倒序键) | + +## 关系 / Join Key +- `inviter_user_id` → `user.id`(硬 FK,多对一):一个邀请人的落地页可被多次访问。 +- **反查组合键 = `(ip, device_model)` + `created_at > cutoff`**:等值匹配 IP 与型号、再取窗口内最近一条。注意 `screen` 虽进了组合索引最左前缀,但 SQL 不拿它做 WHERE 条件。 +- 反查结果不直接 join 本表→`user`,而是 `db.get(User, fp.inviter_user_id)` 拿邀请人,再回 `invite_relation` 走 `bind()`。 + +## 索引与约束 +- PK `id`。 +- `ix_invite_fp_match`(`ip, screen, device_model, created_at`):反查主索引。`/bind` 兜底分支按 `ip + device_model + created_at` 范围扫 + 倒序 LIMIT 1,SQLite/PG 优化器都能用上该复合索引。 +- `ix_invite_fp_inviter`(`inviter_user_id`):统计/排查用("这个邀请人的落地页被谁访问过")。 + +## 注意 +- **`screen` 只入库不反查(关键设计,`resolve_inviter_by_fingerprint` docstring)**:浏览器算物理像素走 `CSS × devicePixelRatio`、Android 走 `DisplayMetrics.widthPixels` 真实硬件值,两端浮点 round 必然 ±1 像素漂移(DPR 不严格是整数)→ 严格匹配注定撞不上。而**同 `device_model` 必同 `screen`**(同型号同硬件),故 `screen` 是冗余字段,删它不损精度。所以匹配只用 `(ip, device_model)`。 +- **组合索引为何含 `screen`**:索引列序是 `(ip, screen, device_model, created_at)`,但 WHERE 只用 `ip`/`device_model`/`created_at`。`screen` 夹在中间会打断"`ip` 之后直接接 `device_model`"的最左连续前缀,理论上略损此查询的索引效率(待确认:实测扫描量是否受影响;当前数据量下无感)。若后续优化,建议把索引调成 `(ip, device_model, created_at)`。 +- **7 天窗口(`rewards.INVITE_FP_WINDOW_DAYS`)的取舍**:太短(24h)覆盖不到"晚上看链接、第二天装";太宽(30d)IP/设备会漂、匹配错率上升;7 天兼顾"看广告→使用"周期与匹配精度。 +- **撞错概率**:只有"同一 IP 下、同型号手机、7 天内同时被不同人邀请"才会归因到错的邀请人,中国家庭/公司 WiFi 场景概率极低;属可接受的工程取舍而非 bug。 +- **与 invite_relation 的关系**:本表只是**线索库**,不发奖、不是结果。真正落地是反查出邀请人后回到 [invite_relation](./invite_relation.md) 的 `bind()`,写一行 `channel='fingerprint'` 并发币;本表本身不参与发奖与幂等。 +- **迁移**:`invite_fingerprint_table`(`down_revision=0cf18d590b1d`),晚于 `invite_code_and_relation`(#24 的表先建)。 diff --git a/docs/database/invite_relation.md b/docs/database/invite_relation.md new file mode 100644 index 0000000..590b20f --- /dev/null +++ b/docs/database/invite_relation.md @@ -0,0 +1,46 @@ +# invite_relation — 好友邀请绑定关系(注册即生效) + +> 模型 `app/models/invite.py`(`InviteRelation`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/bind`(写)、`GET /api/v1/invite/me`(战绩)、`GET /api/v1/invite/invitees`(列表) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**注册即生效**:同一事务里给 A、B 各发 1 万金币(= 1 元,可提现)。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。 + +## 用在哪 / 增删改查 +- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`,**同事务**复用 `wallet.grant_coins` 给双方发金币(`grant_coins` 只 flush,由 `bind()` 统一 commit)→ "建关系 + 双方加金币"原子。 + - **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`,不重复发奖(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。 + - 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active` → `invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at` 在 `rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`。只有全过才插行 + 发奖。 +- **U / D**:无。这张表只增不改不删(纯账本)。 +- **R**: + - `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币。 + - `GET /api/v1/invite/invitees`(`my_invitees`)→ `get_invitees(inviter_id, limit, offset)`:join `user` 出被邀请人列表(倒序分页),名字降级兜底 `nickname → wechat_nickname → 脱敏手机号`,`coins` 取 `inviter_coin`。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 邀请人(A) | +| `invitee_user_id` | Integer | FK→user.id, **UNIQUE**, index, NOT NULL | 被邀请人(B)。**唯一 = 幂等键**,一个 B 只能被归因一次 | +| `channel` | String(16) | NOT NULL, default `clipboard` | 归因来源:`clipboard`(剪贴板自动)/ `manual`(手动填码)/ `fingerprint`(指纹反查兜底)。入库前 `[:16]` 截断 | +| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(注册即生效)。**预留** `pending`/`effective`:将来若改"完成首单才生效"时启用 | +| `inviter_coin` | Integer | NOT NULL, default 0 | 本次给 A 发的金币(记账留痕,=`rewards.INVITE_INVITER_COINS`=10000) | +| `invitee_coin` | Integer | NOT NULL, default 0 | 本次给 B 发的金币(=`rewards.INVITE_INVITEE_COINS`=10000) | +| `created_at` | DateTime(tz) | server_default now(), index | 绑定时间(= 列表倒序键) | + +## 关系 / Join Key +- `inviter_user_id` → `user.id`(硬 FK,多对一):一个 A 可邀多个 B。 +- `invitee_user_id` → `user.id`(硬 FK,**一对一**,唯一约束):一个 B 至多一行。 +- 发金币时落 `coin_transaction`:`biz_type='invite_inviter'`(给 A,`ref_id=invitee.id`)/ `biz_type='invite_invitee'`(给 B,`ref_id=inviter.id`),双方 `ref_id` 互指对方便于对账。 +- `get_invitees` 用 `InviteRelation JOIN user ON user.id = invitee_user_id` 取被邀请人资料;`total` 单独 `count` 算 `has_more`。 + +## 索引与约束 +- PK `id`。 +- `ix_invite_relation_inviter_user_id`(`inviter_user_id`):`get_stats` / `get_invitees` 按邀请人查。 +- `ix_invite_relation_invitee_user_id`(`invitee_user_id`,**UNIQUE**):幂等键 + 加速 `_relation_of_invitee` 反查。 +- `ix_invite_relation_created_at`(`created_at`):列表倒序。 + +## 注意 +- **防重复发奖三道**(`repositories/invite.py` docstring):① `invitee_user_id` 唯一(应用层 `_relation_of_invitee` 先查 + DB 唯一约束并发兜底);② 自邀屏蔽;③ 手机号天然唯一(每个 B = 一个真实手机号账号)= 限制刷量规模。 +- **`status` 当前恒为 `effective`**:产品取"注册即生效"而非"完成首单才生效",`pending` 取值是为后者预留、目前不写入。 +- **`inviter_coin`/`invitee_coin` 是留痕字段**:写死当时发的金币值,即便日后改奖励常量,历史行仍保留发奖时的额度,便于对账。 +- **`channel` 三种取值**:`clipboard`(deferred-deeplink 主路径,落地页写剪贴板、首启读回)/ `manual`(用户在邀请页手输)/ `fingerprint`(剪贴板被覆盖时走指纹兜底,见 [invite_fingerprint](./invite_fingerprint.md));三者都汇入同一个 `bind()`,只 `channel` 不同。 +- **风控缺口(#24 设计文档「上线前还差什么」标注)**:1 万金币可提现且**邀请人无总数上限**,接码平台批量注册新号绑同码即可刷;上线前需加 inviter 上限 + 基础风控。当前 MVP 仅靠"手机号唯一 + 72h 新人闸"挡。 +- **alembic 多 head**:本表迁移 `invite_code_and_relation`(`down_revision=11a1d08c6f55`)与 `coupon_state_tables` 是同一父的兄弟迁移,合 main 前需建 merge 迁移,否则 prod `alembic upgrade head` 撞 `Multiple head revisions`(#24 设计文档已警示)。 diff --git a/docs/database/ops_marquee_seed.md b/docs/database/ops_marquee_seed.md index 7de9bdb..a001611 100644 --- a/docs/database/ops_marquee_seed.md +++ b/docs/database/ops_marquee_seed.md @@ -8,7 +8,7 @@ | 列 | 类型 | 约束 / 默认 | 说明 | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `masked_user` | String(64) | NULL 可空 | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)风格(整串存)。**留空或旧 `用户****xxx` 模板名 → feed 展示时随机合成混合风格名**(避开同屏撞名、自愈历史种子),风格与真实条一致、永不穿帮 | +| `masked_user` | String(64) | NULL 可空 | 脱敏用户名(整串存)。**留空或旧 `用户****xxx` 模板名 → feed 展示时按脱敏规则随机合成**(本地姓/名字池组合的真名 + 中英网络昵称语料,首末字+星;避开同屏撞名、自愈历史种子),风格与真实条一致、永不穿帮。详见 [platform-savings-feed](../api/platform-savings-feed.md) | | `min_cents` | Integer | NOT NULL | 节省金额区间下限(分) | | `max_cents` | Integer | NOT NULL | 节省金额区间上限(分);feed 每次在 `[min,max]` 随机取值,**固定金额则 min==max** | | `enabled` | Boolean | NOT NULL, default true | 停用的不参与混播 | diff --git a/docs/postgres-migration.md b/docs/database/postgres-migration.md similarity index 97% rename from docs/postgres-migration.md rename to docs/database/postgres-migration.md index 8d1ee58..84f6567 100644 --- a/docs/postgres-migration.md +++ b/docs/database/postgres-migration.md @@ -5,7 +5,7 @@ > **相关文档**: > - 这份文档讲 **切引擎的完整步骤**(本机装 PG / 建库 / `scripts/init_postgres.py` 用法 / 生产切换) > - [数据库迁移.md](./数据库迁移.md) 讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查/迁移链 12 条) -> - [后端技术实现.md §10](./后端技术实现.md) 列待办与已知问题(含 `init_postgres.py` 已知小 bug) +> - [后端技术实现.md §10](../后端技术实现.md) 列待办与已知问题(含 `init_postgres.py` 已知小 bug) > > **背景假设**:当前生产**无真实用户数据**(MVP 阶段),所以整个迁移本质是"切引擎",不涉及数据搬迁。如果将来已有真实数据,本文档不适用,需补充 `pgloader` 演练 + 钱表金额逐行核对 + 停服窗口 + 回滚预案。 > @@ -248,7 +248,7 @@ sudo systemctl restart shaguabijia-app-server ## 6. 顺便建议:同时装上 Redis -PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](./待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署: +PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](../guides/待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署: ```bash sudo apt install redis-server diff --git a/docs/database/price_observation.md b/docs/database/price_observation.md new file mode 100644 index 0000000..105d6cd --- /dev/null +++ b/docs/database/price_observation.md @@ -0,0 +1,71 @@ +# price_observation — 比价沉淀的价格事实(平台/门店视角的价格资产层) + +> 模型 `app/models/price_observation.py` · 仓库 `app/repositories/price_observation.py` · 上报端点 `app/api/internal/price.py`(`POST /internal/price-observation`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +每完成一次比价,pricebot 在 done 帧把「在某平台某店、这一篮菜/这单到手价多少钱」按平台逐条算出来,**server→server 内部上报**落这里(一次外卖比价 = 源平台 1 条 + N 个目标平台各 1 条,`scope='order'`)。**与登录无关、不依赖客户端鉴权**——比价透传链路(`compare.py`)当前不鉴权,只要比价跑到 done 就记,**匿名用户也记**(来源记 `source_device_id`,`source_user_id` 客户端带上时才有)。 + +与 [`comparison_record`](./comparison_record.md) 的区别(model docstring 明示,两表独立、互不影响): +- `comparison_record` = **用户视角**的「我的比价记录」,客户端登录后用带 JWT 的通道(`POST /compare/record`)主动上报,按 `(user_id, trace_id)` 存。 +- `price_observation` = **平台/门店视角的客观价格事实**,server 侧**无条件沉淀**,按 (平台, 门店, 菜, 时间) 组织。是未来「别人查过同店 → 直接秒回价格」价格大数据的源头(#21,「先存数据、用法后续」)。 + +> raw trace(逐 step 全过程)另存 pricebot 本地 `jsonl.gz`,本表只存**提炼后的价格事实**(本表可从 raw 重算);`trace_id` 软指 pricebot work_logs 供溯源/重算。 + +## 用在哪 / 增删改查 +- **C(批量插入,幂等)**:`POST /internal/price-observation`(`report_price_observation` → `repo.insert_batch`)。pricebot 在 done 帧把整批观测 POST 过来落库。幂等键 = `(trace_id, platform, scope)`:仓库**先查该 trace 已有的 `(platform, scope)`,只插新的**(不依赖 `ON CONFLICT`,跨方言 PG/SQLite 都安全),并发撞唯一约束则 `db.rollback()` 后整批跳过(本批本就幂等,**不重试**,见 P0 约定)。同批内还会去重一次(防 pricebot 同帧给重复平台)。返回 `{inserted, skipped}`。 +- **U / D**:无。append-only,无更新无删除。 +- **R**:**当前无读取端点**(纯写入沉淀层,未来做「秒回价格」/聚合时再用)。 + +> 上报端点是 **server→server 专用**:不走用户 JWT,靠共享密钥头 `X-Internal-Secret`(== `settings.INTERNAL_API_SECRET`,常量时间比较 `hmac.compare_digest`)。**密钥未配置(默认空)→ 直接 503**(挡住裸奔的写端点);不匹配 → 401。该密钥须与 pricebot 侧同值。 + +## 字段 + +批级字段(门店/菜篮/地理/来源,一次比价共享,由 `insert_batch` 灌进每条)vs 逐平台字段(`observations[]` 各自带)见下表「来源」列。 + +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `observed_at` | DateTime(tz) | server_default now(), index, NOT NULL | 观测时刻(≈比价 done 时;服务端落库时取 now)。按时间查「最近有效价」用 | +| `trace_id` | String(64) | index, NOT NULL | pricebot 侧 trace_id:**幂等去重键**(含于唯一约束)+ 回指 A 层原始 trace(溯源/重算)。软指 pricebot work_logs,无硬 FK | +| `business_type` | String(16) | NOT NULL, default `food`, index | `food`(外卖,当前唯一接通)/ `ecom`(电商,二期)。**批级共享** | +| **门店维度** | | | denormalize 成文本,不建维度表;实体归一二期再做 | +| `platform` | String(32) | index, NOT NULL | 平台标识。**逐平台**(`observations[].platform`),含于唯一约束 | +| `platform_store_id` | String(64) | 可空 | 平台内门店 ID(抓得到才存,将来归一最稳的 key;**现在多为空**)。**逐平台** | +| `store_name` | String(128) | index, 可空 | 门店名。**批级共享**(源/目标同名场景下整批一份) | +| `city` | String(64) | 可空 | **批级共享** | +| `geohash` | String(16) | index, 可空 | 地理(**现在拿不到→留空**;二期把 device 经纬度透传进比价请求后回填)。**批级共享** | +| `lng` / `lat` | Float | 可空 | 同上,**批级共享** | +| **价格事实** | | | | +| `is_source` | Boolean | NOT NULL, default false | 是否源平台(发起比价那家)。源平台价也是真实观测,照记。**逐平台** | +| `scope` | String(16) | NOT NULL, default `order` | 口径:`order`(整单到手价,外卖当前唯一)/ `dish`(单菜单价,引擎暂不逐菜读,二期)。含于唯一约束。**逐平台** | +| `price_cents` | Integer | 可空 | 该口径到手价(分);**None = 该平台采集失败 / 门店打烊,无有效价**。**逐平台** | +| `coupon_saved_cents` | Integer | 可空 | 红包/券省了多少(分)。**逐平台** | +| `coupon_name` | String(64) | 可空 | 优惠来源名。**逐平台** | +| `store_closed` | String(32) | 可空 | 目标店打烊原因(**非空时 `price_cents` 为 None**)。**逐平台** | +| `rank` | Integer | 可空 | 本次比价全局名次(1=最便宜;按到手价升序,源/目标统一排)。**逐平台** | +| **明细(JSON)** | | | | +| `dishes` | JSON(PG JSONB) | 可空 | 菜篮 `[{name, qty, subtotal?}]`(外卖)。dish 级单价二期从这里 + 引擎增强推。**批级共享** | +| `attrs` | JSON(PG JSONB) | 可空 | 灵活字段兜底(配送费/起送/活动名/跳过菜数 等),免得加字段就迁移。**逐平台**(`observations[].attrs`) | +| **来源(溯源/去重/置信)** | | | | +| `source_device_id` | String(64) | index, 可空 | 来源设备;匿名比价也有。**批级共享** | +| `source_user_id` | Integer | index, 可空 | **比价链路不鉴权拿不到,客户端带上时才有**,先可空、不进唯一键。软指 `user.id`。**批级共享** | +| `confidence` | Float | NOT NULL, default 1.0 | 置信度,预留 | +| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 落库时间 | + +## 关系 / Join Key +- **无硬外键**。 +- `trace_id` **软指** pricebot work_logs(溯源/重算/排查),并作幂等去重键;与 `comparison_record.trace_id` 同源(同一次比价的 pricebot trace),但两表不互相 join、各存各的视角。 +- `source_user_id` **软指** `user.id`(可空,不进唯一键、不阻塞落库)。 +- `source_device_id` 与不鉴权期 `comparison_record.device_id` / 领券三表 `device_id` 同源,可用于对账(无 SQL 级约束)。 + +## 索引与约束 +- PK `id`。 +- **UNIQUE(`trace_id`, `platform`, `scope`) = `uq_price_obs_trace_platform_scope`**:一次比价(trace)里同一平台、同一口径只一条 → pricebot 重试 / 客户端 replay 重复上报幂等去重。 +- index:`observed_at`、`trace_id`、`business_type`、`platform`、`store_name`、`geohash`、`source_device_id`、`source_user_id`(为将来按 平台/门店/菜/时间/地理 查询聚合预埋)。 + +## 注意 +- **资产层定位「先存后用」**:当前**只写不读**(无 R 端点、不参与任何业务判断),是为「别人查过同店→秒回价格」大数据预先沉淀的原始事实。结构化列预埋查询/聚合维度,用法二期再定。 +- **`attrs` / `dishes` 是 JSONB 兜底,别塞原始无障碍树**(几十 KB → 行膨胀);原始大树/逐 step 看 `trace_id` 指过去的 pricebot 落盘。`_JSON = JSON().with_variant(JSONB(), "postgresql")`:PG 用 JSONB(可建 GIN),SQLite dev 退化通用 JSON(同 `comparison_record` / `coupon_claim_record`)。 +- **`price_cents=None` 是有意义的观测**:表示该平台采集失败 / 门店打烊(看 `store_closed`),不是「没记」。统计「最低价」时要先过滤 None。 +- **当前数据多为半空**:`platform_store_id` 多为空(门店 ID 抓不全)、`geohash/lng/lat` 留空(device 经纬度二期才透传进比价请求)、`source_user_id` 多为空(链路不鉴权)。这些是**二期回填**计划,不是 bug。 +- **幂等/并发**:不重试、失败就失败(P0 约定);同批去重 + 唯一键 + `IntegrityError` 回滚三重防御,但回滚是**整批**回滚(撞约束则本批全跳过)。 +- 上报端点未配 `INTERNAL_API_SECRET` 时返 **503**(不是 401),这是「内部端点默认关闭」的安全默认态——本地 dev / 未启用时该表不会有新数据。 diff --git a/docs/database/signin_boost_record.md b/docs/database/signin_boost_record.md index 718a21c..ea71341 100644 --- a/docs/database/signin_boost_record.md +++ b/docs/database/signin_boost_record.md @@ -1,6 +1,6 @@ # signin_boost_record — 签到膨胀记录 -App 用户 Day1-Day13 当天签到后,看完激励视频可固定膨胀 2000 金币一次。Day14 不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。 +App 用户当天签到后,看完激励视频可固定膨胀一次(默认 3000 金币,`rewards.SIGNIN_BOOST_COIN`,运营后台 `app_config.signin_boost_coin` 可改)。循环最后一天(`cycle_day == SIGNIN_CYCLE_LEN`,即 7 天循环的第 7 天)不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。 ## 字段 @@ -9,14 +9,14 @@ App 用户 Day1-Day13 当天签到后,看完激励视频可固定膨胀 2000 | `id` | Integer | PK | 自增主键 | | `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | | `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 | -| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 2000 | +| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 3000(`rewards.get_signin_boost_coin`) | | `ad_ref_id` | String(64) | nullable | 穿山甲 S2S 回调 `trans_id` | -| `created_at` | DateTime | NOT NULL | 创建时间 | +| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 | ## 约束 -- `UNIQUE(user_id, signin_date)`:同一用户同一天只能膨胀一次。 +- `UNIQUE(user_id, signin_date)` = `uq_signin_boost_user_date`:同一用户同一天只能膨胀一次。 ## 关联 -- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id=ad_ref_id`。 +- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id = ad_ref_id`(无 ad_ref_id 时回退当天日期 ISO 串)。 diff --git a/docs/database/signin_record.md b/docs/database/signin_record.md index 786beab..0f89b21 100644 --- a/docs/database/signin_record.md +++ b/docs/database/signin_record.md @@ -1,13 +1,13 @@ -# signin_record — 签到记录(14 天循环) +# signin_record — 签到记录(7 天循环) > 模型 `app/models/signin.py` · 仓库 `app/repositories/signin.py` · 接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..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 天"展示。 +App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,200,300,200,400,400,800)`,一轮 7 天累计 2500 金币,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 8 天回到第 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` 读最近一条 + 推算今天档位。 +- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、7 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | @@ -15,7 +15,7 @@ App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天 | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | | `signin_date` | **Date** | NOT NULL | 签到日期(**北京时间** `cn_today()` 的 date)。其 ISO 串被 `coin_transaction.ref_id` 引用(biz_type=signin) | -| `cycle_day` | Integer | NOT NULL | 取值 `1..14`:14 天循环里今天第几档,决定发币额;断签重置回 1 | +| `cycle_day` | Integer | NOT NULL | 取值 `1..7`:7 天循环里今天第几档,决定发币额;断签重置回 1 | | `streak` | Integer | NOT NULL | 连续签到天数(≥1,不封顶);断签重置回 1 | | `coin_awarded` | Integer | NOT NULL | 本次实发金币(= 当时档位金额) | | `created_at` | DateTime(tz) | server_default now() | 时间 | diff --git a/docs/database/store_mapping.md b/docs/database/store_mapping.md new file mode 100644 index 0000000..d63695d --- /dev/null +++ b/docs/database/store_mapping.md @@ -0,0 +1,85 @@ +# store_mapping — 跨平台「同一家店」的 id/名/deeplink 身份映射(门店资产层) + +> 模型 `app/models/store_mapping.py` · 仓库 `app/repositories/store_mapping.py` · 上报/反查端点 `app/api/internal/store.py`(`POST /internal/store-mapping`、`GET /internal/store-mapping/lookup`、`POST /internal/store-mapping/invalidate`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +每完成一次比价,pricebot 把同一家物理店在 **淘宝/美团/京东** 各自的店铺 id、店名、可复跳 deeplink **server→server 内部上报**落这里一行(一次比价 = 一个 trace = 一行,各平台「腿」解析出 id 后填空合并进同一行)。**与登录无关、不依赖客户端鉴权**(同 [`price_observation`](./price_observation.md),`source_user_id` 客户端带上时才有,匿名也记)。 + +与 [`price_observation`](./price_observation.md) 的区别(两表平行、独立): +- `price_observation` = 平台/门店视角的**价格事实**(某店这单多少钱)。 +- `store_mapping` = 平台/门店视角的**身份映射**(同一家店在各平台的店铺 id/名/deeplink)。是未来「我见过这家店 → 跳过重新搜索/匹配,直接 deeplink 进店内搜索」的源头(#48)。 + +与 [`comparison_record`](./comparison_record.md)(用户视角、带 JWT、按 `user_id` 存)也无关:本表是 server 无条件沉淀的客观映射、匿名也记。 + +> **⚠️ 数据质量**:跨平台「同一家店」的连接来自 agent 的 **LLM 店铺匹配**,匹配错则一行里 `id_taobao` 与 `id_jd`/`name_meituan` **连到不同店**。本表是 **append-only 原始记录**(每比价一行、`trace_id` 幂等防重试重复),「一行里多平台 id 共存」只表示「两条腿搜同一个源店名各自匹配到了某家店」= name-match 置信度、**非已核实同一实体**;清洗/归一/精确匹配二期由下游做。 + +## 用在哪 / 增删改查 +- **C / U(upsert,trace_id 维度幂等 + 填空合并)**:`POST /internal/store-mapping`(`report_store_mapping` → `repo.upsert`)。合并键 = `trace_id`(唯一约束)。一次跨平台比价 = 一个 trace = 一个真实店铺:某条腿解析出 id 先建行(填自己那几列),后到的腿(将来)再 upsert 进**同一行**。**合并策略 = 填空(fill-the-blanks)**:`_merge_fill_blanks` 只写该行当前为 NULL 的列、**绝不覆盖已有非空值**;共享列(geo/source/溯源)先到先得。首写用逐列 `setattr`(而非硬编码构造器,否则首写是美团/京东腿时其列会漏)。不依赖 `ON CONFLICT`,跨方言安全;并发撞唯一约束则 `rollback()` 后转填空合并路径。返回 `{inserted, row_id}`(inserted=1 新建 / 0 合并)。 +- **R(lookup 反查,命中即省一整段现场反查)**:`GET /internal/store-mapping/lookup?source_platform=&name=&lat=&lng=`(`lookup_nearest`)。**比价前**按「源平台店名」反查各目标平台已沉淀的店铺 id,命中就让 pricebot **直接 deeplink 跳店内搜索**,省掉「开平台→进店→分享→反查」整段。机制:匹配键 = 源平台对应的 name 列精确等于 `name`;每个目标**分别**取「含该目标 id 的同名候选里最近一条」(有入参 geo 且候选带 geo → haversine 取最近,否则取 `created_at` 最新);**排除源平台自己**(不复用源平台 id);**过滤掉 deeplink 已失效的候选**(见下)。返回 `{target_key: {id..., deeplink, row_id, dist_km?}}`,空 = 没缓存、pricebot 走现场反查老路。 +- **U(deeplink 失效标记)**:`POST /internal/store-mapping/invalidate`(`{platform, shop_id}`)。pricebot 撞错误页回退时上报,按 `shop_id` 把**所有**匹配行的对应 `*_deeplink_invalid_at` 置 `now()`(幂等:已标记的跳过)。`taobao`→`mark_taobao_deeplink_invalid`、`jd`→`mark_jd_deeplink_invalid`;**其它平台(含 meituan)当前 no-op 返回 `affected=0`、不报错**(向后兼容 pricebot 将来扩展)。返回 `{ok, affected}`。 +- **D**:无业务删除。 + +> 上报/反查/失效端点全是 **server→server 专用**,复用 `price.py` 的 `_check_secret`(同一 `X-Internal-Secret` 密钥,未配置→503,不匹配→401)。 + +## 字段 + +各平台列**按比价角色稀疏填充**(淘宝当目标才有 `*_taobao`,依此类推)。 + +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| **跨平台身份** | | | 同一物理店在各平台的 id/名 | +| `id_taobao` | String(64) | index, 可空 | 淘宝 = 分享短链解析出的 **shopId**(淘宝当目标、走完取 id 流程才有)。门店稳定数字主键 | +| `name_taobao` | String(128) | 可空 | 店铺页 a11y `content_desc`「店铺标题:xxx」剥前缀 | +| `id_meituan` | String(64) | index, 可空 | **留给将来 CPS API 的稳定数字 poi_id**;当前无 share→稳定 id 机制 → 多为空(可复跳票据见 `meituan_poi_id_str`) | +| `name_meituan` | String(128) | 可空 | 源平台店名(来自 intent/agent 匹配名) | +| `id_jd` | String(64) | index, 可空 | 京东 = **storeId**(门店稳定数字主键,同 taobao shopId;3.cn 短链反查)。`venderId` 另存 `jd_vender_id` | +| `name_jd` | String(128) | 可空 | 京东店铺页店名 | +| **地理** | | | 同名店异地区分 / 地理分桶匹配主要燃料 | +| `city` | String(64) | 可空 | | +| `geohash` | String(16) | index, 可空 | | +| `lng` / `lat` | Float | 可空 | lookup 时按入参 geo 取最近候选用 | +| `taobao_address` | String(256) | 可空 | 淘宝门店地址(店铺页 a11y 抓到才有;比经纬度更利于人工/LLM 匹配) | +| **溯源 / 画像** | | | | +| `source_platform` | String(32) | index, 可空 | 发起比价那家:`meituan` / `taobao_flash` / `jd_waimai` … lookup 据此选 name 列、排除源平台自己 | +| `business_type` | String(16) | NOT NULL, default `food` | | +| `trace_id` | String(64) | NOT NULL | **幂等合并键**(唯一约束)+ 回指原始 trace(溯源)。软指 pricebot work_logs,无硬 FK | +| `source_device_id` | String(64) | index, 可空 | | +| `source_user_id` | Integer | index, 可空 | **比价链路不鉴权拿不到,客户端带上时才有**,软指 `user.id` | +| **淘宝原料**(可复跳/重解析/调试) | | | | +| `taobao_share_url` | String(256) | 可空 | `m.tb.cn` 短链 | +| `taobao_resolved_url` | Text | 可空 | 解析出的目标 URL(含 shopId)。Text 因 URL 可能很长 | +| `taobao_deeplink` | Text | 可空 | 拼好的 et-store/search deeplink | +| `taobao_deeplink_invalid_at` | DateTime(tz) | 可空 | **淘宝 deeplink 失效标记**(#53)。撞「页面出错了」降级页时被置;**NULL=有效**。lookup 反查过滤掉非 NULL 的淘宝候选 | +| **美团原料**(dpurl.cn 短链 → 302 反查 → imeituan://) | | | | +| `meituan_poi_id_str` | String(64) | index, 可空 | **⚠️ 每次分享重新加密、非稳定主键**(调研 §八)→ 单列存「可复跳的一次性票据」,**不进 `id_meituan`** | +| `meituan_share_url` | String(256) | 可空 | `dpurl.cn` 短链 | +| `meituan_resolved_url` | Text | 可空 | 302 落地 menu URL(含 poi_id_str) | +| `meituan_deeplink` | Text | 可空 | 拼好的 `imeituan://` 店内搜索 deeplink | +| **京东原料**(3.cn 短链 → 重定向反查 → openapp.jdmobile://) | | | | +| `jd_vender_id` | String(64) | index, 可空 | venderId 单列存(deeplink 模板 venderId+storeId 都要;venderId 是商家维度、可跨门店,与门店 storeId 分开记) | +| `jd_share_url` | String(256) | 可空 | `3.cn` 短链 | +| `jd_resolved_url` | Text | 可空 | 反查出的目标 `openapp.jdmobile://` URL | +| `jd_deeplink` | Text | 可空 | 拼好的 pages/search 店内搜索 deeplink | +| `jd_deeplink_invalid_at` | DateTime(tz) | 可空 | **京东 deeplink 失效标记**(#56,对标淘宝)。撞「当前门店超出配送范围」页时被置;**NULL=有效**。lookup 过滤非 NULL 的京东候选 | +| `attrs` | JSON(PG JSONB) | 可空 | 灵活字段兜底,免得加字段就迁移 | +| `created_at` | DateTime(tz) | server_default now(), index, NOT NULL | lookup 同名候选无 geo 时按它取最新 | + +## 关系 / Join Key +- **无硬外键**。 +- `trace_id` **软指** pricebot work_logs(溯源/排查),并作 upsert 幂等合并键。 +- `source_user_id` **软指** `user.id`(可空,不进唯一键、不阻塞落库)。 +- lookup 的匹配靠**店名字符串精确相等**(`_SOURCE_NAME_COLUMN` 把源平台映到对应 name 列)+ 可选 geo 取最近,**非 id 级 join**。 + +## 索引与约束 +- PK `id`。 +- **UNIQUE(`trace_id`) = `uq_store_mapping_trace`**:一次比价(trace)只一行 → pricebot 重试 / 客户端 replay 重复上报幂等去重;后到的平台腿填空合并进同一行。 +- index:`id_taobao`、`id_meituan`、`id_jd`、`meituan_poi_id_str`、`jd_vender_id`、`geohash`、`source_platform`、`source_device_id`、`source_user_id`、`created_at`(lookup 反查 / 失效标记按 id 列查需要)。 + +## 注意 +- **deeplink 失效→标记→回退闭环**(#53 淘宝 / #56 京东,本表最关键机制):缓存的 deeplink 会过期(淘宝撞「页面出错了」、京东撞「当前门店超出配送范围」)。pricebot 撞到错误降级页 → 调 `/internal/store-mapping/invalidate` 按 shopId/storeId 把**所有**相关行打上 `*_deeplink_invalid_at` → 后续 `lookup_nearest` **整条排除**失效候选(当没缓存,pricebot 走正常搜店)→ 重搜成功会写**新行**自然覆盖。按 shopId 标记所有行(不是单行)是因为同一坏 id 可能散在多次比价的多行里,全标掉才能让 lookup 不再返回它。 +- **美团没有失效标记**:`meituan_poi_id_str` 是「每次分享重新加密」的一次性票据、本就不稳定不复用为稳定 id,故无 invalidate 路径(`invalidate` 端点对 meituan no-op)。三平台 share→id 反查机制不对称:淘宝 `m.tb.cn`→shopId(稳定)、京东 `3.cn`→venderId+storeId(稳定)、美团 `dpurl.cn`→poi_id_str(**非稳定**)。 +- **资产层「先存后用」+ append-only**:lookup 是已接通的读用途(省现场反查),但表本身是原始记录、不做清洗归一。「一行多平台 id 共存」**不代表已核实同一实体**——下游做精确匹配前别当真值用。 +- **数据质量依赖 LLM 匹配**:跨平台连接来自 agent 店铺匹配,**可能连错店**;排查某行可疑映射时回 `trace_id` 指的 pricebot trace 看当时的匹配上下文。 +- **`attrs` 是 JSONB 兜底,别塞大树**(同 `price_observation`);`_JSON = JSON().with_variant(JSONB(), "postgresql")`,PG JSONB / SQLite dev 通用 JSON。 +- **填空合并不覆盖**:共享列(geo/source/device)先到先得,后到的腿只填自己那几列的空位;想「纠正」已写错的列**当前没有路径**(append-only,靠新 trace 写新行)。 +- 上报端点未配 `INTERNAL_API_SECRET` 时返 **503**(默认关闭态),本地 dev / 未启用时该表不会有新数据。 diff --git a/docs/database/user.md b/docs/database/user.md index 04b5adb..ce33696 100644 --- a/docs/database/user.md +++ b/docs/database/user.md @@ -13,24 +13,27 @@ ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `id` | Integer | PK, autoincrement | 用户主键;被 15 张表的 `user_id` 外键引用 | +| `id` | Integer | PK, autoincrement | 用户主键;被多张业务表的 `user_id` 外键引用 | | `phone` | String(20) | UNIQUE, index, NOT NULL | 手机号(登录主键)。注销后置 `deleted_` 占位释放唯一约束 | +| `username` | String(11) | UNIQUE, index, NOT NULL | 对外展示账号 ID:11 位纯数字、首位非 1(与手机号天然区分)、全局唯一、创建时随机生成、不可变、**不参与登录**(登录仍走 phone)。生成见 `repositories/user._gen_username` | | `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` 写) | +| `invite_code` | String(16) | UNIQUE, index, nullable | 邀请码:每用户一个稳定短码(懒生成,见 `repositories/invite.ensure_code`),分享链接/二维码里带它。UNIQUE 允许多个 NULL(未生成的用户) | | `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`(admin 禁用)/ `deleted`(已注销);仅 active 能通过鉴权 | +| `debug_trace_enabled` | Boolean | NOT NULL, default false, server_default false | 调试链接权限:开了的用户在比价完成弹窗 + 比价记录页能看到「复制调试链接」按钮(运营后台按用户配置);`/me` 与登录响应带出给前端做条件渲染 | | `created_at` | DateTime(tz) | server_default now() | 注册时间 | | `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`。 +- **被引用方(本表是 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`;`invite_relation` 的 `inviter_user_id` / `invitee_user_id` 均 → `user.id`。 - 与 `admin_user` **无任何关联**(C 端用户 vs 后台管理员,两套体系)。 ## 索引与约束 -- PK `id`;UNIQUE+index `phone`;UNIQUE+index `wechat_openid`(允许多 NULL)。 +- PK `id`;UNIQUE+index `phone`、`username`、`invite_code`(允许多 NULL)、`wechat_openid`(允许多 NULL)。 ## 注意 - `nickname/avatar_url`(通用)与 `wechat_nickname/wechat_avatar_url`(微信)**分开存,不互相覆盖**。 diff --git a/docs/database/user_task.md b/docs/database/user_task.md index c2acc22..26eb190 100644 --- a/docs/database/user_task.md +++ b/docs/database/user_task.md @@ -2,10 +2,11 @@ > 模型 `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`。任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 1000}`,运营后台 `app_config.task_rewards` 可改)。 +**只能领一次**的任务,领奖后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`。任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 750}`,运营后台 `app_config.task_rewards` 可改)。 +> ⚠️ 当前 `TASK_REWARDS` 里唯一的 `enable_notification`(打开消息提醒)已改为**可重复领取**任务(每次减半,见 `rewards.notification_reward`),它**不写本表**——领取次数靠数 `coin_transaction` 的正向流水条数(`biz_type='task_enable_notification'`),`ref_id` 带序号 `task_key:N`。因此本表目前实际**没有被任何已定义任务写入**;待新增真正的一次性任务后才会落行。 ## 用在哪 / 增删改查 -- **C(插入)**:`POST /tasks/claim`(`claim_task`)。`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。 +- **C(插入)**:`POST /tasks/claim`(`claim_task`),**仅一次性任务**走这条路:`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。可重复任务(`enable_notification`)在 `claim_task` 里走另一分支:**不写本表**,只发币(`ref_id=task_key:N`),减半到底(`notification_max_claims`)后再领抛 `AlreadyClaimedError`。 - **U / D**:无。领过即终态。 - **R**:`GET /tasks/list`(任务页:列出所有已知任务 + 是否已领),`list_tasks` 把本表已领的 `task_key` 集合与任务字典对照。 @@ -14,7 +15,7 @@ |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `task_key` | String(48) | NOT NULL | 任务标识,取值见 `rewards.TASK_REWARDS`(当前仅 `enable_notification`)。**被 `coin_transaction.ref_id` 引用**(biz_type=`task_`) | +| `task_key` | String(48) | NOT NULL | 任务标识,取值见 `rewards.TASK_REWARDS`(`enable_notification` 是可重复任务,不落本表)。一次性任务领取时其 `task_key` = 配套 `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() | 领取时间 | diff --git a/docs/database/withdraw_order.md b/docs/database/withdraw_order.md index edb454c..9b34897 100644 --- a/docs/database/withdraw_order.md +++ b/docs/database/withdraw_order.md @@ -44,6 +44,7 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款) ## 索引与约束 - PK `id`;UNIQUE+index `out_bill_no`;index `user_id`、`created_at`。 +- 部分唯一索引 `ux_withdraw_order_user_active`(`user_id`),条件 `status IN ('reviewing', 'pending')`:每个用户同时只能有一笔在途(待审核 / 打款中)提现单,DB 层挡并发重复提现。 ## 注意 - **资金安全**:原子扣款(`WHERE cash_balance_cents >= amount`)+ `out_bill_no` 幂等 + 结果不明时**先查单再决定,绝不盲目退款**(防退款后又到账)+ 孤儿 pending 单 `reconcile_pending_withdraws` 对账兜底。 diff --git a/docs/数据库迁移.md b/docs/database/数据库迁移.md similarity index 98% rename from docs/数据库迁移.md rename to docs/database/数据库迁移.md index 599f313..9a9c6ce 100644 --- a/docs/数据库迁移.md +++ b/docs/database/数据库迁移.md @@ -6,7 +6,7 @@ > **相关文档**: > - 这份文档讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查) > - [postgres-migration.md](./postgres-migration.md) 讲 **从 SQLite 切到 PostgreSQL** 的完整步骤(本机安装/建库/`scripts/init_postgres.py` 用法) -> - 配合 [后端技术实现.md §10](./后端技术实现.md) 看待办与已知问题 +> - 配合 [后端技术实现.md §10](../后端技术实现.md) 看待办与已知问题 --- diff --git a/docs/待办与技术债.md b/docs/guides/待办与技术债.md similarity index 97% rename from docs/待办与技术债.md rename to docs/guides/待办与技术债.md index 14a3a7f..76a38d8 100644 --- a/docs/待办与技术债.md +++ b/docs/guides/待办与技术债.md @@ -40,7 +40,7 @@ ## 技术债(暂不处理,知情即可) - **agent 接口公网裸奔**:不鉴权 = 任何人可调,烧 pricebot 的 LLM 算力。与美团 3 接口现状同级,记账后随 P1 一起补。 -- **既有债(已记录在别处)**:美团 `referral-link` 的 `sid` 客户端可覆盖刷分佣;`logout` 占位不吊销 token;`SMS_MOCK=true` 全站免密登录;`JWT_SECRET_KEY` 默认值可伪造 token。详见 [后端技术实现.md](./后端技术实现.md) 与 [api/](./api/)。 +- **既有债(已记录在别处)**:美团 `referral-link` 的 `sid` 客户端可覆盖刷分佣;`logout` 占位不吊销 token;`SMS_MOCK=true` 全站免密登录;`JWT_SECRET_KEY` 默认值可伪造 token。详见 [后端技术实现.md](../后端技术实现.md) 与 [api/](./api/)。 --- @@ -87,7 +87,7 @@ ## 已解决 -- ✅ **短信验证码接入极光(real 模式,2026-06-03)**:`/sms/send`+`/sms/login` 从 mock 升级为真实可用。`integrations/sms.py` 走**自定义验证码模式**(本服务 `secrets` 生成 6 位码 + 进程内存存储 + 极光 `/v1/messages` 只负责发 + 本地校验一次性/防爆破);鉴权**复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`**(同一极光应用,**上线只需 `SMS_MOCK=false`**,无需额外凭证;`sign_id=31729`/`temp_id=1` 已审核就绪)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数上限);`SmsError.status_code` 让发送失败按类型返回 429/503/400。**mock 保留"任意6位通过"**(不动其他 11 个测试文件的 `_login` helper);`tests/test_auth.py` 加 real 模式单测(极光协议 / 一次性 / 防爆破 / 余额回滚)。文档 [integrations/sms.md](./integrations/sms.md)。**遗留**:验证码进程内存,多 worker 需迁 DB/Redis(下方技术债)。 +- ✅ **短信验证码接入极光(real 模式,2026-06-03)**:`/sms/send`+`/sms/login` 从 mock 升级为真实可用。`integrations/sms.py` 走**自定义验证码模式**(本服务 `secrets` 生成 6 位码 + 进程内存存储 + 极光 `/v1/messages` 只负责发 + 本地校验一次性/防爆破);鉴权**复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`**(同一极光应用,**上线只需 `SMS_MOCK=false`**,无需额外凭证;`sign_id=31729`/`temp_id=1` 已审核就绪)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数上限);`SmsError.status_code` 让发送失败按类型返回 429/503/400。**mock 保留"任意6位通过"**(不动其他 11 个测试文件的 `_login` helper);`tests/test_auth.py` 加 real 模式单测(极光协议 / 一次性 / 防爆破 / 余额回滚)。文档 [integrations/sms.md](../integrations/sms.md)。**遗留**:验证码进程内存,多 worker 需迁 DB/Redis(下方技术债)。 - ✅ **比价记录落库(server + client,2026-05-31)**:每次比价 done 后客户端上报、按 user 落库,作「我的比价记录」数据源 + 用户级画像沉淀。 - **server**(本仓):新表 `comparison_record`(独立于 `savings_record`;结构化列 + `items`/`comparison_results`/`skipped_dish_names`/`raw_payload` 四个 JSON(B) 列)+ 3 个**鉴权**端点 `POST /api/v1/compare/record`(`(user_id,trace_id)` 幂等 upsert,best/saved/is_source_best/status 服务端从 comparison_results 派生)/ `GET /api/v1/compare/records`(游标分页)/ `GET /records/{id}`(含 raw_payload);`models/comparison.py` + `repositories/comparison.py` + `schemas/compare_record.py` + `api/v1/compare_record.py` + 迁移 `comparison_record_table`(head `b2c3d4e5f6a7`)+ `tests/test_compare_record.py`(8 例全过)+ `docs/api/compare-record-*.md`。 - **client**(android 仓):`PriceBotService.runTask()` 比价 done 后(`lastDoneParams!=null`,成功/引擎失败都报)用独立 IO 协程尽力上报;`Protocol.CompareRecordRequest.fromComparison()` 从 calibration+done.params 零翻译组装;`ApiClient.reportCompareRecord()` 走新建的 `authedClient`(复用 `AuthInterceptor`+`RefreshAuthenticator`,自动 Bearer+401 刷新)。领券不报(非价格对比,本期范围只外卖)。 @@ -99,7 +99,7 @@ - ✅ **无障碍身份统一傻瓜比价**:服务名/图标继承 app;`accessibility_service_description`/前台通知/`GuideCopy`/引导 Activity 里的 "PriceBot" 全改「傻瓜比价」;app 图标换成完整 `logo1`(adaptive 居中+黄边,不裁切)。 - ✅ **GuideOverlayService 组件名 bug**:批量 sed 改包名误把无障碍服务 ComponentName 的 packageName 改成 `…shaguabijia.agent`(应是 applicationId `…shaguabijia`)→ 无障碍开启检测永远 false;改用 `PermissionHelper.isAccessibilityEnabled` 动态构造。 - ✅ **BuildConfig 包名**:`com.pricebot.app.BuildConfig` import 改本 app 包名。 -- ✅ **SQLite → PostgreSQL 切换**(2026-05-29,PR #6 + alembic 合并迁移):新增 `scripts/init_postgres.py` 一键脚本、`psycopg[binary]>=3.1` 依赖、`savings_record.dishes` 列改 JSONB、连接池参数(`pool_size=10/overflow=20/recycle=3600`)。SQLite 仍可作开发兜底(只改 `DATABASE_URL`)。详见 [postgres-migration.md](./postgres-migration.md)。 +- ✅ **SQLite → PostgreSQL 切换**(2026-05-29,PR #6 + alembic 合并迁移):新增 `scripts/init_postgres.py` 一键脚本、`psycopg[binary]>=3.1` 依赖、`savings_record.dishes` 列改 JSONB、连接池参数(`pool_size=10/overflow=20/recycle=3600`)。SQLite 仍可作开发兜底(只改 `DATABASE_URL`)。详见 [postgres-migration.md](../database/postgres-migration.md)。 - ✅ **外卖比价透传 2 端点**(2026-05-27,`feat(compare)`):`/api/v1/intent/recognize` + `/api/v1/price/step` 在 `compare.py`,纯 body 透传到 pricebot-backend,`PRICEBOT_COMPARE_TIMEOUT_SEC=60` 独立超时。电商 2 个待接(`compare.py` 加两行即可)。 - ✅ **用户资料 + 帮助反馈接口**(2026-05-28,PR #4):`PATCH /api/v1/user/profile` 改昵称、`POST /api/v1/user/avatar` 上传头像(魔数嗅探)、`DELETE /api/v1/user` 注销账号(软删+匿名化)、`POST /api/v1/feedback` 提交反馈(含截图);新建 `feedback` 表 + `core/media.py` + `/media` 静态服务。 - ✅ **美团 3 端点未配凭证降级**(2026-05-28,`feat(meituan)`):未配 `MT_CPS_APP_KEY` 时 `coupons`/`feed`/`referral-link` 直接返空(不再 502),解决新开发机首屏炸的问题。**坏副作用**:`/feed` 跟"已配但调用失败"路径在响应上无法区分(都是 200 + 空 items),靠 `/health` 的 `mt_cps_configured` 字段区分。 diff --git a/docs/看广告赚金币上线清单.md b/docs/guides/看广告赚金币上线清单.md similarity index 82% rename from docs/看广告赚金币上线清单.md rename to docs/guides/看广告赚金币上线清单.md index 3f0ccd7..2ef65a4 100644 --- a/docs/看广告赚金币上线清单.md +++ b/docs/guides/看广告赚金币上线清单.md @@ -9,7 +9,7 @@ 客户端**不发奖**,`onRewardArrived` 只触发"去后端刷余额"。客户端被破解也刷不到钱。 - 后端发奖**幂等**(按 `trans_id` 去重)+ **每日上限**(`DAILY_AD_REWARD_LIMIT`,按北京时间)。 - 关键常量:`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)。 +- **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)。 --- @@ -41,18 +41,31 @@ > 已确认走 **GroMore 广告位层级**回调(客户端 useMediation(true);规范 supportcenter/26240), > **不是**联盟代码位层级(5416)。验签算法 / 响应格式 / reward_amount 解析**代码已按 GroMore 规范实现** > (2026-05-27),剩下的是后台配置 + 填密钥。 +> +> 🔴 **2026-06-16 实测的头号阻塞:穿山甲打线上后端会超时**。真机看广告时 SDK 日志 +> `onRewardArrived valid=false errorCode=50002`,errorMsg = +> `Get "https://app-api.shaguabijia.com/api/v1/ad/pangle-callback...": context deadline exceeded`。 +> 而我们自己 curl 线上是秒回。说明**穿山甲机房到线上后端这条网络路径有问题**(疑似 nginx/云防火墙对 +> 机房/境外 IP 限流或拦截)。**m-key 配对了也救不了——穿山甲根本没把请求送达。** 上线前必须单独验 +> 「线上 `app-api.shaguabijia.com` 对穿山甲机房可达且响应够快」,否则用户看完发不了奖。 +> +> ✅ **多激励位已支持**(2026-06-16):内部测试期 测试位/专属位/正式位 并存,m-key 各配一行 +> (`PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD`),`verify_callback_sign_any` 逐个试。本地已验通。 - [ ] **GroMore 后台配回调**:GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」→ 回调 URL 填 `https://app-api.shaguabijia.com/api/v1/ad/pangle-callback`(用域名,别用 IP)。 ⚠️ 广告位层级配了就**别再在代码位层级重复配**(会导致发奖出问题)。 -- [ ] **拿 m-key(安全密钥)**:就在上面"编辑广告位"页获取,填到生产 `.env` 的 `PANGLE_REWARD_SECRET`。 +- [ ] **拿 m-key(安全密钥)**:就在上面"编辑广告位"页获取(每个激励位各一把),填到生产 `.env` 的命名变量 + (正式位填 `PANGLE_REWARD_SECRET_PROD`;内部测试期 测试位/专属位 也并存就填 `_TEST`/`_TEST_DEDICATED`)。 (注:这是 GroMore 广告位的 m-key,**不是**联盟代码位那个 Security Key。) - [x] ~~换验签~~:`app/integrations/pangle.py` 已实现 `sign = SHA256("{m-key}:{trans_id}")`(GroMore 真实算法)。 - [x] ~~响应格式~~:已返回 GroMore 要求的 `{"is_verify": bool, "reason": int}`。 - [x] ~~reward_amount~~:回调按 `reward_amount` 发金币(`rewards.resolve_ad_reward_coin`,带回退/夹紧); **后台广告位"奖励数量"须配成与 `AD_REWARD_COIN`(=100)一致**,保证"广告内展示/进度预告/到账"三者一致。 - [x] ~~透传 user_id~~:客户端已 `setUserID(userId)` + `setMediaExtra("uid:...")`。 -- [ ] 生产 `.env`:`PANGLE_CALLBACK_ENABLED=true` + `PANGLE_REWARD_SECRET=`(配齐才不返 503) +- [ ] 生产 `.env`:`PANGLE_CALLBACK_ENABLED=true` + 至少一个 m-key 命名变量(配齐才不返 503)+ 确认 + `AD_REWARD_TEST_GRANT_ENABLED=false`(绕过反作弊,生产红线)。改 `.env` 后**重启 uvicorn** + (watchfiles 不监听 .env)。验证:外部 curl 回调 URL 从 `503` 变 `403 bad sign` 即生效。 ## C. 部署 + 包名 diff --git a/docs/邀请功能-实现原理与本地测试.md b/docs/guides/邀请功能-实现原理与本地测试.md similarity index 100% rename from docs/邀请功能-实现原理与本地测试.md rename to docs/guides/邀请功能-实现原理与本地测试.md diff --git a/docs/integrations/meituan.md b/docs/integrations/meituan.md index e3259bf..ed4a05b 100644 --- a/docs/integrations/meituan.md +++ b/docs/integrations/meituan.md @@ -47,4 +47,4 @@ api 层 3 个端点都在入口处 `if not settings.mt_cps_configured:` 早返 ## 备注 - 美团接口当前**无鉴权**,且换链的 `sid` 允许客户端传值覆盖默认渠道——见对应 api 文档「备注」 -- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../待办与技术债.md) 已记账 +- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../guides/待办与技术债.md) 已记账 diff --git a/docs/integrations/pangle.md b/docs/integrations/pangle.md index 72837fd..7c6d825 100644 --- a/docs/integrations/pangle.md +++ b/docs/integrations/pangle.md @@ -16,12 +16,18 @@ | 函数 | 说明 | |---|---| | `build_sign(trans_id, secret) -> str` | 计算 `SHA256("{secret}:{trans_id}")` hex。自验签测试 / 模拟回调脚本共用,保证两端一致 | -| `verify_callback_sign(params, secret) -> bool` | 校验回调签名。密钥空 / 缺 `trans_id` 一律失败;比较用 `hmac.compare_digest` 定长比较防时序侧信道 | +| `verify_callback_sign(params, secret) -> bool` | 用**单个** m-key 校验回调签名。密钥空 / 缺 `trans_id` 一律失败;比较用 `hmac.compare_digest` 定长比较防时序侧信道 | +| `verify_callback_sign_any(params, secrets) -> bool` | 用**一组** m-key 逐个验签、任一通过即接受(多激励位共用同一回调 URL 时用)。空列表 → 失败 | + +## 配置(多激励位 = 多 m-key) +每个 GroMore 激励位的 m-key 由后台各自生成、互不相同,但本服务用**同一个回调 URL** 接所有位的回调,因此把用到的位的 m-key 都配上;验签时 `verify_callback_sign_any` 逐个试、任一过即接受(仍安全:伪造者须知道其中某个 m-key 才能造出合法 sign)。m-key 在后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取。`settings.pangle_reward_secrets` 把下列来源汇总去重: -## 配置 | 配置项 | 说明 | |---|---| -| `PANGLE_REWARD_SECRET` | m-key(安全密钥)。后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取 | +| `PANGLE_REWARD_SECRET_TEST` | 测试应用 激励位 104099649 的 m-key | +| `PANGLE_REWARD_SECRET_TEST_DEDICATED` | 测试应用 专属激励位 104127529 的 m-key | +| `PANGLE_REWARD_SECRET_PROD` | 正式应用 激励位 104099389 的 m-key | +| `PANGLE_REWARD_SECRET` | 旧用法:单个或逗号分隔多个 m-key,仍兼容(与上面三个命名项合并去重) | ## 踩坑 - **别用联盟代码位那套**:联盟代码位层级用的是另一套 Security Key + `isValid` 响应体,与 GroMore 广告位层级不通用。我们走 GroMore,别接错。 diff --git a/docs/integrations/sms.md b/docs/integrations/sms.md index 6e0b15b..7086643 100644 --- a/docs/integrations/sms.md +++ b/docs/integrations/sms.md @@ -56,4 +56,4 @@ 3. 真机发一条验证:收到短信 + 能登录 ## 已知局限 -**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../待办与技术债.md)。 +**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。 diff --git a/docs/后端技术实现.md b/docs/后端技术实现.md index 9d6eeff..e106985 100644 --- a/docs/后端技术实现.md +++ b/docs/后端技术实现.md @@ -36,7 +36,7 @@ | ASGI | uvicorn[standard] | 生产 `--host 127.0.0.1 --port 8770` | | ORM | SQLAlchemy 2.0(Mapped 风格) | | | 迁移 | Alembic | | -| DB | **PostgreSQL 16(生产)/ SQLite(开发兜底)** | 一键脚本 `scripts/init_postgres.py` 建库 + 写 `.env` + 跑迁移;开发环境改 `DATABASE_URL=sqlite:///./data/app.db` 即回退。无 Redis。详见 [docs/postgres-migration.md](./postgres-migration.md) | +| DB | **PostgreSQL 16(生产)/ SQLite(开发兜底)** | 一键脚本 `scripts/init_postgres.py` 建库 + 写 `.env` + 跑迁移;开发环境改 `DATABASE_URL=sqlite:///./data/app.db` 即回退。无 Redis。详见 [docs/database/postgres-migration.md](./database/postgres-migration.md) | | DB 驱动 | psycopg3(`psycopg[binary]>=3.1`) | `DATABASE_URL=postgresql+psycopg://...`,**不要装 psycopg2**——SQLAlchemy 2.0 时代默认 psycopg3 | | Auth | PyJWT(HS256) | access + refresh | | 极光 | httpx + cryptography | REST 验 token + RSA 解密手机号 | @@ -125,8 +125,8 @@ tests/ # pytest(auth / health / welfare / withdraw / ad_re run.sh # 本地启动脚本(自动先跑迁移再起服务) docs/api/ # API 接口文档(索引 README + 一接口一文件) docs/integrations/ # 集成层实现文档(SDK 签名/加解密/协议细节) -docs/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移) -docs/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py) +docs/database/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移) +docs/database/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py) ``` > **命名说明**:`api/v1/` 的 `v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部 SDK 集成、`repositories` 装数据访问、`core` 装基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。 @@ -222,6 +222,16 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert --- +## 6.5 pricebot 回写:内部端点(server→server) + +§6 是 app-server 透传给 pricebot;反过来 pricebot 也会把少量数据回写 app-server 落库,走 `app/api/internal/*` —— **不走用户 JWT**,靠共享密钥头 `X-Internal-Secret`(== `INTERNAL_API_SECRET`,未配则 503,防裸奔写端点)校验。 + +**启动确认窗兜底样本**(2026-06):国产 ROM 打开别的 App 时弹"想要打开 XX"确认窗,pricebot 对没见过文案(繁体/英文/ROM 改版)的窗用 LLM 兜底放行后,把样本 POST 到 [`/internal/launch-confirm-sample`](../app/api/internal/launch_confirm.py) 落 `launch_confirm_sample` 表(host 包 + 弹窗树 + LLM plan + 设备 locale/机型),供研发定期人工沉淀回 pricebot 的规则 yaml(回到快路径)。**需 pricebot 与 app-server 两边 `.env` 配同一 `INTERNAL_API_SECRET` 才生效**(未配则 pricebot 侧跳过上报、不影响比价/领券)。 + +> 同类内部回写端点还有 `/internal/price-observation`(价格观测)、`/internal/store-mapping`(跨平台店铺映射)等 pricebot 比价资产沉淀。 + +--- + ## 7. 数据模型 业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。 @@ -255,7 +265,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert `upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。 -**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./数据库迁移.md)。 +**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./database/数据库迁移.md)。 --- @@ -277,7 +287,7 @@ scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/ ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head && systemctl restart shaguabijia-app-server" ``` -完整 PG 切换流程见 [docs/postgres-migration.md](./postgres-migration.md)。 +完整 PG 切换流程见 [docs/database/postgres-migration.md](./database/postgres-migration.md)。 **生产 checklist(均为上线必查)**: @@ -316,11 +326,11 @@ conda activate price # 首次:pip install -e . | 美团接口无鉴权 + sid 可覆盖 | 评估加鉴权/锁定 sid(注意首页要求未登录可见) | | 美团接口未配凭证降级 | 未配 `MT_CPS_APP_KEY` 时 3 端点返空(不报 502),`/feed` 跟"已配但调用失败"路径无法区分——见 [integrations/meituan](./integrations/meituan.md) | | 领券/比价依赖 pricebot | `coupon/step` / `intent/recognize` / `price/step` 仅透传,真正逻辑在 pricebot-backend;前端已接通领券链路,比价 food MVP 也已接通 | -| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./待办与技术债.md) P1 | +| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./guides/待办与技术债.md) P1 | | SMS 已接极光(2026-06-03) | real 模式自定义验证码,上线只需 `SMS_MOCK=false`(复用极光凭证)。详见 [integrations/sms](./integrations/sms.md) | | 短信冷却存内存 | 扩 worker 前需迁移到 Redis | | `MEDIA_ROOT` 进程内 serve | 头像/反馈截图当前用 FastAPI StaticFiles,生产建议 nginx 直 serve 该目录 | -| `init_postgres.py` 已知小 bug | 5 条小坑,见 [待办与技术债.md](./待办与技术债.md) | +| `init_postgres.py` 已知小 bug | 5 条小坑,见 [待办与技术债.md](./guides/待办与技术债.md) | | Alembic 多 head 风险 | 跨分支并行开发要在 PR 合并前 `alembic heads` 检查只有一个;漏检会出现 "Multiple head revisions",需要 `alembic merge` 合并 | -> 完整接口协议见 [`docs/api/`](./api/)(38 个端点 + `/health` + `/media` 静态服务),以代码为准。 +> 完整接口协议见 [`docs/api/`](./api/)(70+ 接口,一接口一文件 + `/health` + `/media` 静态服务),以 `docs/api/README.md` 索引和代码为准。 diff --git a/scripts/pull_meituan_coupons.py b/scripts/pull_meituan_coupons.py index aaefdc2..3431cc8 100644 --- a/scripts/pull_meituan_coupons.py +++ b/scripts/pull_meituan_coupons.py @@ -1,45 +1,74 @@ -"""美团 CPS 券定时抓取入库(北京试点)。 +"""美团 CPS 券定时抓取入库(全国 359 个地级市)。 -把 3 路券抓进 meituan_coupon 表,供「销量/佣金排序」从库里捞、本地排序,不再实时打美团: +把每个城市的 3 路券抓进 meituan_coupon 表,供「智能推荐 / 销量最高」从库里捞、本地排序, +不再实时打美团: 1. search_waimai : 到家/外卖, 搜「外卖」 翻到尽头 2. search_meishi : 到家/外卖, 搜「美食」 翻到尽头 3. store_supply : 到店, 多业务线供给(到餐+到综+酒店+门票) 翻到尽头 +城市来自 app/integrations/data/meituan_cities.json(美团官方城市字典,359 地级市)。 +实测一个地级市 cityId 已覆盖其下辖县级市(徐州 → 邳州/新沂…),故按地级市抓即可。 + +并发:城市级并发(每城内部仍顺序跑 3 路),主线程串行入库(Session 不跨线程)。 +实测单城全量 ~110s/~2300 条;15 并发抓完全国一轮 ~50–60min,402 占比 ~3% 且退避全消化。 +mentor 要求每 3h 全量一轮,窗口充裕,故默认 12 并发 + 启动错峰,把瞬时峰值与 402 压更低。 + 按 (source, product_view_sign) upsert 存最新态;last_seen 每轮刷新。带文件锁,防止 -上一轮没跑完下一轮又起(本地 5~10min、跨进程 cron 都安全)。 +上一轮没跑完下一轮又起。 用法: - # 单轮(打通验证 / 给 cron 用,线上每 1h 一次) + # 单轮全量(给 cron 用,线上每 3h 一次) python -m scripts.pull_meituan_coupons --once - # 本地循环(默认每 10min 一轮) - python -m scripts.pull_meituan_coupons --loop --interval 600 + # 常驻循环(每 3h 一轮) + python -m scripts.pull_meituan_coupons --loop --interval 10800 + + # 本地测试:只抓前 N 个城市 / 指定城市 + python -m scripts.pull_meituan_coupons --once --limit 3 + python -m scripts.pull_meituan_coupons --once --city-ids OCZOBCJDEXKE7KBN3BD7AYQG2Q + +部署(服务器):推荐 cron 跑 --once(每 3h),避免长驻进程孤儿: + 0 */3 * * * cd /path/to/app-server && .venv/bin/python -m scripts.pull_meituan_coupons --once >> data/etl.log 2>&1 """ from __future__ import annotations import argparse import hashlib +import logging import os import re import sys import tempfile +import threading import time +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta, timezone -# Windows 控制台按 UTF-8 输出中文/¥ +# Windows 控制台按 UTF-8 输出中文/¥;line_buffering=True 让 print 每行即时 flush—— +# 否则 stdout 重定向到 cron/后台日志文件时是块缓冲,要攒到 ~4KB 或进程退出才落盘, +# 常驻(--loop)时几乎看不到每轮进度。 try: - sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + sys.stdout.reconfigure(encoding="utf-8", line_buffering=True) # type: ignore[attr-defined] except Exception: # noqa: BLE001 pass -from sqlalchemy import delete, func, select -from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy import delete, func, select # noqa: E402 +from sqlalchemy.dialects.postgresql import insert as pg_insert # noqa: E402 -from app.db.session import SessionLocal -from app.integrations.meituan import MeituanCpsError, _call -from app.models.meituan_coupon import MeituanCoupon +from app.db.session import SessionLocal, engine # noqa: E402 +from app.integrations.cities import all_cities # noqa: E402 +from app.integrations.meituan import MeituanCpsError, _call # noqa: E402 +from app.models.meituan_coupon import MeituanCoupon # noqa: E402 + +# dev 默认 create_engine(echo=True) 会逐条打 SQL,本脚本逐城 upsert 会把日志刷爆; +# ETL 不需要 SQL 日志,显式关掉(入库不受影响;线上 prod 本就 echo=False)。 +engine.echo = False + +# 美团调用偶发错误(本机走代理高并发时的 SSL EOF / 上游 code=5 等)会被 meituan._call 的 +# logger.exception 打完整 traceback,并发抓取下单轮可刷数十 KB 日志。ETL 自己用 _STATS 统计 +# 「放弃页数」,无需逐条 traceback,故把美团 logger 压到 CRITICAL(线上直连少见此类错误)。 +logging.getLogger("shagua.meituan").setLevel(logging.CRITICAL) -CITY_BEIJING = "WKV2HMXUEK634WP64CUCUQGM64" QUERY_PATH = "/cps_open/common/api/v1/query_coupon" PAGE_SIZE = 20 MAX_PAGES = 80 # 单路安全上限(搜索 ~52 页、供给 ~70 页) @@ -49,7 +78,12 @@ RETRY = 7 # 无 sudo 部署时 data/ 常属 root,cps 写不了会导致每轮 PermissionError、cron 抓不进数据。 # 需要指定位置时用环境变量 MEITUAN_ETL_LOCK 覆盖。 LOCK_FILE = os.environ.get("MEITUAN_ETL_LOCK") or os.path.join(tempfile.gettempdir(), "meituan_etl.lock") -LOCK_STALE_SEC = 30 * 60 # 锁超过 30min 视为陈旧(进程异常退出残留),自动接管 +# 多城全量一轮 ~50–60min,锁陈旧阈值放宽到 90min,避免把「正在跑的轮次」误判为残留而接管。 +LOCK_STALE_SEC = 90 * 60 + +DEFAULT_CONCURRENCY = 12 # 并发城市数(实测 15 并发 402 占 3% 可退避消化;12 更稳,3h 窗口充裕) +STARTUP_STAGGER = 0.3 # 首批城市启动错峰间隔秒(削平瞬时峰值,实测能压低 402) +PRUNE_FAIL_RATIO_MAX = 0.05 # 失败城占比超此值则本轮跳过 prune(避免大面积抓取失败误删库) SOURCES = [ {"code": "search_waimai", "label": "外卖·搜外卖", "kind": "search", "platform": 1, "keyword": "外卖"}, @@ -58,33 +92,44 @@ SOURCES = [ "platform": 2, "biz_lines": [1, 2, 3, 4]}, ] +# 跨线程统计(并发抓取下汇总请求量 / 402 / 放弃页数,供观测限流) +_STATS_LOCK = threading.Lock() +_STATS = {"req": 0, "r402": 0, "err": 0} + + +def _bump(key: str) -> None: + with _STATS_LOCK: + _STATS[key] += 1 + # ───────────────────────── 美团调用 ───────────────────────── def _call_retry(body: dict) -> dict | None: - """打美团,402/频繁退避重试;其它错误打印并放弃本页。""" + """打美团,402/频繁退避重试;其它错误放弃本页。并发下不逐条 print(避免刷屏),计入 _STATS。""" for a in range(RETRY): + _bump("req") try: return _call(QUERY_PATH, body) except MeituanCpsError as e: msg = str(e) if "402" in msg or "频繁" in msg: + _bump("r402") time.sleep(2.5 * (a + 1)) continue - print(f" [warn] meituan: {msg[:80]}") + _bump("err") return None - except Exception as e: # noqa: BLE001 - print(f" [warn] {type(e).__name__}: {str(e)[:60]}") + except Exception: # noqa: BLE001 time.sleep(2.0 * (a + 1)) + _bump("err") return None -def _pull_search(platform: int, keyword: str) -> list[dict]: +def _pull_search(city_id: str, platform: int, keyword: str) -> list[dict]: rows: list[dict] = [] sid = None pg = 1 while pg <= MAX_PAGES: - body = {"platform": platform, "searchText": keyword, "cityId": CITY_BEIJING, "pageSize": PAGE_SIZE} + body = {"platform": platform, "searchText": keyword, "cityId": city_id, "pageSize": PAGE_SIZE} if sid: body["searchId"] = sid else: @@ -102,14 +147,14 @@ def _pull_search(platform: int, keyword: str) -> list[dict]: return rows -def _pull_supply(platform: int, biz_lines: list[int]) -> list[dict]: +def _pull_supply(city_id: str, platform: int, biz_lines: list[int]) -> list[dict]: rows: list[dict] = [] sid = None biz_param = [{"bizLine": b} for b in biz_lines] for _ in range(MAX_PAGES): body = { "multipleSupplyList": [{"platform": platform, "bizLineParamList": biz_param}], - "cityId": CITY_BEIJING, + "cityId": city_id, "sortField": 2, # 供给查询 sortField 必填;我们入库后本地再排,这里给个默认 "pageSize": PAGE_SIZE, } @@ -157,7 +202,19 @@ def _to_cents(yuan) -> int | None: return None -def _parse_item(item: dict, source: dict) -> dict | None: +def _strip_nul(v): + """递归去掉字符串里的 NUL(0x00):PostgreSQL 的 text / jsonb 字段都不接受该字节, + 美团偶有脏数据(某券文本含 NUL)会让整批 upsert 抛 DataError。入库前统一清洗。""" + if isinstance(v, str): + return v.replace(chr(0), "") + if isinstance(v, dict): + return {k: _strip_nul(x) for k, x in v.items()} + if isinstance(v, list): + return [_strip_nul(x) for x in v] + return v + + +def _parse_item(item: dict, source: dict, city_id: str) -> dict | None: cpd = item.get("couponPackDetail") or {} br = item.get("brandInfo") or {} ci = item.get("commissionInfo") or {} @@ -186,11 +243,12 @@ def _parse_item(item: dict, source: dict) -> dict | None: dist = None dedup_raw = f"{brand}|{name}|{price_cents}" - return { + # 入库前递归清洗 NUL(美团脏数据偶含 0x00,PostgreSQL text/jsonb 拒绝整批 → DataError) + return _strip_nul({ "source": source["code"], "platform": source["platform"], "biz_line": item.get("bizLine") or cpd.get("bizLine"), - "city_id": CITY_BEIJING, + "city_id": city_id, "product_view_sign": str(sign)[:128], "sku_view_id": cpd.get("skuViewId"), "name": (name[:256] or None), @@ -207,7 +265,28 @@ def _parse_item(item: dict, source: dict) -> dict | None: "delivery_distance_m": dist, "dedup_key": hashlib.md5(dedup_raw.encode("utf-8")).hexdigest(), "raw": item, - } + }) + + +def _pull_one_city(city: dict, index: int, concurrency: int, stagger: float) -> list[dict]: + """抓单个城市的 3 路券并解析。worker 线程内执行(只抓取+解析,不碰 DB)。 + + 启动错峰:首批并发的 `concurrency` 个城市按 index 错开首请求,削平瞬时峰值(压低 402)。 + """ + if stagger: + time.sleep((index % concurrency) * stagger) + cid = city["city_id"] + parsed: list[dict] = [] + for src in SOURCES: + if src["kind"] == "search": + items = _pull_search(cid, src["platform"], src["keyword"]) + else: + items = _pull_supply(cid, src["platform"], src["biz_lines"]) + for it in items: + p = _parse_item(it, src, cid) + if p: + parsed.append(p) + return parsed # ───────────────────────── 入库(upsert) ───────────────────────── @@ -272,30 +351,59 @@ def _release_lock() -> None: # ───────────────────────── 主流程 ───────────────────────── -def run_once(prune_hours: int = 24) -> None: +def run_once( + prune_hours: int = 24, + concurrency: int = DEFAULT_CONCURRENCY, + stagger: float = STARTUP_STAGGER, + cities: list[dict] | None = None, +) -> None: if not _acquire_lock(): print(f"[{datetime.now():%H:%M:%S}] 上一轮还在跑(锁占用),跳过本轮") return t0 = time.time() now = datetime.now(timezone.utc) + with _STATS_LOCK: + _STATS.update(req=0, r402=0, err=0) + cities = cities if cities is not None else all_cities() + n_city = len(cities) db = SessionLocal() try: - total = 0 - for src in SOURCES: - ts = time.time() - if src["kind"] == "search": - items = _pull_search(src["platform"], src["keyword"]) - else: - items = _pull_supply(src["platform"], src["biz_lines"]) - parsed = [p for p in (_parse_item(it, src) for it in items) if p] - up, dup = _upsert(db, parsed, now) - total += up - print(f" {src['label']:18} 抓{len(items):5} 解析{len(parsed):5} " - f"入库{up:5} (本源去重{dup:3}) {time.time() - ts:4.0f}s") - # 清理长期未再出现的陈旧券(美团 sign 轮换 / 券下架后的残留),默认 24h 宽限。 - # 护栏:仅在本轮确有入库(total>0)时才清理。美团整体故障时本轮可能 0 入库 - # (脚本不抛异常、只是抓回空),若仍照常 prune,连续故障会按 last_seen 把全表删空。 - if prune_hours and prune_hours > 0 and total > 0: + total_up = 0 + done = 0 + fails: list[str] = [] + # 城市级并发抓取(worker 只抓取+解析),主线程逐城串行入库(Session 不跨线程)。 + with ThreadPoolExecutor(max_workers=concurrency) as pool: + futs = { + pool.submit(_pull_one_city, city, i, concurrency, stagger): city + for i, city in enumerate(cities) + } + for fut in as_completed(futs): + city = futs[fut] + try: + parsed = fut.result() + except Exception as e: # noqa: BLE001 + fails.append(city["name"]) + print(f" [fail] {city['name']}: {type(e).__name__}: {str(e)[:60]}") + continue + try: + up, _dup = _upsert(db, parsed, now) + except Exception as e: # noqa: BLE001 + db.rollback() # 事务已 abort,必须 rollback 才能继续给下一城复用 Session + fails.append(city["name"]) + print(f" [入库失败] {city['name']}: {type(e).__name__}: {str(e)[:80]}") + continue + total_up += up + done += 1 + if done % 20 == 0 or done == n_city: + print(f" [{done}/{n_city}] {city['name']:10} 抓{len(parsed):5} 入库{up:5} " + f"(累计入库 {total_up}, 用时 {time.time() - t0:.0f}s)") + + # 清理陈旧券(美团 sign 轮换 / 券下架残留),默认 24h 宽限。两道护栏防误删: + # ① total_up>0:本轮 0 入库(疑似上游整体故障,脚本不抛异常只抓回空)时跳过,否则 + # 会按 last_seen 把全表删空; + # ② 失败城占比 ≤5%:大面积城市抓取失败(限流/网络)时跳过,避免误删还在架上的券。 + fail_ratio = len(fails) / max(1, n_city) + if prune_hours and prune_hours > 0 and total_up > 0 and fail_ratio <= PRUNE_FAIL_RATIO_MAX: cutoff = now - timedelta(hours=prune_hours) pruned = db.execute( delete(MeituanCoupon).where(MeituanCoupon.last_seen < cutoff) @@ -303,36 +411,62 @@ def run_once(prune_hours: int = 24) -> None: db.commit() if pruned: print(f" 清理陈旧券(>{prune_hours}h 未再出现): {pruned} 条") - elif prune_hours and prune_hours > 0 and total == 0: - print(" 本轮 0 入库(疑似上游故障),跳过清理以防误删全表") + elif prune_hours and prune_hours > 0: + reason = "本轮 0 入库" if total_up == 0 else f"失败城占比 {fail_ratio:.0%}>{PRUNE_FAIL_RATIO_MAX:.0%}" + print(f" 跳过 prune({reason},防误删)") + cnt = db.execute(select(func.count()).select_from(MeituanCoupon)).scalar() - print(f"[{datetime.now():%H:%M:%S}] 本轮完成: 入库 {total} 条, 表总计 {cnt} 行, " - f"用时 {time.time() - t0:.0f}s") + with _STATS_LOCK: + req, r402, err = _STATS["req"], _STATS["r402"], _STATS["err"] + fail_tail = (": " + ",".join(fails[:10])) if fails else "" + print(f"[{datetime.now():%H:%M:%S}] 本轮完成: {done}/{n_city} 城, 入库 {total_up} 条, " + f"表总计 {cnt} 行, 请求 {req}(402 {r402} / 放弃 {err}), " + f"失败城 {len(fails)}{fail_tail}, 用时 {time.time() - t0:.0f}s") finally: db.close() _release_lock() +def _select_cities(limit: int, city_ids: str) -> list[dict]: + cities = all_cities() + if city_ids.strip(): + want = {c.strip() for c in city_ids.split(",") if c.strip()} + return [c for c in cities if c["city_id"] in want] + if limit and limit > 0: + return cities[:limit] + return cities + + def main() -> None: - ap = argparse.ArgumentParser(description="美团 CPS 券定时抓取入库") + ap = argparse.ArgumentParser(description="美团 CPS 券定时抓取入库(全国 359 地级市)") ap.add_argument("--once", action="store_true", help="只跑一轮(默认)") ap.add_argument("--loop", action="store_true", help="循环跑") - ap.add_argument("--interval", type=int, default=600, help="循环间隔秒(默认 600=10min)") + ap.add_argument("--interval", type=int, default=10800, + help="循环间隔秒(默认 10800=3h,对齐每 3h 全量一轮)") ap.add_argument("--prune-hours", type=int, default=24, help="清理超过 N 小时未再出现的陈旧券(默认 24;0=不清理)") + ap.add_argument("--concurrency", type=int, default=DEFAULT_CONCURRENCY, + help=f"并发城市数(默认 {DEFAULT_CONCURRENCY};实测 15 并发 402 占 3%% 可退避消化)") + ap.add_argument("--stagger", type=float, default=STARTUP_STAGGER, + help=f"首批城市启动错峰间隔秒(默认 {STARTUP_STAGGER};削平瞬时峰值压低 402)") + ap.add_argument("--limit", type=int, default=0, help="只抓前 N 个城市(测试用,0=全部)") + ap.add_argument("--city-ids", default="", help="只抓这些 cityId(逗号分隔,测试用,优先于 --limit)") args = ap.parse_args() + cities = _select_cities(args.limit, args.city_ids) + print(f"城市数: {len(cities)} / 并发: {args.concurrency} / 错峰: {args.stagger}s / 间隔: {args.interval}s") + if args.loop: print(f"循环模式: 每 {args.interval}s 一轮 (Ctrl-C 退出)") while True: try: - run_once(args.prune_hours) + run_once(args.prune_hours, args.concurrency, args.stagger, cities) except Exception as e: # noqa: BLE001 print(f"[{datetime.now():%H:%M:%S}] 本轮异常: {type(e).__name__}: {e}") _release_lock() time.sleep(args.interval) else: - run_once(args.prune_hours) + run_once(args.prune_hours, args.concurrency, args.stagger, cities) if __name__ == "__main__": diff --git a/tests/test_admin.py b/tests/test_admin.py index dea292f..7932a7f 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -125,7 +125,7 @@ def test_long_password_does_not_crash(admin_client: TestClient) -> None: def test_audit_log_pagination_no_gap() -> None: - """审计游标分页跨页不丢/不重(回归 next_cursor off-by-one)。""" + """审计分页跨页不丢/不重(offset 分页:cursor 即 offset,翻完覆盖全部)。""" from app.admin.repositories import admin_user as admin_repo from app.admin.repositories import audit_log as audit_repo @@ -142,12 +142,13 @@ def test_audit_log_pagination_no_gap() -> None: ) created_ids.append(log.id) - # limit=2 翻 5 条,收集所有 id,应正好覆盖创建的 5 条(无丢无重) + # limit=2 翻 5 条,收集所有 id,应正好覆盖创建的 5 条(无丢无重);total 恒为符合条件总数 seen: list[int] = [] cursor = None for _ in range(10): # 上限防死循环 - rows, cursor = audit_repo.list_audit_logs(db, action=action, limit=2, cursor=cursor) + rows, cursor, total = audit_repo.list_audit_logs(db, action=action, limit=2, cursor=cursor) seen.extend(r.id for r in rows) + assert total == len(created_ids), f"total 应为 {len(created_ids)},得 {total}" if cursor is None: break assert sorted(seen) == sorted(created_ids), f"分页丢/重: want={created_ids} got={seen}" diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py index 12797bc..13c0e29 100644 --- a/tests/test_admin_config.py +++ b/tests/test_admin_config.py @@ -70,8 +70,7 @@ def test_list_config(admin_client: TestClient, token: str) -> None: 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"] == [ - 200, 120, 150, 180, 200, 250, 500, - 200, 250, 300, 350, 400, 500, 3000, + 200, 200, 300, 200, 400, 400, 800, ] assert items["signin_rewards"]["overridden"] is False @@ -79,7 +78,7 @@ def test_list_config(admin_client: TestClient, token: str) -> None: 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, 800, 900, 1000, 1100, 1200, 1300, 1400]}, + json={"value": [100, 200, 300, 400, 500, 600, 700]}, headers=_auth(token), ) assert r.status_code == 200, r.text @@ -118,8 +117,42 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N db.close() +def test_update_bool_config(admin_client: TestClient, token: str) -> None: + # 提现自动对账开关默认 True + items = { + i["key"]: i + for i in admin_client.get("/admin/api/config", headers=_auth(token)).json() + } + assert items["withdraw_auto_reconcile_enabled"]["type"] == "bool" + assert items["withdraw_auto_reconcile_enabled"]["value"] is True + + # 关掉 → DB 落 False、业务读到 False + r = admin_client.patch( + "/admin/api/config/withdraw_auto_reconcile_enabled", + json={"value": False}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + assert r.json()["value"] is False and r.json()["overridden"] is True + + db = SessionLocal() + try: + from app.repositories import app_config + + assert app_config.get_value(db, "withdraw_auto_reconcile_enabled") is False + finally: + db.close() + + # bool 项不接受非布尔值 + assert admin_client.patch( + "/admin/api/config/withdraw_auto_reconcile_enabled", + json={"value": 1}, + headers=_auth(token), + ).status_code == 400 + + def test_config_validation(admin_client: TestClient, token: str) -> None: - # 签到档位长度≠14 + # 签到档位长度≠7 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_admin_read.py b/tests/test_admin_read.py index 4111d50..90005ad 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -91,6 +91,80 @@ def test_user_filter_by_status(admin_client: TestClient, admin_token: str) -> No assert all(u["status"] == "active" for u in r.json()["items"]) +def test_user_list_sort_filter_range(admin_client: TestClient, admin_token: str) -> None: + """用户列表:渠道/昵称筛选 + id 升降序 + 时间范围 + 非法 sort_by 422。""" + db = SessionLocal() + try: + u1 = user_repo.upsert_user_for_login(db, phone="13811110001", register_channel="sms") + u1.nickname = "排序测试甲" + u2 = user_repo.upsert_user_for_login(db, phone="13811110002", register_channel="wechat") + u2.nickname = "排序测试乙" + db.commit() + id1, id2 = u1.id, u2.id + finally: + db.close() + + # 渠道精确筛选 + r = admin_client.get( + "/admin/api/users", params={"register_channel": "wechat"}, headers=_auth(admin_token) + ) + assert r.status_code == 200, r.text + assert all(u["register_channel"] == "wechat" for u in r.json()["items"]) + + # 昵称模糊筛选 + r = admin_client.get( + "/admin/api/users", params={"nickname": "排序测试", "limit": 100}, headers=_auth(admin_token) + ) + ids = {u["id"] for u in r.json()["items"]} + assert id1 in ids and id2 in ids + + # id 升序 / 降序 + asc_ids = [ + u["id"] + for u in admin_client.get( + "/admin/api/users", + params={"sort_by": "id", "sort_order": "asc", "limit": 100}, + headers=_auth(admin_token), + ).json()["items"] + ] + assert asc_ids == sorted(asc_ids) + desc_ids = [ + u["id"] + for u in admin_client.get( + "/admin/api/users", + params={"sort_by": "id", "sort_order": "desc", "limit": 100}, + headers=_auth(admin_token), + ).json()["items"] + ] + assert desc_ids == sorted(desc_ids, reverse=True) + + # 时间范围:2000 年之前无人;之后有人(验证范围条件确实生效) + assert ( + admin_client.get( + "/admin/api/users", params={"created_to": "2000-01-01T00:00:00Z"}, + headers=_auth(admin_token), + ).json()["items"] + == [] + ) + assert ( + len( + admin_client.get( + "/admin/api/users", params={"created_from": "2000-01-01T00:00:00Z", "limit": 100}, + headers=_auth(admin_token), + ).json()["items"] + ) + >= 1 + ) + + # 非法 sort_by → 422 + assert ( + admin_client.get( + "/admin/api/users", params={"sort_by": "phone"}, headers=_auth(admin_token) + ).status_code + == 422 + ) + + def test_wallet_and_withdraw_lists(admin_client: TestClient, admin_token: str) -> None: uid = _seed_user_with_data("13800000004") r = admin_client.get( @@ -112,6 +186,65 @@ def test_feedback_list(admin_client: TestClient, admin_token: str) -> None: assert all(f["status"] == "new" for f in r.json()["items"]) +def test_ad_coin_audit_full_count_truncate_and_only_mismatch( + admin_client: TestClient, admin_token: str +) -> None: + """A+B:total/mismatch_count 按全量统计(不受 limit 影响),truncated 旗标 + only_mismatch 过滤。 + + 用 capped 行造确定性数据(应发恒 0,coin==0 即一致),不依赖发奖公式: + 3 条 coin=0(一致) + 2 条 coin=7(不一致) → 全量 total=5、mismatch=2。 + """ + from app.models.ad_reward import AdRewardRecord + + d = "2020-01-15" # 固定历史日 + 独立 user,隔离其它用例数据 + db = SessionLocal() + try: + uid = user_repo.upsert_user_for_login(db, phone="13800009999", register_channel="sms").id + for i in range(3): + db.add(AdRewardRecord( + trans_id=f"adaudit-ok-{i}", user_id=uid, coin=0, status="capped", + reward_scene="reward_video", reward_date=d, + )) + for i in range(2): + db.add(AdRewardRecord( + trans_id=f"adaudit-bad-{i}", user_id=uid, coin=7, status="capped", + reward_scene="reward_video", reward_date=d, + )) + db.commit() + finally: + db.close() + + base = {"date": d, "user_id": uid} + + # 全量:total=5、mismatch=2、不截断、返回 5 条 + body = admin_client.get( + "/admin/api/ad-coin-audit", params={**base, "limit": 100}, headers=_auth(admin_token) + ).json() + assert body["total"] == 5 + assert body["mismatch_count"] == 2 + assert body["truncated"] is False + assert len(body["items"]) == 5 + + # 截断:limit=2 → 统计仍全量、truncated=True、只回 2 条 + body = admin_client.get( + "/admin/api/ad-coin-audit", params={**base, "limit": 2}, headers=_auth(admin_token) + ).json() + assert body["total"] == 5 and body["mismatch_count"] == 2 + assert body["truncated"] is True + assert len(body["items"]) == 2 + + # only_mismatch:只回 ✗ 行(2 条),统计仍全量、不截断 + body = admin_client.get( + "/admin/api/ad-coin-audit", + params={**base, "limit": 100, "only_mismatch": True}, + headers=_auth(admin_token), + ).json() + assert body["total"] == 5 and body["mismatch_count"] == 2 + assert body["truncated"] is False + assert len(body["items"]) == 2 + assert all(it["matched"] is False for it in body["items"]) + + def test_read_apis_require_auth(admin_client: TestClient) -> None: """所有 M2 读接口未带 token → 401(router 级 get_current_admin 守卫)。""" for path in [ diff --git a/tests/test_admin_write.py b/tests/test_admin_write.py index 67bc719..bd8cd8e 100644 --- a/tests/test_admin_write.py +++ b/tests/test_admin_write.py @@ -136,6 +136,102 @@ def test_grant_zero_rejected(admin_client: TestClient, finance_token: str) -> No assert r.status_code == 400 +def test_set_coins_writes_delta_txn(admin_client: TestClient, finance_token: str) -> None: + """set 模式:先有余额,再设为目标值,只写一笔差值流水,审计带 mode/target/before。""" + uid = _seed_user("13900000009") + # 先增到 300 + admin_client.post( + f"/admin/api/users/{uid}/coins", json={"amount": 300, "reason": "底"}, + headers=_auth(finance_token), + ) + # 设为 100 → 差值 -200(扣减) + r = admin_client.post( + f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": 100, "reason": "设值"}, + headers=_auth(finance_token), + ) + assert r.status_code == 200, r.text + db = SessionLocal() + try: + assert db.get(CoinAccount, uid).coin_balance == 100 + txns = db.execute( + select(CoinTransaction).where(CoinTransaction.user_id == uid) + .order_by(CoinTransaction.id.desc()) + ).scalars().all() + # 两笔:+300(admin_grant)、-200(admin_deduct) + assert txns[0].amount == -200 and txns[0].biz_type == "admin_deduct" + logs = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "user.coins.grant", AdminAuditLog.target_id == str(uid) + ).order_by(AdminAuditLog.id.desc()) + ).scalars().all() + assert logs[0].detail["mode"] == "set" + assert logs[0].detail["target"] == 100 + assert logs[0].detail["before"] == 300 + assert logs[0].detail["amount"] == -200 + finally: + db.close() + + +def test_set_coins_equal_balance_rejected(admin_client: TestClient, finance_token: str) -> None: + """set 为当前余额(差值 0)→ 拒绝,不写流水。""" + uid = _seed_user("13900000010") + admin_client.post( + f"/admin/api/users/{uid}/coins", json={"amount": 50, "reason": "底"}, + headers=_auth(finance_token), + ) + r = admin_client.post( + f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": 50, "reason": "x"}, + headers=_auth(finance_token), + ) + assert r.status_code == 400 + db = SessionLocal() + try: + txns = db.execute( + select(CoinTransaction).where(CoinTransaction.user_id == uid) + ).scalars().all() + assert len(txns) == 1 # 仅初始那笔,设值被拒后无新流水 + finally: + db.close() + + +def test_set_coins_negative_target_rejected(admin_client: TestClient, finance_token: str) -> None: + uid = _seed_user("13900000011") + r = admin_client.post( + f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": -1, "reason": "x"}, + headers=_auth(finance_token), + ) + assert r.status_code == 400 + + +def test_set_cash_writes_delta_txn(admin_client: TestClient, finance_token: str) -> None: + """现金 set 模式:设为目标分值,写一笔差值流水。""" + uid = _seed_user("13900000012") + admin_client.post( + f"/admin/api/users/{uid}/cash", json={"amount_cents": 500, "reason": "底"}, + headers=_auth(finance_token), + ) + r = admin_client.post( + f"/admin/api/users/{uid}/cash", + json={"mode": "set", "amount_cents": 200, "reason": "设值"}, + headers=_auth(finance_token), + ) + assert r.status_code == 200, r.text + db = SessionLocal() + try: + assert db.get(CoinAccount, uid).cash_balance_cents == 200 + logs = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "user.cash.grant", AdminAuditLog.target_id == str(uid) + ).order_by(AdminAuditLog.id.desc()) + ).scalars().all() + assert logs[0].detail["mode"] == "set" + assert logs[0].detail["target_cents"] == 200 + assert logs[0].detail["before_cents"] == 500 + assert logs[0].detail["amount_cents"] == -300 + finally: + db.close() + + # ===== 封号 ===== def test_set_user_status_and_audit(admin_client: TestClient, operator_token: str) -> None: diff --git a/tests/test_app_version.py b/tests/test_app_version.py new file mode 100644 index 0000000..53d2ebf --- /dev/null +++ b/tests/test_app_version.py @@ -0,0 +1,95 @@ +"""OTA 检查更新端点测试。 + +覆盖:公开读端点未配置时返回默认(latest_version_code=0=无更新)、内部写端点鉴权 +(503 未配 / 401 头错 / 200)、写后能读到同一组值、写入参数校验(422)。 +""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.db.session import SessionLocal +from app.models.app_config import AppConfig +from app.repositories.app_config import APP_VERSION_KEY + +_SECRET = "test-internal-secret-only-for-pytest" + +# 合法 body(够过 pydantic 校验,用于鉴权用例) +_VALID = { + "latest_version_code": 31, + "latest_version_name": "0.1.6", + "apk_url": "https://app-api.shaguabijia.com/media/releases/shaguabijia-v0.1.6-build31.apk", +} + + +@pytest.fixture() +def clean_version(): + """每个用例前后清掉 latest_app_version 行(表由 conftest 建好,session 级共享)。""" + def _clear(): + s = SessionLocal() + s.query(AppConfig).filter(AppConfig.key == APP_VERSION_KEY).delete() + s.commit() + s.close() + _clear() + yield + _clear() + + +def test_read_default_when_unset(client: TestClient, clean_version) -> None: + """未配置任何版本时,公开读端点返回 latest_version_code=0(客户端视为已是最新);不鉴权。""" + r = client.get("/api/v1/platform/app-version") + assert r.status_code == 200, r.text + assert r.json()["latest_version_code"] == 0 + + +def test_write_secret_unset_503(client: TestClient, clean_version, monkeypatch) -> None: + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", "") # 未配 = 写端点关闭 + r = client.post("/internal/app-version", json=_VALID) + assert r.status_code == 503 + + +def test_write_auth(client: TestClient, clean_version, monkeypatch) -> None: + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) + assert client.post("/internal/app-version", json=_VALID).status_code == 401 + assert client.post( + "/internal/app-version", json=_VALID, headers={"X-Internal-Secret": "wrong"} + ).status_code == 401 + r = client.post("/internal/app-version", json=_VALID, headers={"X-Internal-Secret": _SECRET}) + assert r.status_code == 200, r.text + + +def test_write_then_read(client: TestClient, clean_version, monkeypatch) -> None: + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) + payload = { + "latest_version_code": 99, + "latest_version_name": "9.9.9", + "apk_url": "https://app-api.shaguabijia.com/media/releases/shaguabijia-v9.9.9-build99.apk", + "update_note": "测试更新说明", + "min_supported_version_code": 50, + "apk_size_bytes": 12345678, + "apk_sha256": "abc123", + } + w = client.post("/internal/app-version", json=payload, headers={"X-Internal-Secret": _SECRET}) + assert w.status_code == 200, w.text + assert w.json()["latest_version_code"] == 99 + + body = client.get("/api/v1/platform/app-version").json() + assert body["latest_version_code"] == 99 + assert body["latest_version_name"] == "9.9.9" + assert body["apk_url"] == payload["apk_url"] + assert body["update_note"] == "测试更新说明" + assert body["min_supported_version_code"] == 50 + assert body["apk_size_bytes"] == 12345678 + assert body["apk_sha256"] == "abc123" + + +def test_write_rejects_invalid(client: TestClient, clean_version, monkeypatch) -> None: + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) + h = {"X-Internal-Secret": _SECRET} + # latest_version_code 必须 > 0 + bad_code = {"latest_version_code": 0, "latest_version_name": "x", "apk_url": "u"} + assert client.post("/internal/app-version", json=bad_code, headers=h).status_code == 422 + # apk_url 不能空 + bad_url = {"latest_version_code": 5, "latest_version_name": "x", "apk_url": ""} + assert client.post("/internal/app-version", json=bad_url, headers=h).status_code == 422 diff --git a/tests/test_auth.py b/tests/test_auth.py index 3aca97e..f9a6be1 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -206,3 +206,40 @@ def test_sms_gc_purges_stale_only(monkeypatch) -> None: assert "stale" not in sms._codes and "fresh" in sms._codes assert "old" not in sms._last_sent and "recent" in sms._last_sent assert "yesterday" not in sms._daily_count and "today" in sms._daily_count + + +# ============================ 用户名 / 默认昵称 ============================ + +def test_login_assigns_username_and_nickname(client) -> None: + """新用户创建即分配:11 位纯数字 username(首位非 0/1,与手机号天然区分)+ 9 位字母数字默认昵称。""" + phone = "13600136000" + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + user = r.json()["user"] + + uname = user["username"] + assert uname.isdigit() and len(uname) == 11 # 11 位纯数字 + assert uname[0] not in ("0", "1") # 无前导 0、与手机号(均以 1 开头)区分 + + nick = user["nickname"] + assert nick and len(nick) == 9 and nick.isalnum() # 9 位字母+数字 + + +def test_username_stable_and_unique_on_relogin() -> None: + """同号重登 username 不变(不可变标识);不同号 username 不同(唯一)。 + 直连 repository 绕开 HTTP 短信冷却。""" + from app.db.session import SessionLocal + from app.repositories import user as user_repo + + db = SessionLocal() + try: + a1 = user_repo.upsert_user_for_login(db, phone="13522220001", register_channel="sms") + uname_a, id_a = a1.username, a1.id + a2 = user_repo.upsert_user_for_login(db, phone="13522220001", register_channel="sms") + assert a2.id == id_a and a2.username == uname_a # 重登:同一行、username 稳定不变 + + b = user_repo.upsert_user_for_login(db, phone="13522220002", register_channel="sms") + assert b.username != uname_a # 不同用户、username 唯一 + finally: + db.close() diff --git a/tests/test_delete_account.py b/tests/test_delete_account.py new file mode 100644 index 0000000..464b02c --- /dev/null +++ b/tests/test_delete_account.py @@ -0,0 +1,141 @@ +"""注销账号(DELETE /api/v1/user):软删 + 释放唯一约束 + 资金前置校验。 + +覆盖: + - 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放 + - 回归原始 bug:注销释放 openid 后,别的账号能绑同一个微信 + - 资金前置闸:有可提现现金 / 在审提现单 → 409 拒绝注销,账号不被删 + - 注销后旧 token 即时失效(status==active 闸) +""" +from __future__ import annotations + +from sqlalchemy import select + +from app.db.session import SessionLocal +from app.models.user import User +from app.models.wallet import CoinAccount + + +def _login(client, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _patch_userinfo(monkeypatch, openid: str) -> None: + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": openid, "nickname": "微信昵称", "avatar_url": "https://x/a.png", "raw": {}}, + ) + + +def _seed_cash(client, token: str, phone: str, cents: int) -> None: + """先访问 /account 触发建账户,再用 DB 直接灌现金余额。""" + client.get("/api/v1/wallet/account", headers=_auth(token)) + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + acc = db.get(CoinAccount, user.id) + acc.cash_balance_cents = cents + db.commit() + finally: + db.close() + + +def _get_user(phone: str) -> User | None: + db = SessionLocal() + try: + return db.execute(select(User).where(User.phone == phone)).scalar_one_or_none() + finally: + db.close() + + +def test_delete_clean_account_releases_all_unique_slots(client) -> None: + phone = "13900000001" + token = _login(client, phone) + # 预置:给该用户造昵称 + 邀请码(占 invite_code 唯一槽),验证注销时一并释放 + db = SessionLocal() + try: + u = db.execute(select(User).where(User.phone == phone)).scalar_one() + u.nickname = "张三" + u.invite_code = "INVCODE0001" + db.commit() + uid = u.id + finally: + db.close() + + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 200, r.text + + u = _get_user(f"deleted_{uid}") + assert u is not None + assert u.status == "deleted" + assert u.phone == f"deleted_{uid}" + assert u.nickname is None + assert u.avatar_url is None + assert u.wechat_openid is None + assert u.wechat_nickname is None + assert u.wechat_avatar_url is None + assert u.invite_code is None + + +def test_delete_releases_wechat_so_another_account_can_bind(client, monkeypatch) -> None: + """原始 bug 回归:A 绑微信 → 注销,B 必须能绑同一个 openid(修复前会 409)。""" + openid = "openid_delreuse_a1" # 独特值,避免跨测试共享库污染(如 test_withdraw 也用 openid_shared_xyz) + # A 绑微信 + _patch_userinfo(monkeypatch, openid) + token_a = _login(client, "13900000002") + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "ca"}, headers=_auth(token_a)) + assert r.status_code == 200, r.text + # A 注销(无现金、无提现单 → 允许) + r = client.delete("/api/v1/user", headers=_auth(token_a)) + assert r.status_code == 200, r.text + # B 绑同一个 openid → 必须成功(修复前会 409 "该微信已绑定其他账号") + token_b = _login(client, "13900000003") + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b)) + assert r.status_code == 200, r.text + assert r.json()["bound"] is True + + +def test_delete_blocked_when_cash_balance_positive(client) -> None: + phone = "13900000004" + token = _login(client, phone) + _seed_cash(client, token, phone, 100) # 1 元未提现 + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 409, r.text + assert "现金" in r.json()["detail"] + u = _get_user(phone) # 账号未被删 + assert u is not None and u.status == "active" + + +def test_delete_blocked_when_reviewing_withdraw_exists(client, monkeypatch) -> None: + """cash 提光(=0)但有在审提现单 → 仍拒绝(命中 reviewing 闸,非 cash 闸)。""" + phone = "13900000005" + _patch_userinfo(monkeypatch, "openid_delrev_b2") + token = _login(client, phone) + _seed_cash(client, token, phone, 50) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + # 提光 50 → cash 归 0,单进 reviewing + r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token)) + assert r.status_code == 200 and r.json()["status"] == "reviewing", r.text + assert r.json()["cash_balance_cents"] == 0 + + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 409, r.text + assert "审核" in r.json()["detail"] + u = _get_user(phone) + assert u is not None and u.status == "active" + + +def test_deleted_user_old_token_is_rejected(client) -> None: + phone = "13900000006" + token = _login(client, phone) + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 200, r.text + # 注销后旧 token 立即失效(get_current_user 校验 status==active) + r = client.get("/api/v1/user/onboarding/status", params={"device_id": "d"}, headers=_auth(token)) + assert r.status_code == 401, r.text diff --git a/tests/test_invite.py b/tests/test_invite.py index 574adad..a578c6a 100644 --- a/tests/test_invite.py +++ b/tests/test_invite.py @@ -300,7 +300,7 @@ def test_bind_no_code_no_fingerprint(client) -> None: # ===================================================================== def test_invitees_basic(client) -> None: - """A 邀 B、C → 列表返 2 条、total=2、没设资料的名字=脱敏手机号、头像 null、金币对。""" + """A 邀 B、C → 列表返 2 条、total=2、名字=被邀请人默认昵称(创建即有)、头像 null、金币对。""" a = _login(client, "13800002050") a_code = _my_code(client, a) for phone in ("13800002051", "13800002052"): @@ -313,9 +313,14 @@ def test_invitees_basic(client) -> None: assert body["total"] == 2 assert body["has_more"] is False assert len(body["items"]) == 2 - # 没设昵称头像 → 名字脱敏手机号、头像 null(不依赖顺序用 set) + # 创建即分配默认昵称 → 名字=被邀请人昵称(取实际值对比)、头像 null(不依赖顺序用 set) + with SessionLocal() as db: + expected_names = { + get_user_by_phone(db, "13800002051").nickname, + get_user_by_phone(db, "13800002052").nickname, + } names = {it["display_name"] for it in body["items"]} - assert names == {"138****2051", "138****2052"} + assert names == expected_names assert all(it["avatar_url"] is None for it in body["items"]) assert all(it["coins"] == INVITE_INVITER_COINS for it in body["items"]) @@ -336,9 +341,12 @@ def test_invitees_order_desc(client) -> None: ).scalar_one() rel_b.created_at = datetime.now(timezone.utc) - timedelta(hours=2) db.commit() + nick_b = ub.nickname + nick_c = get_user_by_phone(db, "13800002062").nickname items = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()["items"] - assert items[0]["display_name"] == "138****2062" # C 刚邀,在前 - assert items[1]["display_name"] == "138****2061" # B 2 小时前,在后 + # 创建即有默认昵称 → display_name=被邀请人昵称;本用例核心验证倒序(C 刚邀在前、B 2h 前在后) + assert items[0]["display_name"] == nick_c + assert items[1]["display_name"] == nick_b def test_invitees_nickname_priority(client) -> None: diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..883a75f --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,13 @@ +"""平台公开端点测试(不鉴权):/api/v1/platform/flags 等。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def test_flags_default_comparing_ad_enabled(client: TestClient) -> None: + """空配置库下,/flags 返回 comparing_ad_enabled 的默认值 True;不需要鉴权。""" + r = client.get("/api/v1/platform/flags") + assert r.status_code == 200, r.text + body = r.json() + assert "comparing_ad_enabled" in body + assert body["comparing_ad_enabled"] is True diff --git a/tests/test_store_mapping_invalidate.py b/tests/test_store_mapping_invalidate.py new file mode 100644 index 0000000..2d3188f --- /dev/null +++ b/tests/test_store_mapping_invalidate.py @@ -0,0 +1,156 @@ +"""淘宝 deeplink 失效标记 + lookup 过滤 + invalidate 端点测试。 + +覆盖:按 shopId 标记所有行(幂等)、lookup 过滤失效淘宝候选、失效后新行仍可命中、 +invalidate 端点鉴权(503 未配 / 401 头错 / 200)、端到端标记后 lookup MISS、非淘宝 no-op。 +""" +from __future__ import annotations + +import pytest + +from app.core.config import settings +from app.db.session import SessionLocal +from app.models.store_mapping import StoreMapping +from app.repositories import store_mapping as repo + +_SECRET = "test-internal-secret-only-for-pytest" + + +def _mk_row(db, *, trace_id, name_meituan=None, name_taobao=None, + id_taobao=None, deeplink=None, lat=None, lng=None, + name_jd=None, id_jd=None, jd_deeplink=None): + row = StoreMapping( + trace_id=trace_id, business_type="food", + name_meituan=name_meituan, name_taobao=name_taobao, + id_taobao=id_taobao, taobao_deeplink=deeplink, lat=lat, lng=lng, + name_jd=name_jd, id_jd=id_jd, jd_deeplink=jd_deeplink, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +@pytest.fixture() +def db(): + """每个用例前后清空 store_mapping(表由 conftest 的 create_all 建好,session 级共享)。""" + s = SessionLocal() + s.query(StoreMapping).delete() + s.commit() + yield s + s.query(StoreMapping).delete() + s.commit() + s.close() + + +# ---------- repo 层 ---------- + +def test_mark_invalid_marks_all_rows_with_shop_id(db): + # 同一个坏 shopId 散在两行(两次比价),另一行不同 shopId + _mk_row(db, trace_id="t1", name_meituan="绝味鸭脖", name_taobao="绝味鸭脖", id_taobao="BAD1", deeplink="dl_a") + _mk_row(db, trace_id="t2", name_meituan="绝味鸭脖", name_taobao="绝味鸭脖", id_taobao="BAD1", deeplink="dl_b") + _mk_row(db, trace_id="t3", name_meituan="绝味鸭脖", name_taobao="绝味鸭脖", id_taobao="GOOD9", deeplink="dl_g") + + # 按 shopId 标记所有行 + assert repo.mark_taobao_deeplink_invalid(db, "BAD1") == 2 + # 幂等:已标记的不重复 + assert repo.mark_taobao_deeplink_invalid(db, "BAD1") == 0 + + rows = {r.trace_id: r for r in db.query(StoreMapping).all()} + assert rows["t1"].taobao_deeplink_invalid_at is not None + assert rows["t2"].taobao_deeplink_invalid_at is not None + assert rows["t3"].taobao_deeplink_invalid_at is None # 不同 shopId 不动 + + +def test_lookup_filters_invalid_taobao(db): + _mk_row(db, trace_id="t1", name_meituan="绝味鸭脖", name_taobao="绝味鸭脖", id_taobao="BAD1", deeplink="dl") + # 标记前命中 + assert repo.lookup_nearest(db, "meituan", "绝味鸭脖")["taobao"]["shop_id"] == "BAD1" + # 标记失效后淘宝候选被过滤 → MISS(仅此一条淘宝) + repo.mark_taobao_deeplink_invalid(db, "BAD1") + assert "taobao" not in repo.lookup_nearest(db, "meituan", "绝味鸭脖") + + +def test_lookup_picks_new_valid_row_after_invalidate(db): + # 失效旧行 + 重搜写的新行(新 shopId,invalid_at=NULL)共存 → lookup 选到新行 + _mk_row(db, trace_id="t1", name_meituan="店A", name_taobao="店A", id_taobao="BAD1", deeplink="dl_bad") + repo.mark_taobao_deeplink_invalid(db, "BAD1") + _mk_row(db, trace_id="t2", name_meituan="店A", name_taobao="店A", id_taobao="NEW2", deeplink="dl_new") + assert repo.lookup_nearest(db, "meituan", "店A")["taobao"]["shop_id"] == "NEW2" + + +# ---------- invalidate 端点 ---------- + +def test_invalidate_endpoint_secret_unset_503(client, monkeypatch): + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", "") # 未配 = 端点关闭 + r = client.post("/internal/store-mapping/invalidate", + json={"platform": "taobao", "shop_id": "X"}) + assert r.status_code == 503 + + +def test_invalidate_endpoint_auth(client, monkeypatch): + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) + body = {"platform": "taobao", "shop_id": "X"} + assert client.post("/internal/store-mapping/invalidate", json=body).status_code == 401 + assert client.post("/internal/store-mapping/invalidate", json=body, + headers={"X-Internal-Secret": "wrong"}).status_code == 401 + r = client.post("/internal/store-mapping/invalidate", json=body, + headers={"X-Internal-Secret": _SECRET}) + assert r.status_code == 200 and r.json()["ok"] is True + + +def test_invalidate_endpoint_marks_and_lookup_miss(client, db, monkeypatch): + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) + _mk_row(db, trace_id="t1", name_meituan="店B", name_taobao="店B", id_taobao="BADX", deeplink="dl") + _mk_row(db, trace_id="t2", name_meituan="店B", name_taobao="店B", id_taobao="BADX", deeplink="dl2") + r = client.post("/internal/store-mapping/invalidate", + json={"platform": "taobao", "shop_id": "BADX"}, + headers={"X-Internal-Secret": _SECRET}) + assert r.status_code == 200 and r.json()["affected"] == 2 + lk = client.get("/internal/store-mapping/lookup", + params={"source_platform": "meituan", "name": "店B"}, + headers={"X-Internal-Secret": _SECRET}) + assert "taobao" not in lk.json() + + +def test_invalidate_endpoint_unsupported_platform_noop(client, monkeypatch): + # 当前支持 taobao/jd;其它平台(如 meituan)no-op 返 affected=0 + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) + r = client.post("/internal/store-mapping/invalidate", + json={"platform": "meituan", "shop_id": "X"}, + headers={"X-Internal-Secret": _SECRET}) + assert r.status_code == 200 and r.json()["affected"] == 0 + + +# ---------- 京东(对称淘宝)---------- + +def test_mark_jd_invalid_marks_all_rows_with_store_id(db): + _mk_row(db, trace_id="j1", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl_a") + _mk_row(db, trace_id="j2", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl_b") + _mk_row(db, trace_id="j3", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JGOOD", jd_deeplink="dl_g") + assert repo.mark_jd_deeplink_invalid(db, "JBAD") == 2 + assert repo.mark_jd_deeplink_invalid(db, "JBAD") == 0 # 幂等 + rows = {r.trace_id: r for r in db.query(StoreMapping).all()} + assert rows["j1"].jd_deeplink_invalid_at is not None + assert rows["j2"].jd_deeplink_invalid_at is not None + assert rows["j3"].jd_deeplink_invalid_at is None # 不同 storeId 不动 + + +def test_lookup_filters_invalid_jd(db): + _mk_row(db, trace_id="j1", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl") + assert repo.lookup_nearest(db, "meituan", "兰州拉面")["jd"]["store_id"] == "JBAD" + repo.mark_jd_deeplink_invalid(db, "JBAD") + assert "jd" not in repo.lookup_nearest(db, "meituan", "兰州拉面") + + +def test_invalidate_endpoint_jd_marks_and_lookup_miss(client, db, monkeypatch): + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) + _mk_row(db, trace_id="j1", name_meituan="店J", name_jd="店J", id_jd="JBADX", jd_deeplink="dl") + _mk_row(db, trace_id="j2", name_meituan="店J", name_jd="店J", id_jd="JBADX", jd_deeplink="dl2") + r = client.post("/internal/store-mapping/invalidate", + json={"platform": "jd", "shop_id": "JBADX"}, + headers={"X-Internal-Secret": _SECRET}) + assert r.status_code == 200 and r.json()["affected"] == 2 + lk = client.get("/internal/store-mapping/lookup", + params={"source_platform": "meituan", "name": "店J"}, + headers={"X-Internal-Secret": _SECRET}) + assert "jd" not in lk.json() diff --git a/tests/test_welfare.py b/tests/test_welfare.py index 7904be2..d011907 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -145,37 +145,67 @@ def test_signin_boost_flow(client) -> None: def test_task_claim_flow(client) -> None: - """任务列表 → 领"打开消息提醒" → 余额到账 → 重复领 409。""" + """打开消息提醒=可重复任务:每次领取金额减半(750/375/188),claimed 恒 False,余额累加。""" token = _login(client, "13800001003") - coin = TASK_REWARDS[TASK_ENABLE_NOTIFICATION] + base = TASK_REWARDS[TASK_ENABLE_NOTIFICATION] # 750 - # 1. 任务列表:未领取 + # 1. 任务列表:可重复任务 claimed 恒 False,coin=首次金额(750) r = client.get("/api/v1/tasks", headers=_auth(token)) assert r.status_code == 200, r.text items = {it["task_key"]: it for it in r.json()["items"]} assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False - assert items[TASK_ENABLE_NOTIFICATION]["coin"] == coin + assert items[TASK_ENABLE_NOTIFICATION]["coin"] == base # 750 - # 2. 领奖 + # 2. 第一次领 → 750,余额 750 r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) assert r.status_code == 200, r.text - assert r.json()["coin_awarded"] == coin - assert r.json()["coin_balance"] == coin + assert r.json()["coin_awarded"] == 750 + assert r.json()["coin_balance"] == 750 - # 3. 重复领 → 409 - r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) - assert r.status_code == 409 - - # 4. 列表变为已领取 + # 3. 列表:下一次金额减半=375,仍 claimed False(可重复) r = client.get("/api/v1/tasks", headers=_auth(token)) items = {it["task_key"]: it for it in r.json()["items"]} - assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is True + assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False + assert items[TASK_ENABLE_NOTIFICATION]["coin"] == 375 + + # 4. 第二次领 → 375(余额 1125),第三次 → 188(四舍五入) + r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) + assert r.status_code == 200 and r.json()["coin_awarded"] == 375 + assert r.json()["coin_balance"] == 1125 + r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) + assert r.status_code == 200 and r.json()["coin_awarded"] == 188 # 5. 未知任务 → 404 r = client.post("/api/v1/tasks/no_such_task/claim", headers=_auth(token)) assert r.status_code == 404 +def test_task_notification_claim_capped(client) -> None: + """可重复任务领满(减半到底)后封顶:不再可领 → 409,列表 claimed 置 True。""" + from app.core.rewards import notification_max_claims + + token = _login(client, "13800001009") + base = TASK_REWARDS[TASK_ENABLE_NOTIFICATION] + cap = notification_max_claims(base) # 750 → 11 + + # 领满 cap 次,每次都应 200(最后几次发 1 金币) + last_coin = None + for _ in range(cap): + r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) + assert r.status_code == 200, r.text + last_coin = r.json()["coin_awarded"] + assert last_coin == 1 # 末次是减半到底的 1 + + # 再领 → 409(封顶,不再发放) + r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) + assert r.status_code == 409, r.text + + # 列表:领满后 claimed 置 True(客户端据此隐藏) + r = client.get("/api/v1/tasks", headers=_auth(token)) + items = {it["task_key"]: it for it in r.json()["items"]} + assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is True + + def test_exchange_info(client) -> None: token = _login(client, "13800001004") r = client.get("/api/v1/wallet/exchange-info", headers=_auth(token)) diff --git a/tools/gen_meituan_cities.py b/tools/gen_meituan_cities.py new file mode 100644 index 0000000..231fb7e --- /dev/null +++ b/tools/gen_meituan_cities.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""从美团「城市字典」Excel 生成随仓库的 JSON(app/integrations/data/meituan_cities.json)。 + +本地一次性工具:美团每年更新城市字典时,把新 Excel 放进来重跑即可。 +Excel 不入库、不进仓库(二进制、需 openpyxl);生成的 JSON 随仓库提交,部署到服务器后 +ETL(scripts/pull_meituan_coupons.py)直接读它遍历全部城市。 + +字典口径:359 个地级市,经实测一个地级市 cityId 已覆盖其下辖县级市(如徐州→邳州/新沂), +故无需区县层级。 + +用法: + python tools/gen_meituan_cities.py ["城市字典xxx.xlsx" 路径] + (不传则用默认 e:\\codes\\城市字典2025 (1).xlsx) +""" +from __future__ import annotations + +import json +import os +import sys + +import openpyxl + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +DEFAULT_EXCEL = r"e:\codes\城市字典2025 (1).xlsx" +OUT = os.path.join(ROOT, "app", "integrations", "data", "meituan_cities.json") + + +def main() -> None: + excel = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_EXCEL + wb = openpyxl.load_workbook(excel, data_only=True) + ws = wb.worksheets[0] + rows = list(ws.iter_rows(values_only=True)) + header = rows[0] + + cities = [] + seen = set() + for r in rows[1:]: + cid = str(r[0]).strip() if r[0] else "" + name = str(r[1]).strip() if len(r) > 1 and r[1] else "" + prov = str(r[2]).strip() if len(r) > 2 and r[2] else "" + if not cid or cid in seen: + continue + seen.add(cid) + cities.append({"city_id": cid, "name": name, "province": prov}) + + os.makedirs(os.path.dirname(OUT), exist_ok=True) + with open(OUT, "w", encoding="utf-8") as f: + json.dump(cities, f, ensure_ascii=False, indent=1) + + print(f"表头: {header}") + print(f"写出 {len(cities)} 城 → {OUT}") + print("样例:", cities[:3]) + + +if __name__ == "__main__": + main()