Compare commits

..

1 Commits

Author SHA1 Message Date
marco 251e48a9cf feat(store_mapping): 淘宝缓存 deeplink 失效检测→标记→回退(app-server 侧)
加 taobao_deeplink_invalid_at 列(迁移+model);mark_taobao_deeplink_invalid 按 shopId 标记所有行;lookup_nearest 过滤失效淘宝候选;新增 POST /internal/store-mapping/invalidate。+ 7 单测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:25:06 +08:00
116 changed files with 278 additions and 6165 deletions
+2 -10
View File
@@ -88,17 +88,9 @@ AUTO_EXCHANGE_ENABLED=true
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。
# 穿山甲"奖励校验密钥"(m-key),验签,从 GroMore 后台各广告位取到后填这里
# 配齐(任一非空)并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。
# 每个激励位 m-key 不同但共用同一回调 URL → 各位分开一行配(留空的忽略);验签逐个试、任一通过即接受。
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",用于验签,从后台取到后填这里;
# 配齐并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。
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(绕过反作弊)。
-1
View File
@@ -30,7 +30,6 @@ data/*
!data/media/
data/media/*
!data/media/dl.html
!data/media/taobao_landing.jpg
secrets/*
!secrets/.gitkeep
@@ -1,26 +0,0 @@
"""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
@@ -1,39 +0,0 @@
"""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")
@@ -1,79 +0,0 @@
"""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')
@@ -1,54 +0,0 @@
"""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(JSONBserver 按 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)
-38
View File
@@ -1,38 +0,0 @@
"""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")
-61
View File
@@ -1,61 +0,0 @@
"""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")
-92
View File
@@ -1,92 +0,0 @@
"""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")
-75
View File
@@ -1,75 +0,0 @@
"""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 可空
# server_default 按方言区分:Postgres 用 ::jsonb 转换;SQLite(本地)用纯 JSON 文本字面量
#(SQLite 不认 ::jsonb,会报 unrecognized token: ":")。
bind = op.get_bind()
platforms_default = (
sa.text("'[\"meituan\"]'::jsonb")
if bind.dialect.name == "postgresql"
else sa.text("'[\"meituan\"]'")
)
# alter_column 在 SQLite 原生不支持 ALTER COLUMN,必须走 batch(重建表);Postgres 下 batch 直接 ALTER。
# 用 batch_alter_table 跨方言通吃(render_as_batch 只管 autogenerate 渲染,不会自动包裹手写的 alter_column)。
with op.batch_alter_table("cps_group") as batch_op:
batch_op.add_column(
sa.Column(
"platforms",
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
nullable=False,
server_default=platforms_default,
)
)
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
# link:sid 可空 + target 加长
with op.batch_alter_table("cps_link") as batch_op:
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
batch_op.alter_column("target_url", existing_type=sa.String(1024), type_=sa.String(2048))
# click:sid 可空 + 事件类型
with op.batch_alter_table("cps_click") as batch_op:
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
batch_op.add_column(
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit")
)
def downgrade() -> None:
with op.batch_alter_table("cps_click") as batch_op:
batch_op.drop_column("event_type")
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_link") as batch_op:
batch_op.alter_column("target_url", existing_type=sa.String(2048), type_=sa.String(1024))
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_group") as batch_op:
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
batch_op.drop_column("platforms")
op.drop_column("cps_activity", "payload")
@@ -1,26 +0,0 @@
"""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
@@ -1,32 +0,0 @@
"""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')
-6
View File
@@ -14,13 +14,10 @@ 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_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
@@ -92,7 +89,4 @@ admin_app.include_router(feedback_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_revenue_router)
+5 -25
View File
@@ -67,8 +67,6 @@ 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,
@@ -88,8 +86,6 @@ 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,
@@ -154,8 +150,6 @@ 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,
@@ -174,8 +168,6 @@ 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,
@@ -192,22 +184,6 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
return rows
def audit_rows(
db: Session, *, date: str, user_id: int | None, scene: str | None = None
) -> list[dict]:
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"
每行含 `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))
return rows
def ad_coin_audit(
db: Session,
*,
@@ -224,7 +200,11 @@ def ad_coin_audit(
影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。
份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。
"""
rows = audit_rows(db, date=date, user_id=user_id, scene=scene)
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"], r["record_id"]), reverse=True)
total = len(rows)
-239
View File
@@ -1,239 +0,0 @@
"""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 口径)→ 北京时间小时(023)。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,
}
-427
View File
@@ -1,427 +0,0 @@
"""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, 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
# 美团订单状态:取消(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<id>);纯淘宝/京东 sid=None(那俩不支持 sid)。"""
has_meituan = "meituan" in (platforms or [])
# 含美团:用填的 sid,或临时占位待回填 g<id>;不含美团: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:
bj = clicked_at.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
+10 -61
View File
@@ -109,63 +109,6 @@ def list_users(
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]:
"""按设备(device_id, ANDROID_ID)聚合 onboarding_completion:每台设备走过引导的账号数 +
最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会
@@ -343,11 +286,10 @@ def list_feedbacks(
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[Feedback], int | None, int]:
) -> tuple[list[Feedback], int | None]:
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。
created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
数据变动可能错位一条——admin 低频场景可接受。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
stmt = select(Feedback)
if status:
stmt = stmt.where(Feedback.status == status)
@@ -364,7 +306,14 @@ def list_feedbacks(
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)
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
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
-75
View File
@@ -1,75 +0,0 @@
"""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"]],
)
-58
View File
@@ -1,58 +0,0 @@
"""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)$")] = 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)
-3
View File
@@ -43,9 +43,6 @@ def _validate(key: str, value: Any) -> None:
for k, v in value.items()
):
raise ValueError("需为 {字符串: 整数} 映射")
elif t == "bool":
if not isinstance(value, bool):
raise ValueError("需为布尔值")
def _item(db, key: str) -> ConfigItemOut:
-435
View File
@@ -1,435 +0,0 @@
"""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,
}
+2 -4
View File
@@ -34,7 +34,7 @@ def list_feedbacks(
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[FeedbackOut]:
items, next_cursor, total = queries.list_feedbacks(
items, next_cursor = queries.list_feedbacks(
db,
status=status,
user_id=user_id,
@@ -47,9 +47,7 @@ def list_feedbacks(
cursor=cursor,
)
return CursorPage(
items=[FeedbackOut.model_validate(f) for f in items],
next_cursor=next_cursor,
total=total,
items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor,
)
+4 -12
View File
@@ -34,7 +34,6 @@ 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(
@@ -96,7 +95,7 @@ def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut:
summary="提现配置健康检查",
dependencies=[Depends(require_role("finance"))], # 暴露密钥路径/配置,限财务+super
)
def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut:
def withdraw_health_check() -> 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,15 +117,8 @@ def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut:
issues.append("微信支付基础配置不完整")
if not settings.WXPAY_AUTH_NOTIFY_URL:
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("自动对账运营开关已关闭(系统配置页可开)")
if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED:
issues.append("自动对账未开启")
return WxpayHealthCheckOut(
ok=not issues,
@@ -139,7 +131,7 @@ def withdraw_health_check(db: AdminDb) -> 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=auto_reconcile_enabled,
auto_reconcile_enabled=settings.WITHDRAW_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,
-90
View File
@@ -1,90 +0,0 @@
"""广告收益报表 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="北京时间小时 023(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="聚合明细(按 用户→类型→代码位 排序)")
-70
View File
@@ -1,70 +0,0 @@
"""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
+1 -1
View File
@@ -10,7 +10,7 @@ class ConfigItemOut(BaseModel):
key: str
label: str
group: str
type: str # int / int_list / dict_str_int / bool
type: str # int / int_list / dict_str_int
help: str | None = None
default: Any
value: Any
-155
View File
@@ -1,155 +0,0 @@
"""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
+3 -6
View File
@@ -95,14 +95,11 @@ def invalidate_store_mapping(
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 将来扩展。
if payload.platform != "taobao":
# 当前只接淘宝; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。
logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform)
return StoreMappingInvalidateOut(ok=True, affected=0)
affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id)
logger.info(
"store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d",
payload.platform, payload.shop_id, affected,
+6 -78
View File
@@ -29,13 +29,9 @@ from app.schemas.ad import (
AdRewardStatusOut,
EcpmReportIn,
EcpmReportOut,
FeedPreviewIn,
FeedPreviewOut,
FeedRewardIn,
FeedRewardOut,
PangleCallbackOut,
RewardNoShowIn,
RewardNoShowOut,
TestGrantIn,
TestGrantOut,
WatchReportIn,
@@ -89,7 +85,7 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
params = dict(request.query_params)
if not pangle.verify_callback_sign_any(params, settings.pangle_reward_secrets):
if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET):
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
@@ -246,12 +242,10 @@ 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 app=%s code=%s",
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%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)
@@ -345,23 +339,6 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
)
def _feed_segments(payload: FeedRewardIn) -> tuple[list[tuple[str, int]], str]:
"""把 feed-reward 入参归一成 `(segments, ecpm_repr)`。
优先用逐条 segments(每条自带 eCPM);未传则回退把 ecpm + duration_seconds 当单段(旧客户端)。
ecpm_repr 取末段 eCPM(报表/审计落库代表值)。两者都缺 → 422。
"""
if payload.segments:
segs = [(s.ecpm, max(0, s.seconds)) for s in payload.segments]
return segs, segs[-1][0]
if payload.ecpm is not None:
return [(payload.ecpm, max(0, payload.duration_seconds))], payload.ecpm
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="feed-reward 需提供 segments 或 ecpm",
)
@router.post(
"/feed-reward",
response_model=FeedRewardOut,
@@ -369,24 +346,19 @@ def _feed_segments(payload: FeedRewardIn) -> tuple[list[tuple[str, int]], str]:
dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))],
)
def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut:
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,整场结束(全程不关)一次性入账。
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。
新客户端按条上报 [segments](每条 eCPM + 秒数),服务端按时序跨条进位精确结算;旧客户端只传
ecpm + duration_seconds 时回退当单段。client_event_id 做幂等键,避免重试重复发。
当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发。
"""
segments, ecpm_repr = _feed_segments(payload)
rec = crud_feed.grant_feed_reward(
db,
user.id,
client_event_id=payload.client_event_id,
segments=segments,
ecpm_repr=ecpm_repr,
ecpm=payload.ecpm,
duration_seconds=payload.duration_seconds,
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,
aborted=payload.aborted,
)
logger.info(
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
@@ -399,47 +371,3 @@ 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(
"/feed-reward/preview",
response_model=FeedPreviewOut,
summary="信息流播放中预演此刻将发放的金币(只读,不入账)",
dependencies=[Depends(rate_limit(600, 60, "ad-feed-preview"))],
)
def feed_reward_preview(payload: FeedPreviewIn, user: CurrentUser, db: DbSession) -> FeedPreviewOut:
"""比价/领券金币小球实时显示**真实即将到账金额**用:按截至此刻的逐条 segments 算,与
/feed-reward 同一口径(同份数封顶 / 同 LT / 同 eCPM 钳),但**不入账、不写库**。
限流放宽到 600/分:小球每满 10 秒(或换条)拉一次,一整场最多十几次,留足并发余量。
"""
segments = [(s.ecpm, max(0, s.seconds)) for s in payload.segments]
result = crud_feed.preview_feed_reward(db, user.id, segments)
return FeedPreviewOut(**result)
@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)
-8
View File
@@ -116,11 +116,3 @@ 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")
+3 -20
View File
@@ -19,7 +19,6 @@ from fastapi import APIRouter, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
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,
@@ -43,22 +42,13 @@ def report_record(
payload: ComparisonRecordIn, user: CurrentUser, db: DbSession
) -> ComparisonRecordCreatedOut:
rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload)
# 同机拉 pricebot 本次比价的 LLM 调用明细落库(best-effort,失败不阻断上报)。
# llm_call_count/retry_count 直接由明细派生:次数=条数,重试=带 error 的条数。
calls = fetch_llm_calls(rec.trace_id)
if calls:
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(
"compare record user=%s trace=%s biz=%s status=%s saved=%s llm_calls=%d",
"compare record user=%s trace=%s biz=%s status=%s saved=%s",
user.id,
rec.trace_id,
rec.business_type,
rec.status,
rec.saved_amount_cents,
len(calls),
)
return ComparisonRecordCreatedOut(id=rec.id)
@@ -83,20 +73,13 @@ 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(列表页「复制调试链接」靠它)
# 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):
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)
if not user.debug_trace_enabled:
for o in outs:
o.trace_url = None
return ComparisonRecordPage(items=outs, next_cursor=next_cursor)
+1 -14
View File
@@ -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 CurrentUser, DbSession
from app.api.deps import DbSession
from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
@@ -28,7 +28,6 @@ from app.schemas.coupon_state import (
CouponPromptDismissIn,
CouponPromptShouldShowOut,
CouponPromptShownIn,
CouponStatsOut,
)
logger = logging.getLogger("shagua.coupon")
@@ -269,15 +268,3 @@ def coupon_completed_today_reset(
与 /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))
-122
View File
@@ -1,122 +0,0 @@
"""CPS 群发短链落地:用户点 /c/{code} → 记点击 → 按平台 302 跳 或 返回淘宝落地页。
公网无鉴权(群里任何人点都要能跳/能领)。记点击失败绝不影响用户。
- 美团/京东:记 visit + 302 跳 target(美团短链 / 京东链接)
- 淘宝:记 visit + 返回 H5 落地页(整段淘口令复制按钮);点"复制口令"→ POST /c/{code}/copy 记 copy
"""
from __future__ import annotations
import html
import json
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.core import media
from app.db.session import get_db
from app.models.cps_activity import CpsActivity
from app.models.cps_link import CpsLink
from app.repositories import cps_link as cps_link_repo
router = APIRouter(tags=["cps-redirect"])
# code 不存在/失效时的兜底落地
_FALLBACK_URL = "https://www.meituan.com/"
# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发)
_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg"
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) -> None:
try:
cps_link_repo.record_click(
db, link=link, ip=_client_ip(request),
ua=request.headers.get("user-agent"), event_type=event_type,
)
except Exception:
pass # 记点击失败不阻断
@router.get("/c/{code}", summary="群发短链落地(记点击 + 跳转/淘宝落地页)")
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)
_record(db, link, request, "visit")
if link.platform == "taobao":
activity = db.get(CpsActivity, link.activity_id)
image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE
return HTMLResponse(_taobao_landing_html(link.target_url, image_url))
# 美团短链 / 京东链接:直接 302 跳
return RedirectResponse(link.target_url, status_code=302)
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件)")
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")
return {"ok": True}
def _taobao_landing_html(token: str, image_url: str) -> str:
"""淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。
image_url 转绝对(落地页 coupon 域,相对也可达,绝对更稳)后经 html.escape 嵌入
<img src>(防属性注入);token 经 json.dumps 安全嵌入 JS。
"""
safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True)
return (
_TAOBAO_HTML
.replace("__IMAGE_URL__", safe_img)
.replace("__TOKEN_JS__", json.dumps(token))
)
_TAOBAO_HTML = """<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>淘宝闪购 · 天天领红包</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color:#333;min-height:100vh}
.hero{display:block;width:100%}
.btn-wrap{position:fixed;left:0;right:0;top:75vh;padding:0 20px}
.btn{display:block;width:100%;background:linear-gradient(90deg,#ff5b5b,#ff3b3b);color:#fff;font-size:19px;font-weight:800;text-align:center;padding:16px;border:none;border-radius:30px;box-shadow:0 6px 16px rgba(255,59,59,.4)}
.btn:active{transform:scale(.98)}
.toast{position:fixed;left:50%;top:42%;transform:translate(-50%,-50%);background:rgba(0,0,0,.82);color:#fff;padding:12px 22px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;pointer-events:none;z-index:99;white-space:nowrap}
.toast.show{opacity:1}
</style>
</head>
<body>
<img class="hero" src="__IMAGE_URL__" alt="淘宝闪购 天天领红包">
<div class="btn-wrap"><button class="btn" onclick="copyToken()">复制口令去淘宝领红包</button></div>
<div class="toast" id="toast"></div>
<script>
var TOKEN = __TOKEN_JS__;
function showToast(m){var t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(function(){t.classList.remove('show')},2200)}
function reportCopy(){try{fetch(location.pathname+'/copy',{method:'POST',keepalive:true})}catch(e){}}
function done(){showToast('复制成功!打开淘宝即可领取');reportCopy()}
function fallback(){
var ta=document.createElement('textarea');ta.value=TOKEN;ta.style.position='fixed';ta.style.opacity='0';
document.body.appendChild(ta);ta.focus();ta.select();
try{document.execCommand('copy');done()}catch(e){showToast('复制失败,请长按手动复制')}
document.body.removeChild(ta);
}
function copyToken(){
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(TOKEN).then(done).catch(fallback)}
else{fallback()}
}
</script>
</body>
</html>"""
+1 -1
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger("shagua.feedback")
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 6
_CONTENT_MAX = 200
_CONTENT_MAX = 2000
_CONTACT_MAX = 128
+1 -17
View File
@@ -2,7 +2,6 @@
路由前缀 `/api/v1/platform`:
GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。
GET /flags 客户端运营 feature flag(比价/领券期广告开关等),客户端拉取后缓存。
展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。
"""
@@ -13,15 +12,9 @@ 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 (
AppFlagsOut,
PlatformStatsOut,
SavingsFeedItem,
SavingsFeedOut,
)
from app.schemas.platform import PlatformStatsOut, SavingsFeedItem, SavingsFeedOut
logger = logging.getLogger("shagua.platform")
@@ -42,12 +35,3 @@ 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")),
)
+3 -30
View File
@@ -136,15 +136,8 @@ class Settings(BaseSettings):
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
# 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自
# 生成),但共用同一回调 URL → 多个位的 m-key 都要配上。验签时所有非空 m-key 逐个试、任一通过
# 即接受(见 pangle.verify_callback_sign_any / 下面的 pangle_reward_secrets)。
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。
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,让(已登录的)客户端在没部署公网、
@@ -152,23 +145,10 @@ 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_secrets)
"""回调开关打开且验签密钥已配,才接受发奖回调。"""
return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET)
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
@@ -210,13 +190,6 @@ 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 = ""
+1 -20
View File
@@ -11,7 +11,7 @@ from typing import Any
from app.core import rewards as r
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int
CONFIG_DEFS: dict[str, dict[str, Any]] = {
"signin_rewards": {
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
@@ -65,23 +65,4 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"group": "签到", "type": "int",
"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 控制。关掉只停自动扫描,"
"提现页「批量对账」手动按钮不受影响。"
),
},
}
-17
View File
@@ -67,23 +67,6 @@ def save_report_image(user_id: int, data: bytes) -> str:
return _save_image("price_report", user_id, data)
def save_cps_image(admin_id: int, data: bytes) -> str:
"""保存 CPS 活动落地页图,返回相对 URL(`/media/cps/<file>`)。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)都能直接 <img src> 加载。
"""
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_avatar(url: str | None) -> None:
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/"
+4 -17
View File
@@ -14,11 +14,8 @@ 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"
@@ -62,15 +59,8 @@ def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
_LOCK_PATH.unlink()
def _reconcile_once(older_than_minutes: int) -> dict | None:
"""读运营开关(app_config):关着返回 None(本轮跳过),开着才真扫单。
env WITHDRAW_AUTO_RECONCILE_ENABLED 是部署级总闸(决定 worker 起不起);
这里的 DB 开关是运营级日常开关,后台一改下一轮即生效、跨进程一致、无需重启。
"""
def _reconcile_once(older_than_minutes: int) -> dict:
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)
@@ -88,18 +78,16 @@ async def _run_loop() -> None:
async def _run_locked_loop(interval: int, older_than: int) -> None:
logger.info(
"withdraw auto reconcile worker started interval=%ss older_than=%sm "
"(runtime on/off via app_config '%s')",
"withdraw auto reconcile started interval=%ss older_than=%sm",
interval,
older_than,
_AUTO_RECONCILE_KEY,
)
try:
while True:
try:
_touch_lock()
result = await asyncio.to_thread(_reconcile_once, older_than)
if result is not None and (result["checked"] or result["resolved"]):
if result["checked"] or result["resolved"]:
logger.info("withdraw auto reconcile result=%s", result)
except WxPayNotConfiguredError:
logger.warning("withdraw auto reconcile skipped: wxpay not configured")
@@ -115,8 +103,7 @@ 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:
# 部署级总闸关:worker 进程不启动(运营后台开关此时无效,需先在 env 打开)。
logger.info("withdraw auto reconcile worker not started (env master switch off)")
logger.info("withdraw auto reconcile disabled")
return None
if not settings.wxpay_configured:
logger.warning("withdraw auto reconcile enabled but wxpay not configured")
-34
View File
@@ -1,34 +0,0 @@
# -*- 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
File diff suppressed because it is too large Load Diff
+2 -39
View File
@@ -126,23 +126,13 @@ def query_coupon(
def get_referral_link(
*,
product_view_sign: str | None = None,
act_id: str | None = None,
product_view_sign: str,
platform: int = 1,
biz_line: int | None = None,
sid: str | None = None,
link_type_list: list[int] | None = None,
) -> dict[str, Any]:
"""换推广链接。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
body: dict[str, Any] = {"productViewSign": product_view_sign}
if platform == 2:
body["platform"] = 2
if biz_line is not None:
@@ -152,30 +142,3 @@ 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)
-10
View File
@@ -42,13 +42,3 @@ 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)
-3
View File
@@ -20,7 +20,6 @@ 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.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
@@ -108,8 +107,6 @@ app.include_router(report_router)
app.include_router(internal_price_router)
app.include_router(internal_store_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)
-4
View File
@@ -6,10 +6,6 @@ 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.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
+2 -7
View File
@@ -4,8 +4,8 @@
由客户端生成,并通过激励视频 extra 透传给 S2S 回调,用于把"展示 eCPM"
"奖励完成"绑定。没有 session id 的旧上报仍可作为按天对账补充。
`ecpm_raw` 原样存客户端上报的字符串——单位是穿山甲 getEcpm 官方口径【分/千次展示】
(非元!),对账由 `core/rewards.parse_ecpm_fen` 解析、`parse_ecpm_yuan` ÷100 转元;本列只存原值
⚠️ `ecpm_raw` 原样存客户端上报的字符串——eCPM 单位(分 / 元)截至 2026-05-31 尚未最终确认,
确认后再加一列解析好的数值;在此之前对账按"待定单位"处理
"""
from __future__ import annotations
@@ -35,11 +35,6 @@ 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 比较)
-3
View File
@@ -30,9 +30,6 @@ class AdFeedRewardRecord(Base):
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 来源(广告收益报表用):我们的应用环境 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")
-4
View File
@@ -33,10 +33,6 @@ 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)
# 穿山甲上报的奖励名(参考,不作发奖依据)
-27
View File
@@ -18,7 +18,6 @@ from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
@@ -102,32 +101,6 @@ class ComparisonRecord(Base):
# 客户端上报的原始 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
)
-38
View File
@@ -1,38 +0,0 @@
"""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"<CpsActivity id={self.id} platform={self.platform} name={self.name!r}>"
-37
View File
@@ -1,37 +0,0 @@
"""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"<CpsGroup id={self.id} sid={self.sid!r} name={self.name!r}>"
-57
View File
@@ -1,57 +0,0 @@
"""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"<CpsLink id={self.id} code={self.code!r} sid={self.sid!r}>"
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)
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"<CpsClick id={self.id} link_id={self.link_id} group_id={self.group_id}>"
-63
View File
@@ -1,63 +0,0 @@
"""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"<CpsOrder id={self.id} order_id={self.order_id!r} "
f"sid={self.sid!r} status={self.mt_status} profit_cents={self.commission_cents}>"
)
+1 -2
View File
@@ -1,8 +1,7 @@
"""签到记录表。
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次
- cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1
(周期长度 = rewards.SIGNIN_CYCLE_LEN,2026-06 14 天改 7 天一轮)
- cycle_day: 1..14,14 天循环里今天落在第几档,决定发多少金币;断签后重置回 1
- streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1
"""
from __future__ import annotations
-2
View File
@@ -105,8 +105,6 @@ class StoreMapping(Base):
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)
-4
View File
@@ -27,10 +27,6 @@ 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")
+1 -9
View File
@@ -23,14 +23,8 @@ 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 用北京时间当天,供按天聚合。
app_env(prod/test) our_code_id(我们后台配置的 104xxx 代码位)供广告收益报表按
应用/代码位聚合; adn(实际填充子渠道)/slot_id(底层 rit)是两组不同口径
"""
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。"""
if ad_session_id:
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
if existing is not None:
@@ -41,8 +35,6 @@ 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(),
)
+44 -123
View File
@@ -16,13 +16,10 @@ from app.repositories import wallet as crud_wallet
FEED_REWARD_UNIT_SECONDS = 10
# 单个 feed 事件的时长上限(秒):一期时长由客户端上报,伪造超长时长会刷份数
# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数
# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。
# 与 rewards.AD_ECPM_MAX_FEN(eCPM 钳顶)合起来,把单事件可铸金币锁进有限区间。
FEED_MAX_DURATION_SECONDS = 120
# 单场份数上限(=120s//10):多条广告按时序「跨条进位」累计满 10 秒一份,封顶 12 份/场。
# 时长改由逐条 segments 上报后,防刷的硬闸从「单事件时长钳」上移到这个份数上限(配合 eCPM 钳顶)。
FEED_MAX_UNITS = FEED_MAX_DURATION_SECONDS // FEED_REWARD_UNIT_SECONDS
def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None:
@@ -45,89 +42,21 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
).scalar_one()
def _existing_granted_units(db: Session, user_id: int) -> int:
"""该账号历史已 granted 的累计份数(LT 因子起点,不按天重置)。"""
return int(
db.execute(
select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)).where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
)
).scalar_one()
)
def _walk_unit_ecpms(segments: list[tuple[str, int]]) -> list[str]:
"""按时序遍历各广告段,**跨条进位**累计满 10 秒结算一份,返回每份对应的 eCPM。
每份归属它满 10 秒那一刻正在播的那条广告的 eCPM:换条时不足一份的零头进位到下一条
(与旧版 `总时长//10` 份数口径一致不浪费零头,但每份 eCPM 归属精确)封顶 [FEED_MAX_UNITS]
;尾部不足一份的秒数丢弃segments `[(ecpm, seconds), ...]`,seconds0 的段跳过
"""
unit_ecpms: list[str] = []
carry = 0
for ecpm, seconds in segments:
if seconds <= 0:
continue
carry += seconds
while carry >= FEED_REWARD_UNIT_SECONDS and len(unit_ecpms) < FEED_MAX_UNITS:
carry -= FEED_REWARD_UNIT_SECONDS
unit_ecpms.append(ecpm)
if len(unit_ecpms) >= FEED_MAX_UNITS:
break
return unit_ecpms
def compute_feed_reward(
db: Session, user_id: int, segments: list[tuple[str, int]]
) -> tuple[int, int, int]:
"""按逐条 segments 计算「此刻若结束将发放的金币」。**只读、不入账**,发奖与预览共用此口径。
返回 `(coin, unit_count, next_unit_coin)`:
- coin:已累计满的各份金币之和(每份 = 所在段 eCPM × LT(账号累计份序号) × 汇率)
- unit_count:已满份数(封顶 [FEED_MAX_UNITS])
- next_unit_coin:下一份( unit_count+1 )的边际金币,按当前在播段(segments 末段)eCPM ;
已封顶或无段时为 0供客户端小球在本份未满区间内做平滑插值显示
"""
unit_ecpms = _walk_unit_ecpms(segments)
unit_count = len(unit_ecpms)
existing_units = _existing_granted_units(db, user_id)
coin = sum(
rewards.calculate_ad_reward_coin(ecpm, existing_units + i)
for i, ecpm in enumerate(unit_ecpms, start=1)
)
if unit_count < FEED_MAX_UNITS and segments:
next_ecpm = segments[-1][0]
next_unit_coin = rewards.calculate_ad_reward_coin(
next_ecpm, existing_units + unit_count + 1
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.status == "granted",
)
else:
next_unit_coin = 0
return coin, unit_count, next_unit_coin
def preview_feed_reward(
db: Session, user_id: int, segments: list[tuple[str, int]]
) -> dict:
"""信息流播放中查询「此刻若结束将发放多少金币」(只读预演,不入账、不写库)。
给比价/领券金币小球实时显示**真实即将到账金额**would_status 预判若此刻结束的结算状态:
daily_capped(已达每日条数上限,整场将发 0)/ too_short(凑不满一份)/ granted
"""
coin, unit_count, next_unit_coin = compute_feed_reward(db, user_id, segments)
if _granted_today(db, user_id, cn_today().isoformat()) >= rewards.get_ad_daily_limit(db):
would_status = "daily_capped"
elif unit_count == 0:
would_status = "too_short"
else:
would_status = "granted"
return {
"coin": coin,
"unit_count": unit_count,
"next_unit_coin": next_unit_coin,
"session_full": unit_count >= FEED_MAX_UNITS,
"would_status": would_status,
}
).scalar_one()
total = 0
for offset in range(1, unit_count + 1):
total += rewards.calculate_ad_reward_coin(ecpm, int(existing_units) + offset)
return total
def grant_feed_reward(
@@ -135,25 +64,16 @@ def grant_feed_reward(
user_id: int,
*,
client_event_id: str,
segments: list[tuple[str, int]],
ecpm_repr: str,
ecpm: str,
duration_seconds: int,
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,
aborted: bool = False,
) -> AdFeedRewardRecord:
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。
"""完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。
发奖规则:**比价全程不关广告才发**,金额按整场逐条 [segments]跨条进位折份
( 10 1 ,每份用所在段 eCPM, [_walk_unit_ecpms])
- aborted=True(用户中途 关闭):整场不发, status='closed_early' 留痕(原因可查)
- 总时长不足 10 (unit_count==0): status='too_short' 不发
- 命中当日条数上限: status='capped' 不发
segments 是逐条广告 `(ecpm, seconds)` 时序列表(旧客户端只传总时长时,API 层包成单段)
ecpm_repr 是落库代表 eCPM(报表/审计用,通常取末段)三道硬闸防刷:份数封顶 [FEED_MAX_UNITS]
限单场份数,eCPM rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额,
一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS
限单事件份数,eCPM rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额;
叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间
"""
existing = _find_by_event(db, client_event_id)
@@ -161,46 +81,47 @@ def grant_feed_reward(
return existing
today = cn_today().isoformat()
total_seconds = sum(max(0, s) for _, s in segments)
# 份数封顶在 compute 内做(FEED_MAX_UNITS);记录的 duration 仍钳一道,纯做报表展示防异常大值。
safe_duration = min(total_seconds, FEED_MAX_DURATION_SECONDS)
coin, unit_count, _next = compute_feed_reward(db, user_id, segments)
# 客户端上报时长先钳到 FEED_MAX_DURATION_SECONDS,防伪造超长时长刷份数(见常量注释)。
safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS))
unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS
def _record(coin_val: int, status: str) -> AdFeedRewardRecord:
return AdFeedRewardRecord(
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm_repr,
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
app_env=app_env,
our_code_id=our_code_id,
coin=coin_val,
status=status,
coin=0,
status="capped",
)
return _commit_record(db, rec, client_event_id)
# 用户中途关闭广告:整场不发(全程不关才发),留一条 closed_early 记录原因。优先级最高。
if aborted:
return _commit_record(db, _record(0, "closed_early"), client_event_id)
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
return _commit_record(db, _record(0, "capped"), client_event_id)
# 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。
if unit_count == 0:
return _commit_record(db, _record(0, "too_short"), client_event_id)
coin = _unit_reward_total(db, user_id, ecpm, unit_count)
if coin > 0:
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="feed_ad_reward", ref_id=client_event_id,
remark=f"信息流广告奖励 {unit_count}",
)
return _commit_record(db, _record(coin, "granted"), client_event_id)
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,
coin=coin,
status="granted",
)
return _commit_record(db, rec, client_event_id)
def _commit_record(db: Session, rec: AdFeedRewardRecord, client_event_id: str) -> AdFeedRewardRecord:
+4 -62
View File
@@ -90,16 +90,6 @@ 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 = (
@@ -112,18 +102,19 @@ 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 or (ecpm_rec.ecpm_raw if ecpm_rec is not None else None)
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
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)
@@ -140,55 +131,6 @@ 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)
-15
View File
@@ -90,21 +90,6 @@ 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,
)
+1 -27
View File
@@ -9,7 +9,7 @@ import logging
from datetime import date, datetime
from zoneinfo import ZoneInfo
from sqlalchemy import delete, func, select
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -206,29 +206,3 @@ 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)
-95
View File
@@ -1,95 +0,0 @@
"""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",
) -> None:
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。"""
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),
))
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
-20
View File
@@ -124,23 +124,6 @@ def mark_taobao_deeplink_invalid(db: Session, shop_id: str) -> int:
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 跳店内搜索, 省掉"开平台→进店→分享反查"整段。
@@ -198,9 +181,6 @@ def lookup_nearest(
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)
-48
View File
@@ -4,8 +4,6 @@
"""
from __future__ import annotations
import secrets
import string
from datetime import datetime, timezone
from sqlalchemy import select
@@ -14,50 +12,6 @@ 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)
@@ -82,8 +36,6 @@ 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,
)
+5 -94
View File
@@ -57,12 +57,6 @@ 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):
@@ -120,108 +114,25 @@ class TestGrantOut(BaseModel):
)
class FeedSegmentIn(BaseModel):
"""信息流轮播中的**一条广告段**:自带 eCPM + 观看秒数。
新客户端按条上报(每条 eCPM 不同),服务端按时序跨条进位每满 10 秒结算一份每份用所在段
eCPM 计价( ad_feed_reward._walk_unit_ecpms)
"""
ecpm: str = Field(..., description="该条广告 eCPM(分/千次展示,SDK getEcpm 原值,非元)")
seconds: int = Field(..., ge=0, description="该条广告的观看秒数")
class FeedRewardIn(BaseModel):
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。
"""信息流广告完成后结算奖励。
规则:全程不关广告才发,金额按整场折份( 10 1 )client_event_id 用于客户端超时重试
幂等中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)
计价口径:优先用 [segments] 逐条精确结算(每条自带 eCPM);未传 segments 时回退用
ecpm + duration_seconds **单段**(旧客户端兼容)两者都没有则 422
每展示满 10 秒累计一份奖励,视频完成后一次性入账client_event_id 用于客户端超时重试幂等
"""
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"
)
segments: list[FeedSegmentIn] | None = Field(
None, description="条广告段(每条自带 eCPM + 观看秒数,时序)。新客户端传它做按条精确结算"
)
ecpm: str | None = Field(
None, description="[旧客户端/回退] 本场信息流代表 eCPM(分/千次);传了 segments 时仅作落库代表值"
)
duration_seconds: int = Field(
0, ge=0, description="[旧客户端/回退] 整场累计观看秒数;传了 segments 时忽略"
)
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="实际展示代码位")
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 / too_short / closed_early")
status: str = Field(..., description="granted / capped")
coin: int = Field(..., description="本次发放金币")
unit_count: int = Field(..., description="按 10 秒折算出的奖励份数")
daily_limit: int = Field(..., description="每日信息流展示次数上限")
class FeedPreviewIn(BaseModel):
"""信息流播放中查询「此刻若结束将发放多少金币」(只读预演,不入账)。
给比价/领券金币小球实时显示**真实即将到账金额**segments 含在播那条的部分秒数
"""
segments: list[FeedSegmentIn] = Field(
..., min_length=1, description="截至此刻的逐条广告段(时序;含在播那条的部分秒数)"
)
class FeedPreviewOut(BaseModel):
coin: int = Field(..., description="此刻结束将发放的累计金币(已满 10 秒的各份之和)")
unit_count: int = Field(..., description="已累计满的份数(封顶 12)")
next_unit_coin: int = Field(
..., description="下一份(第 unit_count+1 份)的金币值,按当前在播段 eCPM 算;封顶后为 0。"
"供小球在本份未满区间内做平滑插值显示"
)
session_full: bool = Field(..., description="是否已达单场份数上限(12),小球应停涨")
would_status: str = Field(
..., description="若此刻结束的结算预判:granted / too_short / daily_capped"
)
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(该次其实已发奖,跳过未写)"
)
-2
View File
@@ -18,8 +18,6 @@ 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
-21
View File
@@ -69,10 +69,6 @@ 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
@@ -86,23 +82,6 @@ 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 总步数")
# ===== 读取出参 =====
-10
View File
@@ -39,13 +39,3 @@ 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
-6
View File
@@ -22,9 +22,3 @@ class SavingsFeedItem(BaseModel):
class SavingsFeedOut(BaseModel):
items: list[SavingsFeedItem]
class AppFlagsOut(BaseModel):
"""客户端拉取的运营 feature flag(不鉴权,登录前也能拉)。客户端缓存后按需读。"""
comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch)
-38
View File
@@ -1,38 +0,0 @@
"""按 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 []
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

+9 -15
View File
@@ -1,22 +1,16 @@
# 后端文档库(docs/)
`shaguabijia-app-server` 的文档都在这里。结构:**根目录放总览,其余按领域/用途分目录**。
`shaguabijia-app-server` 的文档都在这里。各文档作用:
## 根目录
| 文档 | 作用 |
| 文档 / 目录 | 作用 |
|---|---|
| [后端技术实现.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 实现原理 + 本地内网全链路测试)。 |
| [后端技术实现.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 包 + 两台真机走全链路 + 上线前还差什么。要本地测 / 接手邀请功能看这份。 |
## api/ 目录是怎么组织的(传送门式)
-3
View File
@@ -63,8 +63,6 @@
| 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) |
| 35a' | `POST /api/v1/ad/feed-reward/preview` | Bearer | [详情](./ad-feed-reward-preview.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) |
@@ -106,7 +104,6 @@
| 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` 允许客户端传值覆盖默认渠道——见各接口"备注"。
+4 -6
View File
@@ -7,13 +7,11 @@
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `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` 去重只留一条) |
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 |
| `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),防伪造。
@@ -25,7 +23,7 @@
| `ok` | bool | 落库即 `true` |
## 说明
客户端在广告**展示后**(`onAdShow``getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。激励视频每次展示上报一条;**信息流轮播每条展示各上报一条**(每条独立 `ad_session_id`),作为「广告收益报表」展示条数/收益的数据源(见 [admin-ad-revenue-report](./admin-ad-revenue-report.md))。`app_env` + `our_code_id` 供报表按「我们的应用 / 我们配置的代码位」聚合,与 `adn`/`slot_id`(底层填充渠道/rit)是两组不同口径。
客户端在广告**展示后**(`onAdShow``getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。
- 普通激励视频发奖会先用 S2S 回调自带 `ecpm`;若缺失,再按 `ad_session_id` 读取本接口上报的 eCPM;两边都没有则不发并记录异常。
- **best-effort**:客户端 fire-and-forget,但普通激励视频若 S2S 缺 eCPM,这条上报会成为发奖依据。
-35
View File
@@ -1,35 +0,0 @@
# POST /api/v1/ad/feed-reward/preview — 信息流播放中预演将发放的金币(只读)
比价 / 领券信息流广告**播放过程中**,查询「若此刻结束、整场将发放多少金币」。给金币小球实时显示**真实即将到账金额**用:与 [feed-reward](./ad-feed-reward.md) **同一计价口径**(同份数封顶 / 同 LT / 同 eCPM 钳),但**只读、不入账、不写库**。
客户端每满 10 秒观看(或换条 eCPM 变化)拉一次;两次之间用 `next_unit_coin × 本份进度` 在本地做平滑插值显示。
## 鉴权
需要 Bearer token。
## 请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `segments` | array | 是 | 截至此刻的**逐条广告段**(时序,至少 1 项;含在播那条的部分秒数),每项 `{ecpm: string, seconds: int}` |
## 响应
| 字段 | 类型 | 说明 |
|---|---|---|
| `coin` | int | 此刻结束将发放的累计金币(已满 10 秒的各份之和) |
| `unit_count` | int | 已累计满的份数(封顶 12) |
| `next_unit_coin` | int | 下一份(第 `unit_count+1` 份)的金币值,按当前在播段 eCPM 算;封顶后为 0。供小球在本份未满区间内平滑插值 |
| `session_full` | bool | 是否已达单场份数上限(12)→ 小球应停涨 |
| `would_status` | string | 若此刻结束的结算预判:`granted` / `too_short`(凑不满一份) / `daily_capped`(已达每日条数上限,整场将发 0) |
## 与 feed-reward 的关系
- 同样按 `segments` 跨条进位每满 10 秒一份、每份用所在段 eCPM 计价(详见 [feed-reward 计算口径](./ad-feed-reward.md#计算口径))。
- preview 的 `coin` 等于「用相同 segments 调 feed-reward 实发的 `coin`」——小球显示值即整场结束实发额(除非中途 `would_status` 提示 `daily_capped`,那种情况整场实发 0)。
- preview **不消耗每日上限、不写任何表**,可高频调用(限流 600/分)。
## 限流
600 次 / 分钟(`ad-feed-preview`)。
+14 -26
View File
@@ -1,8 +1,6 @@
# POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币
点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发
播放过程中查询「此刻将发放多少金币」(金币小球实时显示用)见 [feed-reward/preview](./ad-feed-reward-preview.md),与本接口同一计价口径但只读不入账。
点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账
## 鉴权
@@ -10,44 +8,34 @@
## 请求体
计价优先用 `segments` 逐条精确结算;未传 `segments` 时回退用 `ecpm` + `duration_seconds` 当**单段**(旧客户端兼容)。两者都不传 → 422。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
| `segments` | array\|null | 否* | **逐条广告段**(时序),每项 `{ecpm: string, seconds: int}`。新客户端传它做按条精确结算 |
| `ecpm` | string\|null | 否* | **[回退]** 本场代表 eCPM(穿山甲 getEcpm 原值,分/千次展示,非元);传了 `segments` 时仅作落库代表值 |
| `duration_seconds` | int | 否 | **[回退]** 整场累计观看秒数;传了 `segments` 时忽略。默认 0 |
| `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` |
\* `segments``ecpm` 至少传其一。
| `ecpm` | string | | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) |
| `duration_seconds` | int | | 实际展示/播放秒数 |
| `adn` | string\|null | 否 | 实际投放 ADN |
| `slot_id` | string\|null | 否 | 实际展示代码位 |
## 响应
| 字段 | 类型 | 说明 |
|---|---|---|
| `granted` | bool | 本次是否入账;未发(任一非 granted 状态)时为 `false` |
| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场<10s 凑不满一份) / `closed_early`(用户中途关闭) |
| `coin` | int | 本次发放金币;非 granted 为 0 |
| `unit_count` | int | 折算出的奖励份数(封顶 12 |
| `granted` | bool | 本次是否入账;达上限时为 `false` |
| `status` | string | `granted` / `capped` |
| `coin` | int | 本次发放金币 |
| `unit_count` | int | 按 10 秒折算出的奖励份数 |
| `daily_limit` | int | 每日信息流展示次数上限,默认 500 |
## 计算口径
- **奖励份数(跨条进位)**:按 `segments` 时序遍历,维护一个连续秒数计数器,每满 10 秒结算一份;换条时不足一份的零头进位到下一条。份数封顶 **12 份/场**(防刷硬闸,替代旧的「单事件时长钳 120s」)
- **每份归属的 eCPM**:该份「满 10 秒那一刻正在播的那条广告」的 eCPM
- **单份奖励**`eCPM / 1000 × 因子1(eCPM 档) × 因子2(账号累计份序号) × 10000`,四舍五入为整数金币
- eCPM 档:`0-100=0.1``101-200=0.3``201-400=0.4``>400=0.6`eCPM 钳顶 ¥500 CPM
- LT 档(按**账号累计已发份数**,不按天重置):第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`
- 旧客户端单段口径 `[(ecpm, duration_seconds)]` 是上述的特例,份数 = `min(duration//10, 12)`,结果与改造前一致。
- 奖励份数`duration_seconds // 10`
- 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币
- eCPM 档:`0-100=0.1``101-200=0.3``201-400=0.4``>400=0.6`
- LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`
## 数据写入
- `ad_feed_reward_record` 新增一行`ecpm_raw` 存代表 eCPM,通常为末段)
- `ad_feed_reward_record` 新增一行。
- 入账时 `coin_account` 增加余额。
- 入账时 `coin_transaction` 写入 `biz_type=feed_ad_reward`
+1 -1
View File
@@ -22,7 +22,7 @@ GroMore 以 GET 回调,关键参数:
| `ecpm` | string | 本次广告 eCPM(同上,可用于收益分析) |
| `sign` | string | 签名,见下 |
**验签**:`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)。
**验签**:`sign = SHA256("{m-key}:{trans_id}")` 十六进制(只签 `trans_id`,其余参数不参与)。算法细节、m-key 来源、为什么这样设计 → 见集成文档 [integrations/pangle](../integrations/pangle.md)。
## 出参
响应 `200`,**响应体必须是 `{"is_verify": bool, "reason": int}`**GroMore 规范)。
-41
View File
@@ -1,41 +0,0 @@
# 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`(不发币)。
-108
View File
@@ -1,108 +0,0 @@
# 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 | 北京时间小时 023(`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) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
+1 -1
View File
@@ -18,7 +18,7 @@ pricebot-backend 的响应**原样返回**JSON object)。典型含 `result`
外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`:`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../guides/待办与技术债.md) P1。
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`:`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../待办与技术债.md) P1。
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`
+1 -1
View File
@@ -5,7 +5,7 @@
比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
> ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../guides/待办与技术债.md) P1)。
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../待办与技术债.md) P1)。
## 入参(JSON body
+1 -1
View File
@@ -17,7 +17,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。
是产品"一键领券"的接入点,**前端已接通**:首页「去领取」→ `CouponPromptDialog` 确认 → 权限检查 → 无障碍引擎 `PriceBotService.startCouponClaim()` → 循环调本接口,后端逐张券下发 launch/wait/done。
⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer <access_token>` 头(契约层 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。
⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer <access_token>` 头(契约层 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。
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`
+2 -2
View File
@@ -6,7 +6,7 @@
**multipart/form-data**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `content` | string | ✓ | 反馈正文,**1-200 字**(strip 后) |
| `content` | string | ✓ | 反馈正文,**1-2000 字**(strip 后) |
| `contact` | string | ✗ | 联系方式(微信/QQ/手机号),**≤128 字**。原型改版后客户端已不再采集、不传该字段(后端默认空串);保留字段兼容旧端 |
| `images` | file[] | ✗ | 截图,**最多 6 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) |
@@ -29,7 +29,7 @@
> 不返回上传的 image URL——这是给运营后台看的,客户端通常不需要。
## 错误码
- `400` 内容为空 / 内容超 200 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对)
- `400` 内容为空 / 内容超 2000 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对)
- `401` 未带 token / token 无效或过期 / 用户被禁用
- `422``content` 字段
+4 -44
View File
@@ -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、**无关系库**。共 **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 张)是两个独立子系统
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **28 张业务表** + `alembic_version`(框架的迁移版本指针)。领券联动的「今日状态」三张表(`coupon_*`)同理:领券过程在 pricebot 内存态跑、**不落库**,只有结果回到 app-server 才落这三张表
---
@@ -16,8 +16,6 @@
| 比价战绩里程碑(逐档领金币) | [`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 位置 / 动作 | 表 | 说明 |
@@ -33,7 +31,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) | 7 天循环发币;签到后看广告可膨胀一次 |
| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 14 天循环发币;签到后看广告可膨胀一次 |
| 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 |
| 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 |
| 信息流广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账 |
@@ -48,21 +46,6 @@
| 新手引导是否再展示 | [`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,独立鉴权)
| 后台模块 | 表 | 说明 |
|---|---|---|
@@ -101,9 +84,7 @@
| 首次进 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`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, 北京日)` 幂等;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`) | 同上;客户端通知(透传链路看不到拒绝) |
@@ -122,19 +103,7 @@
| 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
| 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | |
> 没有任何表会被业务流程物理 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` 是可空旁路(链路不鉴权,匿名也记)。
> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U。
---
@@ -144,8 +113,6 @@
- **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` 变**:
@@ -172,8 +139,6 @@
- **里程碑解锁进度不存库**:`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 关系(文字版)
```
@@ -189,11 +154,6 @@ 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 内部上报)
```
---
+4 -25
View File
@@ -3,13 +3,13 @@
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`
> 最后更新:2026-06-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))
> 最后更新:2026-06-11(合并:新增 3 张领券今日状态表 `coupon_*` + `onboarding_completion` 新手引导完成表;含 [OVERVIEW 总览](./OVERVIEW.md))
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
---
## 表总览(37 张业务表 + `alembic_version` 框架表)
## 表总览(28 张业务表 + `alembic_version` 框架表)
### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 |
@@ -18,12 +18,6 @@
| `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) |
### 钱包 / 福利(看广告赚钱闭环)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
@@ -32,7 +26,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` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) |
| `signin_record` | 签到记录(14 天循环) | `models/signin.py` | [详情](./signin_record.md) |
| `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) |
| `user_task` | 一次性任务领取去重 | `models/task.py` | [详情](./user_task.md) |
| `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) |
@@ -48,16 +42,10 @@
| `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_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+日) | `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) |
@@ -66,15 +54,6 @@
|---|---|---|---|
| `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) |
### 首页门面数据
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
+4 -6
View File
@@ -7,19 +7,17 @@
## 用在哪 / 增删改查
- **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。
- **U / D**:无。
- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助;广告收益报表 [admin-ad-revenue-report](../api/admin-ad-revenue-report.md) 的展示条数/收益数据源(按 `app_env`/`our_code_id` 聚合)。当前无面向 C 端用户的读接口。
- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助。当前无面向 C 端用户的读接口。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `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`;聚合后实际填充的子渠道) |
| `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`) |
| `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 | 时间 |
+5 -9
View File
@@ -1,7 +1,5 @@
# 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 的奖励记录。每条记录对应客户端完成的一次信息流广告展示/播放事件。
## 字段
@@ -13,15 +11,13 @@
| `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 | 实际展示代码位(底层 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`(用户中途 ✕ 关闭,全程未看完) |
| `adn` | String(32) | nullable | 实际投放 ADN |
| `slot_id` | String(64) | nullable | 实际展示代码位 |
| `coin` | Integer | NOT NULL | 实发金币,`capped` 时为 0 |
| `status` | String(16) | NOT NULL | `granted` / `capped` |
| `created_at` | DateTime | index, NOT NULL | 创建时间 |
## 约束
+4 -6
View File
@@ -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 公式发币。另:`POST /ad/reward-noshow`(`record_reward_noshow`,Bearer)在用户提前关/未发奖时记一行 `status='closed_early'``coin=0` 留痕(同 session 已 granted 则跳过)。
- **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 公式发币。
- **U / D**:无。
- **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。
@@ -13,15 +13,13 @@
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `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}` |
| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等) |
| `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` 或客户端上报 |
| `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` 不在支持集合,只留痕不发) |
| `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` |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 |
| `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) |
| `raw` | String(1024) | nullable | 回调原始参数(审计排查) |
+2 -2
View File
@@ -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` / `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) |
| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` |
| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards) |
| `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 |
-1
View File
@@ -34,7 +34,6 @@
## 索引与约束
- 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`,不外露。
+3 -4
View File
@@ -10,8 +10,8 @@
| 动作 / endpoint | `biz_type` | `amount` | `ref_id` 指向 |
|---|---|---|---|
| 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) |
| 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 广告 `trans_id`(= `signin_boost_record.ad_ref_id`);无 ad_ref_id 时回退当天日期 ISO |
| 领任务 `POST /tasks/claim` | `task_<key>`(如 `task_enable_notification`) | + | 一次性任务=`user_task.task_key`;可重复任务(`enable_notification`)=带序号 `task_key:N` |
| 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 当天日期串(= `signin_boost_record.signin_date` ISO) |
| 领任务 `POST /tasks/claim` | `task_<key>`(如 `task_enable_notification`) | + | `user_task.task_key` |
| 普通激励视频 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,11 +36,10 @@
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- `ref_id` 是**软关联**(无 FK),目标随 `biz_type`:`signin`→签到日(`signin_record.signin_date` ISO) / `signin_boost``signin_boost_record.ad_ref_id`(无则当天日期) / `task_<key>`一次性任务=`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。
- `ref_id` 是**软关联**(无 FK),目标`biz_type`:`signin`→签到日 / `signin_boost``ad_reward_record.trans_id` / `task_<key>``user_task.task_key` / `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 等)在**同一事务**写,不会只发币不留痕。
+1 -1
View File
@@ -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/milestones/{milestone}/claim`(`claim`)。档位合法 + 已解锁(`count_success >= milestone`)+ 未领过 → 写一行(`coin_awarded=0`)。越界 `UnknownMilestoneError`(404)、未解锁 `MilestoneLockedError`(409)、已领 `AlreadyClaimedError`(409)。
- **C(插入)**:`POST /compare/milestone/claim`(`claim`)。档位合法 + 已解锁(`count_success >= milestone`)+ 未领过 → 写一行(`coin_awarded=0`)。越界 `UnknownMilestoneError`(404)、未解锁 `MilestoneLockedError`(409)、已领 `AlreadyClaimedError`(409)。
- **U / D**:无。
- **R**:`GET /compare/milestones`(战绩页:各档 claimed/active/locked + 成功次数),`get_status` 读本表已领集合 + `comparison_record` 成功计数对照。
+2 -4
View File
@@ -5,7 +5,6 @@
每完成一次比价(外卖/电商/领券),客户端在 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 最便宜),不信客户端自算。
@@ -20,7 +19,6 @@
| `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 | 源平台到手价(分) |
@@ -34,7 +32,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,applied_coupons}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用),`applied_coupons`=`[{name,amount}]` 多券明细 |
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用) |
| `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 |
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
@@ -48,7 +46,7 @@
- 被 `comparison_milestone_claim` 间接依赖:解锁进度 = 本表 `status='success'` 计数。
## 索引与约束
- 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`(幂等覆盖)。
- PK `id`;index `user_id``business_type``created_at`;UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。
## 注意
- 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。结构化金额列存「分」,`comparison_results.price`/`coupon_saved` 原样存「元」。
+12 -15
View File
@@ -4,7 +4,7 @@
领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `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_prompt_engagement`** — 弹窗频控源。按 `(device, 自然日)` 记「今天是否对领券引导窗表达过**意向**」(点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天 engage 过就不再弹。
- **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。
- **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。
@@ -19,38 +19,35 @@
---
## coupon_prompt_engagement — 弹窗频控(今日这个 App 是否已对引导窗表达意向)
## coupon_prompt_engagement — 弹窗频控(今日是否已对引导窗表达意向)
`(device_id, package, engage_date)` 唯一,一台设备、一个 App、一天一条;今天**这个 App** engage 过(弹/领/拒)就不再弹该 App。各 App 独立:美团弹过不压淘宝/京东
`(device_id, engage_date)` 唯一,一台设备一天一条;今天 engage 过(领或拒)就不再弹。
### 用在哪 / 增删改查
- **C / U(幂等 upsert**`mark_engagement`条触发:
- `POST /api/v1/coupon/prompt/shown`(引导窗弹出那刻上报)→ 记 `shown`(频控主判据:弹出即占用今天这个 App 的「一次」,领/拒/无视都算用掉);
- **C / U(幂等 upsert**`mark_engagement`条触发:
- `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` 查(老客户端不带 → `""` 全局态)
- 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。
- **D**`POST /api/v1/coupon/prompt/reset``reset_today_engagement`)—— 删这台设备今天那条,开发设置「重置今日领券弹窗状态」按钮调,测频控用;删后今天又能弹。
- **R**`GET /api/v1/coupon/prompt/should-show?device_id=…``has_engaged_today`)→ `should_show = not 今天已 engage`。客户端切外卖 App 前查,纯后台判据
### 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / 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 不影响弹不弹** |
| `engage_type` | String(16) | NOT NULL | `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天有没有这条」,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)。
- PK `id`index `user_id`UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`(一台设备一天一条)。
### 注意
- `device_id` 重装会变 → 重装当新设备,今天重新弹一次(产品预期)。
- 判断只看「今天这台设备这个 App 有没有这条」,不看 `engage_type`弹/领/拒都算 engage 过、都不再弹该 App)。
- 判断只看「今天这台设备有没有这条」,不看 `engage_type`领或拒都算 engage 过、都不再弹)。
---
@@ -60,7 +57,7 @@
### 用在哪 / 增删改查
- **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 不鉴权
- **U / D**无业务删除
- **R**`GET /api/v1/coupon/completed-today?device_id=…``has_completed_today`)→ `completed`。客户端据此把首页「去领取」卡置灰、不可点。
### 字段
@@ -122,5 +119,5 @@
## 三表共性小结
- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。
- 唯一键都含 `device_id` + 某个北京自然日列engagement 还含 `package`,按 App 频控)`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
- 唯一键都含 `device_id` + 某个北京自然日列;`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
- 无硬外键:`user_id` 软指 `user.id``trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。
+5 -5
View File
@@ -1,6 +1,6 @@
# signin_boost_record — 签到膨胀记录
App 用户当天签到后,看完激励视频可固定膨胀一次(默认 3000 金币,`rewards.SIGNIN_BOOST_COIN`,运营后台 `app_config.signin_boost_coin` 可改)。循环最后一天(`cycle_day == SIGNIN_CYCLE_LEN`,即 7 天循环的第 7 天)不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。
App 用户 Day1-Day13 当天签到后,看完激励视频可固定膨胀 2000 金币一次。Day14 不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。
## 字段
@@ -9,14 +9,14 @@ App 用户当天签到后,看完激励视频可固定膨胀一次(默认 300
| `id` | Integer | PK | 自增主键 |
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
| `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 |
| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 3000(`rewards.get_signin_boost_coin`) |
| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 2000 |
| `ad_ref_id` | String(64) | nullable | 穿山甲 S2S 回调 `trans_id` |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 |
| `created_at` | DateTime | NOT NULL | 创建时间 |
## 约束
- `UNIQUE(user_id, signin_date)` = `uq_signin_boost_user_date`:同一用户同一天只能膨胀一次。
- `UNIQUE(user_id, signin_date)`:同一用户同一天只能膨胀一次。
## 关联
- 膨胀成功时写 `coin_transaction.biz_type=signin_boost``ref_id = ad_ref_id`(无 ad_ref_id 时回退当天日期 ISO 串)
- 膨胀成功时写 `coin_transaction.biz_type=signin_boost``ref_id=ad_ref_id`
+4 -4
View File
@@ -1,13 +1,13 @@
# signin_record — 签到记录(7 天循环)
# signin_record — 签到记录(14 天循环)
> 模型 `app/models/signin.py` · 仓库 `app/repositories/signin.py` · 接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,200,300,200,400,400,800)`,一轮 7 天累计 2500 金币,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 8 天回到第 1 天;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。
App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..14)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,120,150,180,200,250,500,200,250,300,350,400,500,3000)`,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 15 天回到第 1 天;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。
## 用在哪 / 增删改查
- **C(插入)**:`POST /signin/do`(`do_signin`)。今天未签且校验通过 → 算出 `cycle_day`/`streak`(昨天签过则在上次基础上 +1、否则重置 1)→ 写一行 + `grant_coins(biz_type='signin', ref_id=今天日期)` 加币,**同事务 commit**。今天已签再调 → 抛 `AlreadySignedError`(409),不重复写。
- **U / D**:无。每天一行,历史不改。
- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、7 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。
- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、14 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
@@ -15,7 +15,7 @@ App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `signin_date` | **Date** | NOT NULL | 签到日期(**北京时间** `cn_today()` 的 date)。其 ISO 串被 `coin_transaction.ref_id` 引用(biz_type=signin) |
| `cycle_day` | Integer | NOT NULL | 取值 `1..7`:7 天循环里今天第几档,决定发币额;断签重置回 1 |
| `cycle_day` | Integer | NOT NULL | 取值 `1..14`:14 天循环里今天第几档,决定发币额;断签重置回 1 |
| `streak` | Integer | NOT NULL | 连续签到天数(≥1,不封顶);断签重置回 1 |
| `coin_awarded` | Integer | NOT NULL | 本次实发金币(= 当时档位金额) |
| `created_at` | DateTime(tz) | server_default now() | 时间 |
+3 -6
View File
@@ -13,27 +13,24 @@
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 用户主键;被多张业务表的 `user_id` 外键引用 |
| `id` | Integer | PK, autoincrement | 用户主键;被 15 张表的 `user_id` 外键引用 |
| `phone` | String(20) | UNIQUE, index, NOT NULL | 手机号(登录主键)。注销后置 `deleted_<id>` 占位释放唯一约束 |
| `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`;`invite_relation``inviter_user_id` / `invitee_user_id` 均 → `user.id`
- **被引用方(本表是 1,对方是 N/1)**:`coin_account``coin_transaction``cash_transaction``withdraw_order``wechat_transfer_authorization``signin_record``signin_boost_record``user_task``comparison_record``comparison_milestone_claim``savings_record``ad_reward_record``ad_watch_log``ad_ecpm_record``ad_feed_reward_record``price_report``feedback``user_id` 均 → `user.id`
- 与 `admin_user` **无任何关联**(C 端用户 vs 后台管理员,两套体系)。
## 索引与约束
- PK `id`;UNIQUE+index `phone``username``invite_code`(允许多 NULL)、`wechat_openid`(允许多 NULL)。
- PK `id`;UNIQUE+index `phone`;UNIQUE+index `wechat_openid`(允许多 NULL)。
## 注意
- `nickname/avatar_url`(通用)与 `wechat_nickname/wechat_avatar_url`(微信)**分开存,不互相覆盖**。
+3 -4
View File
@@ -2,11 +2,10 @@
> 模型 `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: 750}`,运营后台 `app_config.task_rewards` 可改)。
> ⚠️ 当前 `TASK_REWARDS` 里唯一的 `enable_notification`(打开消息提醒)已改为**可重复领取**任务(每次减半,见 `rewards.notification_reward`),它**不写本表**——领取次数靠数 `coin_transaction` 的正向流水条数(`biz_type='task_enable_notification'`),`ref_id` 带序号 `task_key:N`。因此本表目前实际**没有被任何已定义任务写入**;待新增真正的一次性任务后才会落行。
像"打开消息提醒"这类**只能领一次**的任务,领奖后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`。任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 1000}`,运营后台 `app_config.task_rewards` 可改)。
## 用在哪 / 增删改查
- **C(插入)**:`POST /tasks/claim`(`claim_task`),**仅一次性任务**走这条路:`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_<key>', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。可重复任务(`enable_notification`)在 `claim_task` 里走另一分支:**不写本表**,只发币(`ref_id=task_key:N`),减半到底(`notification_max_claims`)后再领抛 `AlreadyClaimedError`
- **C(插入)**:`POST /tasks/claim`(`claim_task`)`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_<key>', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。
- **U / D**:无。领过即终态。
- **R**:`GET /tasks/list`(任务页:列出所有已知任务 + 是否已领),`list_tasks` 把本表已领的 `task_key` 集合与任务字典对照。
@@ -15,7 +14,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` 是可重复任务,不落本表)。一次性任务领取时其 `task_key` = 配套 `coin_transaction.ref_id`(biz_type=`task_<key>`) |
| `task_key` | String(48) | NOT NULL | 任务标识,取值见 `rewards.TASK_REWARDS`(当前仅 `enable_notification`)。**被 `coin_transaction.ref_id` 引用**(biz_type=`task_<key>`) |
| `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() | 领取时间 |
-1
View File
@@ -44,7 +44,6 @@ 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` 对账兜底。
+1 -1
View File
@@ -47,4 +47,4 @@ api 层 3 个端点都在入口处 `if not settings.mt_cps_configured:` 早返
## 备注
- 美团接口当前**无鉴权**,且换链的 `sid` 允许客户端传值覆盖默认渠道——见对应 api 文档「备注」
- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../guides/待办与技术债.md) 已记账
- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../待办与技术债.md) 已记账

Some files were not shown because too many files have changed in this diff Show More