Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 008ca9b5bd | |||
| 1a7a624b87 | |||
| 5a18dbb8a2 | |||
| 436b2a36da |
+2
-10
@@ -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(绕过反作弊)。
|
||||
|
||||
@@ -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,34 +0,0 @@
|
||||
"""add feed_scene to ad_feed_reward_record
|
||||
|
||||
信息流广告点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。
|
||||
比价与领券共用同一个信息流代码位(AdConfig.feedCodeId),slot_id / our_code_id 都分不出来,
|
||||
只能由客户端在各调用点(PriceBotService / CouponForegroundService / WelfareScreen)显式打标。
|
||||
可空:历史记录与未升级客户端为 NULL = 未分类。
|
||||
|
||||
Revision ID: add_feed_scene
|
||||
Revises: drop_force_onboarding
|
||||
Create Date: 2026-06-20 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "add_feed_scene"
|
||||
down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ad_feed_reward_record",
|
||||
sa.Column("feed_scene", sa.String(length=16), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ad_feed_reward_record", "feed_scene")
|
||||
@@ -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,26 +0,0 @@
|
||||
"""merge add_feed_scene and launch_confirm_sample heads
|
||||
|
||||
Revision ID: ceb286289426
|
||||
Revises: add_feed_scene, launch_confirm_sample_table
|
||||
Create Date: 2026-06-20 23:52:57.853400
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ceb286289426'
|
||||
down_revision: Union[str, Sequence[str], None] = ('add_feed_scene', 'launch_confirm_sample_table')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -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(JSONB,server 按 trace_id 同机拉 pricebot 落库)
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "comparison_debug_fields"
|
||||
down_revision: Union[str, Sequence[str], None] = "cps_activity_image"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("comparison_record", sa.Column("device_model", sa.String(64), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("device_manufacturer", sa.String(64), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("rom_vendor", sa.String(32), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("rom_name", sa.String(32), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("rom_version", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("android_version", sa.String(16), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("android_sdk", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("app_version", sa.String(32), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("app_version_code", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("source_app_version", sa.String(32), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("longitude", sa.Float(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("latitude", sa.Float(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("total_ms", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("step_count", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("llm_call_count", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("retry_count", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("llm_calls", _JSONB, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for col in (
|
||||
"llm_calls", "retry_count", "llm_call_count", "step_count", "total_ms",
|
||||
"latitude", "longitude", "source_app_version", "app_version_code",
|
||||
"app_version", "android_sdk", "android_version", "rom_version",
|
||||
"rom_name", "rom_vendor", "device_manufacturer", "device_model",
|
||||
):
|
||||
op.drop_column("comparison_record", col)
|
||||
@@ -1,53 +0,0 @@
|
||||
"""coupon_prompt_engagement 频控加 package 维度(美团/淘宝/京东各自独立弹)
|
||||
|
||||
Revision ID: coupon_engage_per_package
|
||||
Revises: store_mapping_jd_cols
|
||||
Create Date: 2026-06-14 11:00:00.000000
|
||||
|
||||
需求:领券引导窗按 (device, App, 自然日) 频控——在美团弹过/领过,不影响淘宝、京东今天
|
||||
仍各弹一次。原表唯一键是 (device_id, engage_date),缺 package → 任一 App 弹过就把整台
|
||||
设备当天标记 engage,其余 App 被压住不弹(bug)。
|
||||
|
||||
本迁移:
|
||||
1. 加 package 列(NOT NULL,旧行用 server_default "" 填占位,不影响新逻辑判断)。
|
||||
2. 旧唯一约束 (device_id, engage_date) → 新 (device_id, package, engage_date)。
|
||||
|
||||
SQLite 不支持直接 drop/add 约束,用 batch_alter_table(建临时表 + 拷数据 + 换名,
|
||||
与 store_mapping_* 同款)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'coupon_engage_per_package'
|
||||
down_revision: Union[str, Sequence[str], None] = 'store_mapping_jd_cols'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
|
||||
# 加 package 列。旧行(改造前的全局记录)填 "" 占位:它们对应的是"老的全局态",
|
||||
# 新逻辑按 (device, package, 日) 判,占位 "" 不会与真实包名(com.xxx)碰撞。
|
||||
batch_op.add_column(
|
||||
sa.Column('package', sa.String(length=64), nullable=False, server_default='')
|
||||
)
|
||||
# 旧唯一约束 (device_id, engage_date) → 新三元组 (device_id, package, engage_date)。
|
||||
batch_op.drop_constraint('uq_coupon_engage_device_date', type_='unique')
|
||||
batch_op.create_unique_constraint(
|
||||
'uq_coupon_engage_device_pkg_date',
|
||||
['device_id', 'package', 'engage_date'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('uq_coupon_engage_device_pkg_date', type_='unique')
|
||||
batch_op.create_unique_constraint(
|
||||
'uq_coupon_engage_device_date',
|
||||
['device_id', 'engage_date'],
|
||||
)
|
||||
batch_op.drop_column('package')
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -1,61 +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 可空
|
||||
op.add_column(
|
||||
"cps_group",
|
||||
sa.Column(
|
||||
"platforms",
|
||||
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[\"meituan\"]'::jsonb"),
|
||||
),
|
||||
)
|
||||
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True)
|
||||
|
||||
# link:sid 可空 + target 加长
|
||||
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True)
|
||||
op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048))
|
||||
|
||||
# click:sid 可空 + 事件类型
|
||||
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True)
|
||||
op.add_column(
|
||||
"cps_click",
|
||||
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("cps_click", "event_type")
|
||||
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024))
|
||||
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.drop_column("cps_group", "platforms")
|
||||
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.drop_column("cps_activity", "payload")
|
||||
@@ -1,48 +0,0 @@
|
||||
"""cps 微信用户表 cps_wx_user + cps_click 加 openid
|
||||
|
||||
CPS 落地页微信网页授权拿 openid/昵称头像,做用户级群统计。
|
||||
|
||||
Revision ID: cps_wx_user
|
||||
Revises: comparison_debug_fields
|
||||
Create Date: 2026-06-19 12:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "cps_wx_user"
|
||||
down_revision: Union[str, Sequence[str], None] = "comparison_debug_fields"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cps_wx_user",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("openid", sa.String(64), nullable=False),
|
||||
sa.Column("unionid", sa.String(64), nullable=True),
|
||||
sa.Column("nickname", sa.String(128), nullable=True),
|
||||
sa.Column("headimgurl", sa.String(512), nullable=True),
|
||||
sa.Column("first_code", sa.String(16), nullable=True),
|
||||
sa.Column("first_group_id", sa.Integer(), nullable=True),
|
||||
sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_cps_wx_user_openid", "cps_wx_user", ["openid"], unique=True)
|
||||
op.create_index("ix_cps_wx_user_unionid", "cps_wx_user", ["unionid"])
|
||||
op.create_index("ix_cps_wx_user_first_group_id", "cps_wx_user", ["first_group_id"])
|
||||
|
||||
op.add_column("cps_click", sa.Column("openid", sa.String(64), nullable=True))
|
||||
op.create_index("ix_cps_click_openid", "cps_click", ["openid"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_cps_click_openid", table_name="cps_click")
|
||||
op.drop_column("cps_click", "openid")
|
||||
op.drop_index("ix_cps_wx_user_first_group_id", table_name="cps_wx_user")
|
||||
op.drop_index("ix_cps_wx_user_unionid", table_name="cps_wx_user")
|
||||
op.drop_index("ix_cps_wx_user_openid", table_name="cps_wx_user")
|
||||
op.drop_table("cps_wx_user")
|
||||
@@ -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,53 +0,0 @@
|
||||
"""launch_confirm_sample table (启动确认窗 agent 兜底样本: LLM 放行的弹窗样本沉淀)
|
||||
|
||||
Revision ID: launch_confirm_sample_table
|
||||
Revises: cps_wx_user
|
||||
Create Date: 2026-06-20 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "launch_confirm_sample_table"
|
||||
down_revision: Union[str, Sequence[str], None] = "cps_wx_user"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"launch_confirm_sample",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("device_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("host_package", sa.String(length=128), nullable=True),
|
||||
sa.Column("target_app", sa.String(length=128), nullable=True),
|
||||
sa.Column("system_locale", sa.String(length=32), nullable=True),
|
||||
sa.Column("exec_success", sa.Boolean(), nullable=False),
|
||||
sa.Column("dialog_title", sa.String(length=256), nullable=True),
|
||||
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
|
||||
sa.Column("payload", sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), "postgresql"), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_created_at"), ["created_at"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_trace_id"), ["trace_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_device_id"), ["device_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_host_package"), ["host_package"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_system_locale"), ["system_locale"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_system_locale"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_host_package"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_device_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_trace_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_created_at"))
|
||||
|
||||
op.drop_table("launch_confirm_sample")
|
||||
@@ -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')
|
||||
@@ -1,33 +0,0 @@
|
||||
"""store_mapping 加淘宝 deeplink 失效标记列 taobao_deeplink_invalid_at
|
||||
|
||||
Revision ID: store_mapping_tb_dl_invalid
|
||||
Revises: coupon_engage_per_package
|
||||
Create Date: 2026-06-15 00:00:00.000000
|
||||
|
||||
缓存的淘宝店内搜索 deeplink 会失效(打开是"页面出错了"降级页)。pricebot 比价撞到错误页时
|
||||
回退正常搜店, 并 server→server 通知把该 shopId 的 deeplink 标记失效。本列记失效时刻
|
||||
(NULL=有效); lookup 反查时过滤掉已失效的淘宝候选, 不再返回坏 deeplink。
|
||||
只加淘宝一列(当前只接淘宝); 用时间戳而非布尔: 留痕可审计、可统计失效率, 且不销毁原 deeplink。
|
||||
无需索引: 过滤总叠在 name_taobao== 之后, 候选集已小。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'store_mapping_tb_dl_invalid'
|
||||
down_revision: Union[str, Sequence[str], None] = 'coupon_engage_per_package'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('taobao_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.drop_column('taobao_deeplink_invalid_at')
|
||||
@@ -14,18 +14,13 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.admin.routers.ad_audit import router as ad_audit_router
|
||||
from app.admin.routers.ad_config import router as ad_config_router
|
||||
from app.admin.routers.ad_revenue import router as ad_revenue_router
|
||||
from app.admin.routers.admins import router as admins_router
|
||||
from app.admin.routers.audit import router as audit_router
|
||||
from app.admin.routers.auth import router as auth_router
|
||||
from app.admin.routers.comparison import router as comparison_router
|
||||
from app.admin.routers.config import router as config_router
|
||||
from app.admin.routers.cps import router as cps_router
|
||||
from app.admin.routers.dashboard import router as dashboard_router
|
||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||
from app.admin.routers.feedback import router as feedback_router
|
||||
from app.admin.routers.feedback_qr import router as feedback_qr_router
|
||||
from app.admin.routers.onboarding import router as onboarding_router
|
||||
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
|
||||
from app.admin.routers.price_report import router as price_report_router
|
||||
@@ -91,12 +86,7 @@ admin_app.include_router(wallet_router)
|
||||
admin_app.include_router(withdraw_router)
|
||||
admin_app.include_router(price_report_router)
|
||||
admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(feedback_qr_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(audit_router)
|
||||
admin_app.include_router(config_router)
|
||||
admin_app.include_router(comparison_router)
|
||||
admin_app.include_router(cps_router)
|
||||
admin_app.include_router(ad_audit_router)
|
||||
admin_app.include_router(ad_config_router)
|
||||
admin_app.include_router(ad_revenue_router)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 口径)→ 北京时间小时(0–23)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(rewards.CN_TZ).hour
|
||||
|
||||
|
||||
def _key(
|
||||
report_date: str,
|
||||
user_id: int,
|
||||
ad_type: str,
|
||||
app_env: str | None,
|
||||
our_code_id: str | None,
|
||||
hour: int | None,
|
||||
) -> tuple:
|
||||
return (report_date, user_id, ad_type, app_env or None, our_code_id or None, hour)
|
||||
|
||||
|
||||
def _date_range(date_from: str, date_to: str) -> list[str]:
|
||||
"""闭区间内逐日 'YYYY-MM-DD' 串(含首尾)。date_from > date_to 时返回空。"""
|
||||
d0 = _date.fromisoformat(date_from)
|
||||
d1 = _date.fromisoformat(date_to)
|
||||
out: list[str] = []
|
||||
d = d0
|
||||
while d <= d1:
|
||||
out.append(d.isoformat())
|
||||
d += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
# 审计行的 scene 与报表 ad_type 一一对应
|
||||
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
|
||||
|
||||
|
||||
def ad_revenue_report(
|
||||
db: Session,
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
user_id: int | None = None,
|
||||
ad_type: str | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
) -> dict:
|
||||
"""日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 date_from==date_to。
|
||||
|
||||
聚合键含**日期**:report_date × user × ad_type × app_env × our_code_id(× 北京小时,granularity=hour)。
|
||||
ad_type: None=全部 / reward_video / feed / draw。
|
||||
granularity: "day"=按天 / "hour"=按小时(聚合键再加北京小时 0–23,每组一行)。
|
||||
limit 只截断展示明细,total 与 total_* / daily 在全量上统计(不受 limit 影响),数字始终可信。
|
||||
|
||||
返回额外含 `daily`(按日期汇总的展示/收益/应发/实发,供前端按天趋势图;不受 limit 影响)。
|
||||
|
||||
注:按小时下,展示按 ecpm 记录的小时、金币按发奖记录的小时各自归桶——S2S 回调可能比展示晚
|
||||
一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
groups: dict[tuple, dict] = {}
|
||||
|
||||
def _grp(key: tuple) -> dict:
|
||||
g = groups.get(key)
|
||||
if g is None:
|
||||
rdate, uid, atype, app_env, code_id, hour = key
|
||||
g = {
|
||||
"report_date": rdate,
|
||||
"user_id": uid,
|
||||
"ad_type": atype,
|
||||
"app_env": app_env,
|
||||
"our_code_id": code_id,
|
||||
"hour": hour,
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
"adns": set(),
|
||||
"impression_records": [], # 该组逐条展示明细(展开下钻用)
|
||||
"records": [], # 该组逐条发奖复算明细(展开下钻用)
|
||||
}
|
||||
groups[key] = g
|
||||
return g
|
||||
|
||||
# 1) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序)
|
||||
stmt = select(AdEcpmRecord).where(
|
||||
AdEcpmRecord.report_date >= date_from,
|
||||
AdEcpmRecord.report_date <= date_to,
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
|
||||
if ad_type is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
|
||||
for rec in db.execute(stmt).scalars():
|
||||
hour = _cn_hour(rec.created_at) if by_hour else None
|
||||
g = _grp(_key(rec.report_date, rec.user_id, rec.ad_type, rec.app_env, rec.our_code_id, hour))
|
||||
g["impressions"] += 1
|
||||
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次);用与发奖同源的解析,口径一致。
|
||||
rev = rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0
|
||||
g["revenue_yuan"] += rev
|
||||
if rec.adn:
|
||||
g["adns"].add(rec.adn)
|
||||
g["impression_records"].append({
|
||||
"id": rec.id,
|
||||
"created_at": rec.created_at,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"revenue_yuan": round(rev, 6),
|
||||
"adn": rec.adn,
|
||||
"slot_id": rec.slot_id,
|
||||
})
|
||||
|
||||
# 2) 应发 / 实发金币 ← 复用金币审计逐条复算(同一公式口径),按同维度求和。
|
||||
# audit_rows 是单日的,区间逐日调用,每天的行归到当天 report_date(语义与单日报表完全一致)。
|
||||
# ad_type=draw 时审计无对应记录(scene 只有 reward_video/feed),金币侧自然为空。
|
||||
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
|
||||
if ad_type is None or audit_scene is not None:
|
||||
for d in _date_range(date_from, date_to):
|
||||
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
|
||||
atype = _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"])
|
||||
hour = _cn_hour(row["created_at"]) if by_hour else None
|
||||
g = _grp(_key(d, row["user_id"], atype, row.get("app_env"), row.get("our_code_id"), hour))
|
||||
g["expected_coin"] += int(row["expected_coin"])
|
||||
g["actual_coin"] += int(row["actual_coin"])
|
||||
# 逐条明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致)——前端展开该组时下钻展示。
|
||||
g["records"].append({
|
||||
"record_id": row["record_id"],
|
||||
"created_at": row["created_at"],
|
||||
"status": row["status"],
|
||||
"ecpm": row["ecpm"],
|
||||
"ecpm_factor": row["ecpm_factor"],
|
||||
"units": row["units"],
|
||||
"lt_index_start": row["lt_index_start"],
|
||||
"lt_index_end": row["lt_index_end"],
|
||||
"lt_factor_start": row["lt_factor_start"],
|
||||
"lt_factor_end": row["lt_factor_end"],
|
||||
"expected_coin": row["expected_coin"],
|
||||
"actual_coin": row["actual_coin"],
|
||||
"matched": row["matched"],
|
||||
})
|
||||
|
||||
rows = list(groups.values())
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
r["report_date"],
|
||||
r["user_id"],
|
||||
r["hour"] if r["hour"] is not None else -1,
|
||||
r["ad_type"] or "",
|
||||
r["our_code_id"] or "",
|
||||
)
|
||||
)
|
||||
|
||||
total_impressions = sum(r["impressions"] for r in rows)
|
||||
total_expected_coin = sum(r["expected_coin"] for r in rows)
|
||||
total_actual_coin = sum(r["actual_coin"] for r in rows)
|
||||
total_revenue_yuan = round(sum(r["revenue_yuan"] for r in rows), 6)
|
||||
|
||||
# 按日期汇总(全量,不受 limit):供前端按天趋势图。
|
||||
daily_map: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
d = daily_map.get(r["report_date"])
|
||||
if d is None:
|
||||
d = {
|
||||
"date": r["report_date"],
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
}
|
||||
daily_map[r["report_date"]] = d
|
||||
d["impressions"] += r["impressions"]
|
||||
d["revenue_yuan"] += r["revenue_yuan"]
|
||||
d["expected_coin"] += r["expected_coin"]
|
||||
d["actual_coin"] += r["actual_coin"]
|
||||
daily = [
|
||||
{**d, "revenue_yuan": round(d["revenue_yuan"], 6)}
|
||||
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||
]
|
||||
|
||||
items = [
|
||||
{
|
||||
"report_date": r["report_date"],
|
||||
"user_id": r["user_id"],
|
||||
"ad_type": r["ad_type"],
|
||||
"app_env": r["app_env"],
|
||||
"our_code_id": r["our_code_id"],
|
||||
"hour": r["hour"],
|
||||
"impressions": r["impressions"],
|
||||
"revenue_yuan": round(r["revenue_yuan"], 6),
|
||||
"expected_coin": r["expected_coin"],
|
||||
"actual_coin": r["actual_coin"],
|
||||
# 组内**逐条**全部一致才记一致——不能用「应发和==实发和」,否则一条多发+一条少发会互相
|
||||
# 抵消、求和相等被误判为 ✓,掩盖真实发奖错误。纯展示无发奖记录的组 all([]) → True。
|
||||
"matched": all(rec["matched"] for rec in r["records"]),
|
||||
"adns": sorted(r["adns"]),
|
||||
"impression_records": sorted(
|
||||
r["impression_records"], key=lambda x: (x["created_at"], x["id"])
|
||||
),
|
||||
"records": sorted(r["records"], key=lambda x: (x["created_at"], x["record_id"])),
|
||||
}
|
||||
for r in rows[:limit]
|
||||
]
|
||||
|
||||
return {
|
||||
"total": len(rows),
|
||||
"truncated": len(rows) > limit,
|
||||
"total_impressions": total_impressions,
|
||||
"total_revenue_yuan": total_revenue_yuan,
|
||||
"total_expected_coin": total_expected_coin,
|
||||
"total_actual_coin": total_actual_coin,
|
||||
"mismatch_count": sum(
|
||||
1 for r in rows if not all(rec["matched"] for rec in r["records"])
|
||||
),
|
||||
"daily": daily,
|
||||
"items": items,
|
||||
}
|
||||
@@ -1,517 +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, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.queries import _as_utc, offset_paginate
|
||||
from app.integrations import meituan
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_group import CpsGroup
|
||||
from app.models.cps_link import CpsClick
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
|
||||
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
||||
_INVALID_STATUS = {"4", "5"}
|
||||
_SETTLED_STATUS = "6"
|
||||
|
||||
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
|
||||
_BJ_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
# ───────────── 单位换算 ─────────────
|
||||
def _yuan_to_cents(v: object) -> int | None:
|
||||
"""「元」字符串/数 → 分。None / 空 / 字面 "null" → None。"""
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
if not s or s.lower() == "null":
|
||||
return None
|
||||
try:
|
||||
return int((Decimal(s) * 100).to_integral_value())
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _ts_to_dt(ts: object) -> datetime | None:
|
||||
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
||||
except (ValueError, OSError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ───────────── 群 ─────────────
|
||||
def get_group(db: Session, group_id: int) -> CpsGroup | None:
|
||||
return db.get(CpsGroup, group_id)
|
||||
|
||||
|
||||
def get_group_by_sid(db: Session, sid: str) -> CpsGroup | None:
|
||||
return db.execute(select(CpsGroup).where(CpsGroup.sid == sid)).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_groups(
|
||||
db: Session, *, keyword: str | None = None, status: str | None = None,
|
||||
limit: int = 20, cursor: int | None = None,
|
||||
) -> tuple[list[CpsGroup], int | None, int]:
|
||||
stmt = select(CpsGroup)
|
||||
if keyword and keyword.strip():
|
||||
kw = f"%{keyword.strip()}%"
|
||||
stmt = stmt.where(CpsGroup.name.ilike(kw) | CpsGroup.sid.ilike(kw))
|
||||
if status:
|
||||
stmt = stmt.where(CpsGroup.status == status)
|
||||
return offset_paginate(db, stmt, (desc(CpsGroup.id),), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def create_group(
|
||||
db: Session, *, name: str, platforms: list[str], sid: str | None = None,
|
||||
member_count: int | None = None, remark: str | None = None, commit: bool = True,
|
||||
) -> CpsGroup:
|
||||
"""建群。含 meituan 才有 sid(留空自动 g<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:
|
||||
# naive(本地 SQLite 读回无 tz)按 UTC 兜底,再转北京——与 queries._as_utc 约定一致,
|
||||
# 否则 astimezone 会把 UTC 瞬刻当系统本地时区解释,分桶整体偏移。
|
||||
ca = clicked_at if clicked_at.tzinfo else clicked_at.replace(tzinfo=timezone.utc)
|
||||
bj = ca.astimezone(_BJ_TZ)
|
||||
key = bj.strftime("%Y-%m-%d") if granularity == "day" else bj.hour
|
||||
b = agg.setdefault(key, {"vp": 0, "vu": set(), "cp": 0, "cu": set()})
|
||||
uid = f"{ip or ''}|{ua or ''}"
|
||||
if event_type == "copy":
|
||||
b["cp"] += 1
|
||||
b["cu"].add(uid)
|
||||
else:
|
||||
b["vp"] += 1
|
||||
b["vu"].add(uid)
|
||||
|
||||
def _point(label: str, b: dict | None) -> dict:
|
||||
return {
|
||||
"label": label,
|
||||
"click_pv": b["vp"] if b else 0,
|
||||
"click_uv": len(b["vu"]) if b else 0,
|
||||
"copy_pv": b["cp"] if b else 0,
|
||||
"copy_uv": len(b["cu"]) if b else 0,
|
||||
}
|
||||
|
||||
points: list[dict] = []
|
||||
if granularity == "day":
|
||||
cur = start.astimezone(_BJ_TZ).date()
|
||||
last = end.astimezone(_BJ_TZ).date()
|
||||
while cur <= last:
|
||||
points.append(_point(cur.strftime("%m-%d"), agg.get(cur.strftime("%Y-%m-%d"))))
|
||||
cur += timedelta(days=1)
|
||||
else:
|
||||
for h in range(24):
|
||||
points.append(_point(f"{h:02d}:00", agg.get(h)))
|
||||
return points
|
||||
|
||||
|
||||
def group_order_daily(
|
||||
db: Session, *, sid: str, start: datetime, end: datetime,
|
||||
) -> dict[str, dict]:
|
||||
"""美团群订单按天(北京时区,key=YYYY-MM-DD)聚合。口径与 group_stats._order_agg 一致:
|
||||
取消(4)/风控(5)不计有效;结算只数 status=6。退款佣金取当天全部单的 refund_profit_cents 之和。
|
||||
返回 {date: {order_count, canceled_count, gmv_cents, est_commission_cents,
|
||||
refund_profit_cents, settled_commission_cents}};无单的天不在 dict 里(端点侧补零)。
|
||||
"""
|
||||
rows = (
|
||||
db.execute(
|
||||
select(CpsOrder)
|
||||
.where(CpsOrder.sid == sid)
|
||||
.where(CpsOrder.pay_time >= _as_utc(start))
|
||||
.where(CpsOrder.pay_time <= _as_utc(end))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
by_day: dict[str, list] = {}
|
||||
for o in rows:
|
||||
if not o.pay_time:
|
||||
continue
|
||||
pt = o.pay_time if o.pay_time.tzinfo else o.pay_time.replace(tzinfo=timezone.utc)
|
||||
d = pt.astimezone(_BJ_TZ).strftime("%Y-%m-%d")
|
||||
by_day.setdefault(d, []).append(o)
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
for d, items in by_day.items():
|
||||
valid = [o for o in items if o.mt_status not in _INVALID_STATUS]
|
||||
settled = [o for o in items if o.mt_status == _SETTLED_STATUS]
|
||||
canceled = [o for o in items if o.mt_status in _INVALID_STATUS]
|
||||
out[d] = {
|
||||
"order_count": len(valid),
|
||||
"canceled_count": len(canceled),
|
||||
"gmv_cents": sum(o.pay_price_cents or 0 for o in valid),
|
||||
"est_commission_cents": sum(o.commission_cents or 0 for o in valid),
|
||||
"refund_profit_cents": sum(o.refund_profit_cents or 0 for o in items),
|
||||
"settled_commission_cents": sum(o.commission_cents or 0 for o in settled),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict]:
|
||||
"""群内微信用户(首次来源 first_group_id=group_id)+ 每人在该群的 visit/copy 次数(领券画像)。
|
||||
|
||||
按首次进入倒序。copy 次数 = 该用户在该群点了几次「复制口令」(领券意向),visit = 进落地页次数。
|
||||
"""
|
||||
users = (
|
||||
db.execute(
|
||||
select(CpsWxUser)
|
||||
.where(CpsWxUser.first_group_id == group_id)
|
||||
.order_by(desc(CpsWxUser.first_seen))
|
||||
.limit(limit)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not users:
|
||||
return []
|
||||
openids = [u.openid for u in users]
|
||||
stat: dict[str, dict] = {}
|
||||
rows = db.execute(
|
||||
select(CpsClick.openid, CpsClick.event_type, func.count())
|
||||
.where(CpsClick.group_id == group_id)
|
||||
.where(CpsClick.openid.in_(openids))
|
||||
.group_by(CpsClick.openid, CpsClick.event_type)
|
||||
).all()
|
||||
for openid, event_type, cnt in rows:
|
||||
s = stat.setdefault(openid, {"visit": 0, "copy": 0})
|
||||
if event_type == "copy":
|
||||
s["copy"] = cnt
|
||||
else:
|
||||
s["visit"] = cnt
|
||||
return [
|
||||
{
|
||||
"openid": u.openid,
|
||||
"nickname": u.nickname,
|
||||
"headimgurl": u.headimgurl,
|
||||
"first_seen": u.first_seen,
|
||||
"visit_count": stat.get(u.openid, {}).get("visit", 0),
|
||||
"copy_count": stat.get(u.openid, {}).get("copy", 0),
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
@@ -11,9 +11,6 @@ from zoneinfo import ZoneInfo
|
||||
from sqlalchemy import Select, asc, desc, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.admin import AdminAuditLog
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.feedback import Feedback
|
||||
@@ -22,16 +19,6 @@ from app.models.price_report import PriceReport
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
||||
|
||||
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
|
||||
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
|
||||
|
||||
# 信息流点位场景 → 金币记录「赚取途径」展示名;NULL/未知 = 历史未分类。
|
||||
_FEED_SCENE_LABEL = {
|
||||
"comparison": "比价信息流",
|
||||
"coupon": "领券信息流",
|
||||
"welfare": "福利信息流",
|
||||
}
|
||||
|
||||
|
||||
def cursor_paginate(
|
||||
db: Session, stmt: Select, id_col, *, limit: int, cursor: int | None
|
||||
@@ -122,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:每台设备走过引导的账号数 +
|
||||
最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会
|
||||
@@ -344,38 +274,6 @@ def _as_utc(value: datetime) -> datetime:
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def withdraw_list_enrichment(db: Session, user_ids: list[int]) -> dict[int, dict]:
|
||||
"""批量富化提现单列表:按本页 user_id 取 手机号/昵称 + 各自累计成功提现金额(分)。
|
||||
|
||||
两条聚合查询搞定(避免逐行 N+1)。累计口径与 get_user_overview 的 withdraw_success_cents
|
||||
完全一致:SUM(amount_cents) WHERE status='success'。返回 {user_id: {phone, nickname,
|
||||
cumulative_success_cents}};某 user_id 查不到用户则不在 dict 里(路由侧回退到默认值)。
|
||||
"""
|
||||
uniq = list(set(user_ids))
|
||||
if not uniq:
|
||||
return {}
|
||||
success_rows = db.execute(
|
||||
select(
|
||||
WithdrawOrder.user_id,
|
||||
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
|
||||
)
|
||||
.where(WithdrawOrder.user_id.in_(uniq), WithdrawOrder.status == "success")
|
||||
.group_by(WithdrawOrder.user_id)
|
||||
).all()
|
||||
success_map = {uid: int(total) for uid, total in success_rows}
|
||||
users = db.execute(
|
||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uniq))
|
||||
).all()
|
||||
return {
|
||||
uid: {
|
||||
"phone": phone,
|
||||
"nickname": nickname,
|
||||
"cumulative_success_cents": success_map.get(uid, 0),
|
||||
}
|
||||
for uid, phone, nickname in users
|
||||
}
|
||||
|
||||
|
||||
def list_feedbacks(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -388,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)
|
||||
@@ -409,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:
|
||||
@@ -591,176 +495,6 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
|
||||
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
|
||||
conds = []
|
||||
if date_from is not None:
|
||||
conds.append(col >= _as_utc_naive(date_from))
|
||||
if date_to is not None:
|
||||
conds.append(col <= _as_utc_naive(date_to))
|
||||
return conds
|
||||
|
||||
|
||||
def _coins_to_cents(coins: int) -> int:
|
||||
"""累计金币折算成可提现现金(分)。COIN_PER_YUAN=10000 → 100 金币 = 1 分。"""
|
||||
return round(coins / (rewards.COIN_PER_YUAN / 100))
|
||||
|
||||
|
||||
def user_reward_stats(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
) -> dict:
|
||||
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
|
||||
|
||||
口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加);
|
||||
平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。
|
||||
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
|
||||
"""
|
||||
wd_success = db.execute(
|
||||
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.status == "success",
|
||||
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
|
||||
)
|
||||
).scalar_one()
|
||||
wd_total = db.execute(
|
||||
select(func.count(WithdrawOrder.id)).where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口
|
||||
cash_balance = acc.cash_balance_cents if acc else 0
|
||||
|
||||
rv = list(db.execute(
|
||||
select(AdRewardRecord).where(
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
AdRewardRecord.status == "granted",
|
||||
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
||||
)
|
||||
).scalars())
|
||||
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
|
||||
rv_coins = sum(r.coin for r in rv)
|
||||
|
||||
feed = list(db.execute(
|
||||
select(AdFeedRewardRecord).where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
||||
)
|
||||
).scalars())
|
||||
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
|
||||
feed_coins = sum(f.coin for f in feed)
|
||||
|
||||
trad_coins = db.execute(
|
||||
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
|
||||
CoinTransaction.user_id == user_id,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type.not_in(_NON_TASK_BIZ_TYPES),
|
||||
*_window_conds(CoinTransaction.created_at, date_from, date_to),
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
return {
|
||||
"withdraw_success_cents": int(wd_success),
|
||||
"cash_balance_cents": int(cash_balance),
|
||||
"withdraw_total": int(wd_total),
|
||||
"traditional_task_cash_cents": _coins_to_cents(int(trad_coins)),
|
||||
"reward_video_count": len(rv),
|
||||
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
|
||||
"reward_video_cash_cents": _coins_to_cents(rv_coins),
|
||||
"feed_count": int(sum(f.unit_count for f in feed)),
|
||||
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
|
||||
"feed_cash_cents": _coins_to_cents(feed_coins),
|
||||
}
|
||||
|
||||
|
||||
def user_coin_records(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[dict], int | None]:
|
||||
"""金币发放记录(提现详情底部表),按时间倒序、offset 游标分页。
|
||||
|
||||
合并三类来源(Phase1 信息流不分比价/领券):
|
||||
- 激励视频 / 签到膨胀 = ad_reward_record(granted)
|
||||
- 信息流广告 = ad_feed_reward_record(granted)
|
||||
- 签到 = coin_transaction(biz_type='signin')
|
||||
每来源各取 offset+limit+1 条(倒序)再归并切片,避免 union 全量拉取。
|
||||
"""
|
||||
offset = max(cursor or 0, 0)
|
||||
fetch = offset + limit + 1
|
||||
rows: list[dict] = []
|
||||
|
||||
for rec in db.execute(
|
||||
select(AdRewardRecord)
|
||||
.where(
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.status == "granted",
|
||||
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
||||
)
|
||||
.order_by(AdRewardRecord.created_at.desc())
|
||||
.limit(fetch)
|
||||
).scalars():
|
||||
is_video = rec.reward_scene == "reward_video"
|
||||
rows.append({
|
||||
"source": rec.reward_scene,
|
||||
"source_label": "激励视频" if is_video else "签到膨胀",
|
||||
"created_at": rec.created_at,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"coin": rec.coin,
|
||||
})
|
||||
|
||||
for rec in db.execute(
|
||||
select(AdFeedRewardRecord)
|
||||
.where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
||||
)
|
||||
.order_by(AdFeedRewardRecord.created_at.desc())
|
||||
.limit(fetch)
|
||||
).scalars():
|
||||
rows.append({
|
||||
"source": "feed",
|
||||
"source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"),
|
||||
"created_at": rec.created_at,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"coin": rec.coin,
|
||||
})
|
||||
|
||||
for rec in db.execute(
|
||||
select(CoinTransaction)
|
||||
.where(
|
||||
CoinTransaction.user_id == user_id,
|
||||
CoinTransaction.biz_type == "signin",
|
||||
*_window_conds(CoinTransaction.created_at, date_from, date_to),
|
||||
)
|
||||
.order_by(CoinTransaction.created_at.desc())
|
||||
.limit(fetch)
|
||||
).scalars():
|
||||
rows.append({
|
||||
"source": "signin",
|
||||
"source_label": "签到",
|
||||
"created_at": rec.created_at,
|
||||
"ecpm": None,
|
||||
"coin": rec.amount,
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
has_more = len(rows) > offset + limit
|
||||
return rows[offset:offset + limit], (offset + limit if has_more else None)
|
||||
|
||||
|
||||
def list_price_reports(
|
||||
db: Session,
|
||||
*,
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""admin 广告配置:读/写穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
|
||||
|
||||
存在 app_config 表的 ad_config dict(见 repositories/app_config.get_ad_config/set_ad_config)。
|
||||
客户端经 /api/v1/platform/ad-config 拉取(不含 reward_mkey)。权限 operator/finance + 审计。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.schemas.ad_config import AdConfigOut, AdConfigUpdate
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import app_config
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/ad-config",
|
||||
tags=["admin-ad-config"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AdConfigOut, summary="广告配置(穿山甲ID/验签密钥/各场景开关)")
|
||||
def get_ad_config(db: AdminDb) -> AdConfigOut:
|
||||
return AdConfigOut(**app_config.get_ad_config(db))
|
||||
|
||||
|
||||
@router.patch("", response_model=AdConfigOut, summary="改广告配置(部分更新,带审计)")
|
||||
def update_ad_config(
|
||||
body: AdConfigUpdate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))],
|
||||
db: AdminDb,
|
||||
) -> AdConfigOut:
|
||||
data = body.model_dump(exclude_none=True)
|
||||
app_config.set_ad_config(db, data, admin_id=admin.id, commit=False)
|
||||
# 审计只记改了哪些字段,不记 mkey 明文(机密)
|
||||
write_audit(
|
||||
db, admin, action="ad_config.set", target_type="ad_config", target_id="ad_config",
|
||||
detail={"changed": sorted(data.keys())}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return AdConfigOut(**app_config.get_ad_config(db))
|
||||
@@ -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"]],
|
||||
)
|
||||
@@ -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|cancelled)$")] = None,
|
||||
business_type: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminComparisonListItem]:
|
||||
items, next_cursor, total = queries.list_comparison_records(
|
||||
db, user_id=user_id, phone=phone, status=status,
|
||||
business_type=business_type, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AdminComparisonListItem.model_validate(r) for r in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{record_id}",
|
||||
response_model=AdminComparisonDetail,
|
||||
summary="比价记录详情(含 LLM 每次调用明细 + 原始 payload)",
|
||||
)
|
||||
def get_comparison_record(record_id: int, db: AdminDb) -> AdminComparisonDetail:
|
||||
rec = queries.get_comparison_record(db, record_id)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
return AdminComparisonDetail.model_validate(rec)
|
||||
@@ -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:
|
||||
|
||||
@@ -1,498 +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,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/daily", summary="群每天明细大表格(点击+订单按天合并)")
|
||||
def group_daily(
|
||||
group_id: int,
|
||||
db: AdminDb,
|
||||
days: Annotated[int, Query(ge=1, le=90)] = 7,
|
||||
) -> dict:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
bj = timezone(timedelta(hours=8))
|
||||
now_bj = datetime.now(bj)
|
||||
start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = now_bj
|
||||
# 点击侧:连续 N 天补零,顺序 = start..end(与下面 while 同序,按 index 对齐避免跨年 MM-DD 碰撞)
|
||||
click_points = cps_repo.group_click_timeseries(
|
||||
db, group_id=group_id, granularity="day", start=start, end=end
|
||||
)
|
||||
# 订单侧:仅美团群(有 sid)有对账;淘宝/京东订单列返回 None(前端显示 "-")
|
||||
is_meituan = "meituan" in (group.platforms or []) and bool(group.sid)
|
||||
order_daily = (
|
||||
cps_repo.group_order_daily(db, sid=group.sid, start=start, end=end) if is_meituan else {}
|
||||
)
|
||||
_ORDER_KEYS = (
|
||||
"order_count", "canceled_count", "gmv_cents",
|
||||
"est_commission_cents", "refund_profit_cents", "settled_commission_cents",
|
||||
)
|
||||
rows = []
|
||||
cur = start.astimezone(bj).date()
|
||||
last = end.astimezone(bj).date()
|
||||
idx = 0
|
||||
while cur <= last:
|
||||
cp = click_points[idx] if idx < len(click_points) else None
|
||||
row = {
|
||||
"date": cur.strftime("%m-%d"),
|
||||
"click_pv": cp["click_pv"] if cp else 0,
|
||||
"click_uv": cp["click_uv"] if cp else 0,
|
||||
"copy_pv": cp["copy_pv"] if cp else 0,
|
||||
}
|
||||
if is_meituan:
|
||||
od = order_daily.get(cur.strftime("%Y-%m-%d"))
|
||||
row.update({k: (od[k] if od else 0) for k in _ORDER_KEYS})
|
||||
else:
|
||||
row.update({k: None for k in _ORDER_KEYS})
|
||||
rows.append(row)
|
||||
idx += 1
|
||||
cur += timedelta(days=1)
|
||||
return {
|
||||
"group_id": group.id,
|
||||
"group_name": group.name,
|
||||
"is_meituan": is_meituan,
|
||||
"days": days,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/wx-users", summary="群内微信用户(领券画像:头像/昵称/领券次数)")
|
||||
def group_wx_users(group_id: int, db: AdminDb) -> dict:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""admin 反馈页二维码卡配置:读 / 改文案开关 / 上传二维码 / 删二维码(带审计)。
|
||||
|
||||
整张卡存通用 app_config 表(见 app/repositories/feedback_qr.py),App 反馈页拉
|
||||
GET /api/v1/feedback/config 同步。权限:operator 可改(运营维护),super 恒可;
|
||||
读为只读(任意已登录 admin)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, 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.schemas.feedback_qr import FeedbackQrOut, FeedbackQrUpdate
|
||||
from app.core import media
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import feedback_qr
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/feedback-config",
|
||||
tags=["admin-feedback-config"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=FeedbackQrOut, summary="反馈页二维码卡配置")
|
||||
def get_config(db: AdminDb) -> FeedbackQrOut:
|
||||
return FeedbackQrOut(**feedback_qr.get_config(db))
|
||||
|
||||
|
||||
@router.patch("", response_model=FeedbackQrOut, summary="改反馈页文案/开关(带审计)")
|
||||
def update_config(
|
||||
body: FeedbackQrUpdate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> FeedbackQrOut:
|
||||
before, after = feedback_qr.update_config(
|
||||
db,
|
||||
enabled=body.enabled,
|
||||
title=body.title,
|
||||
group_name=body.group_name,
|
||||
subtitle=body.subtitle,
|
||||
admin_id=admin.id,
|
||||
commit=False,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="feedback_qr.update", target_type="feedback_qr", target_id=None,
|
||||
detail={"before": before, "after": after}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return FeedbackQrOut(**after)
|
||||
|
||||
|
||||
@router.post("/image", response_model=FeedbackQrOut, summary="上传反馈页二维码(带审计)")
|
||||
async def upload_image(
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
file: UploadFile = File(...),
|
||||
) -> FeedbackQrOut:
|
||||
data = await file.read()
|
||||
try:
|
||||
url = media.save_feedback_qr(data)
|
||||
except media.MediaError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
before, after = feedback_qr.set_image(db, url, admin_id=admin.id, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="feedback_qr.set_image", target_type="feedback_qr", target_id=None,
|
||||
detail={"before": before.get("image_url"), "after": url},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
media.delete_feedback_qr(before.get("image_url")) # 提交成功后再删旧图,避免新图未落库就丢旧图
|
||||
return FeedbackQrOut(**after)
|
||||
|
||||
|
||||
@router.delete("/image", response_model=FeedbackQrOut, summary="移除反馈页二维码(带审计)")
|
||||
def delete_image(
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> FeedbackQrOut:
|
||||
before, after = feedback_qr.set_image(db, None, admin_id=admin.id, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="feedback_qr.delete_image", target_type="feedback_qr", target_id=None,
|
||||
detail={"before": before.get("image_url")}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
media.delete_feedback_qr(before.get("image_url"))
|
||||
return FeedbackQrOut(**after)
|
||||
@@ -17,8 +17,6 @@ from app.admin.schemas.user import (
|
||||
GrantCoinsRequest,
|
||||
SetDebugTraceRequest,
|
||||
SetUserStatusRequest,
|
||||
UserCoinRecord,
|
||||
UserRewardStats,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import user as user_repo
|
||||
@@ -68,46 +66,6 @@ def get_user(user_id: int, db: AdminDb) -> AdminUserOverview:
|
||||
return AdminUserOverview.model_validate(overview)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}/reward-stats",
|
||||
response_model=UserRewardStats,
|
||||
summary="用户提现/看广告统计(提现详情用,按时间窗口)",
|
||||
)
|
||||
def get_user_reward_stats(
|
||||
user_id: int,
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime | None, Query()] = None,
|
||||
date_to: Annotated[datetime | None, Query()] = None,
|
||||
) -> UserRewardStats:
|
||||
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
|
||||
if user_repo.get_user_by_id(db, user_id) is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return UserRewardStats(
|
||||
**queries.user_reward_stats(db, user_id, date_from=date_from, date_to=date_to)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}/coin-records",
|
||||
response_model=CursorPage[UserCoinRecord],
|
||||
summary="用户金币发放记录(提现详情底部表,按时间窗口分页)",
|
||||
)
|
||||
def get_user_coin_records(
|
||||
user_id: int,
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime | None, Query()] = None,
|
||||
date_to: Annotated[datetime | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[UserCoinRecord]:
|
||||
items, next_cursor = queries.user_coin_records(
|
||||
db, user_id, date_from=date_from, date_to=date_to, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[UserCoinRecord(**r) for r in items], next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{user_id}/status", response_model=OkResponse, summary="封禁/解封用户")
|
||||
def set_user_status(
|
||||
user_id: int,
|
||||
|
||||
@@ -25,7 +25,6 @@ from app.admin.schemas.wallet import (
|
||||
WithdrawBulkItemResult,
|
||||
WithdrawDetailOut,
|
||||
WithdrawLedgerCheckOut,
|
||||
WithdrawListItemOut,
|
||||
WithdrawOrderOut,
|
||||
WithdrawRejectRequest,
|
||||
WithdrawSummaryOut,
|
||||
@@ -35,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(
|
||||
@@ -45,7 +43,7 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[WithdrawListItemOut], summary="提现单列表")
|
||||
@router.get("", response_model=CursorPage[WithdrawOrderOut], summary="提现单列表")
|
||||
def list_withdraws(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
@@ -64,7 +62,7 @@ def list_withdraws(
|
||||
] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[WithdrawListItemOut]:
|
||||
) -> CursorPage[WithdrawOrderOut]:
|
||||
items, next_cursor, total = queries.list_all_withdraw_orders(
|
||||
db,
|
||||
user_id=user_id,
|
||||
@@ -79,16 +77,11 @@ def list_withdraws(
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
# 联表带出手机号/昵称 + 累计成功提现(本页 user_id 批量富化,2 条聚合查询,无 N+1)。
|
||||
enrichment = queries.withdraw_list_enrichment(db, [o.user_id for o in items])
|
||||
_empty = {"phone": None, "nickname": None, "cumulative_success_cents": 0}
|
||||
out_items = [
|
||||
WithdrawListItemOut.model_validate(o).model_copy(
|
||||
update=enrichment.get(o.user_id, _empty)
|
||||
)
|
||||
for o in items
|
||||
]
|
||||
return CursorPage(items=out_items, next_cursor=next_cursor, total=total)
|
||||
return CursorPage(
|
||||
items=[WithdrawOrderOut.model_validate(o) for o in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
|
||||
@@ -102,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] = []
|
||||
@@ -124,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,
|
||||
@@ -145,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,
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""admin 广告配置 schemas(穿山甲 app_id/各位ID/验签密钥/各场景开关)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AdConfigOut(BaseModel):
|
||||
"""admin 读到的完整广告配置(含 reward_mkey;admin 有权见,但绝不经 /platform 下发客户端)。"""
|
||||
|
||||
app_id: str
|
||||
reward_code_id: str
|
||||
compare_feed_code_id: str
|
||||
coupon_feed_code_id: str
|
||||
reward_mkey: str
|
||||
reward_enabled: bool
|
||||
compare_ad_enabled: bool
|
||||
coupon_ad_enabled: bool
|
||||
|
||||
|
||||
class AdConfigUpdate(BaseModel):
|
||||
"""部分更新:只改传入(非 None)字段。空串是合法值(如清空 mkey 回退 .env)。"""
|
||||
|
||||
app_id: str | None = None
|
||||
reward_code_id: str | None = None
|
||||
compare_feed_code_id: str | None = None
|
||||
coupon_feed_code_id: str | None = None
|
||||
reward_mkey: str | None = None
|
||||
reward_enabled: bool | None = None
|
||||
compare_ad_enabled: bool | None = None
|
||||
coupon_ad_enabled: bool | None = None
|
||||
@@ -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="北京时间小时 0–23(granularity=hour 时有值;按天为 null)")
|
||||
impressions: int = Field(..., description="展示条数(每条广告展示一条;轮播每条各计一次)")
|
||||
revenue_yuan: float = Field(..., description="收益(元)= Σ(eCPM元 ÷ 1000);测试应用多为 0")
|
||||
expected_coin: int = Field(..., description="应发金币(按公式复算,与金币审计同源)")
|
||||
actual_coin: int = Field(..., description="实发金币(实际入账,按现发奖算法)")
|
||||
matched: bool = Field(..., description="该组应发==实发(组内任一条不符则 false)")
|
||||
adns: list[str] = Field(default_factory=list, description="实际填充的底层 ADN 子渠道集合(如 pangle/gdt)")
|
||||
impression_records: list[AdRevenueImpression] = Field(
|
||||
default_factory=list,
|
||||
description="该组逐条展示明细(时间/eCPM/收益/adn);展开下钻用,无发奖也有(只要有展示)",
|
||||
)
|
||||
records: list[AdRevenueRecord] = Field(
|
||||
default_factory=list,
|
||||
description="该组逐条发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);展开下钻用,纯展示无发奖记录的组为空",
|
||||
)
|
||||
|
||||
|
||||
class AdRevenueReportOut(BaseModel):
|
||||
"""报表响应:全量统计 + 按天趋势 + 聚合明细。"""
|
||||
|
||||
date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)")
|
||||
date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)")
|
||||
daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)")
|
||||
total: int = Field(..., description="聚合组总数(全量,不受 limit 影响)")
|
||||
truncated: bool = Field(..., description="明细是否被 limit 截断")
|
||||
total_impressions: int = Field(..., description="全量展示条数合计")
|
||||
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
|
||||
total_expected_coin: int = Field(..., description="全量应发金币合计")
|
||||
total_actual_coin: int = Field(..., description="全量实发金币合计")
|
||||
mismatch_count: int = Field(..., description="应发≠实发的组数(=0 说明全部按公式发放)")
|
||||
items: list[AdRevenueRow] = Field(..., description="聚合明细(按 用户→类型→代码位 排序)")
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,22 +0,0 @@
|
||||
"""admin 反馈页二维码卡配置 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FeedbackQrOut(BaseModel):
|
||||
enabled: bool
|
||||
image_url: str | None = None
|
||||
title: str
|
||||
group_name: str
|
||||
subtitle: str
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class FeedbackQrUpdate(BaseModel):
|
||||
"""改文案/开关(均可选,只改传了的字段;图片走单独的上传/删除接口)。"""
|
||||
|
||||
enabled: bool | None = None
|
||||
title: str | None = Field(default=None, max_length=40)
|
||||
group_name: str | None = Field(default=None, max_length=40)
|
||||
subtitle: str | None = Field(default=None, max_length=60)
|
||||
@@ -37,36 +37,6 @@ class AdminUserOverview(BaseModel):
|
||||
feedback_total: int
|
||||
|
||||
|
||||
class UserRewardStats(BaseModel):
|
||||
"""提现详情抽屉「用户统计区」:按时间窗口算的提现 + 看广告行为统计。
|
||||
|
||||
窗口由 date_from/date_to 决定(都不传 = 注册至今 = 全量);现金余额除外——它是当前快照。
|
||||
各「*_cash_cents」是把该来源累计发放金币折算成可提现现金(分):100 金币 = 1 分(COIN_PER_YUAN=10000)。
|
||||
eCPM 单位沿用穿山甲原值「分/千次」。
|
||||
"""
|
||||
|
||||
withdraw_success_cents: int # 累计提现(窗口内 success 金额)
|
||||
cash_balance_cents: int # 现金余额(当前快照,不随窗口)
|
||||
withdraw_total: int # 提现总次数(窗口内全部状态)
|
||||
traditional_task_cash_cents: int # 传统任务提现(非广告非人工的金币折现)
|
||||
reward_video_count: int # 累计激励视频数(granted 条数)
|
||||
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
|
||||
reward_video_cash_cents: int # 激励视频提现(金币折现)
|
||||
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
|
||||
feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次)
|
||||
feed_cash_cents: int # 信息流广告提现(金币折现)
|
||||
|
||||
|
||||
class UserCoinRecord(BaseModel):
|
||||
"""金币发放记录(提现详情底部表)。source 见 source_label;非广告来源 ecpm 为 None。"""
|
||||
|
||||
source: str # reward_video / signin_boost / feed / signin
|
||||
source_label: str # 激励视频 / 签到膨胀 / 信息流广告 / 签到
|
||||
created_at: datetime
|
||||
ecpm: str | None = None # 原始 eCPM(分/千次),非广告为 None
|
||||
coin: int # 发放金币数
|
||||
|
||||
|
||||
def _strip_reason(v: str) -> str:
|
||||
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因
|
||||
if not v.strip():
|
||||
|
||||
@@ -50,17 +50,6 @@ class WithdrawOrderOut(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WithdrawListItemOut(WithdrawOrderOut):
|
||||
"""提现单列表项:在提现单字段基础上,补本页用户的手机号/昵称 + 累计成功提现金额(分)。
|
||||
|
||||
仅列表接口用(需联表 User + 聚合);详情/批量仍用 WithdrawOrderOut(不带这些字段)。
|
||||
"""
|
||||
|
||||
phone: str | None = None
|
||||
nickname: str | None = None
|
||||
cumulative_success_cents: int = 0 # 累计成功提现 = SUM(amount_cents) WHERE status='success'
|
||||
|
||||
|
||||
class WithdrawSummaryOut(BaseModel):
|
||||
reviewing_count: int
|
||||
reviewing_amount_cents: int
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""最新 App 版本写入端点(发布流程 → app-server)。
|
||||
|
||||
发车流程出 APK 后,把版本号 / 下载链接 / sha256 等 POST 到这里,落 app_config(key=latest_app_version)。
|
||||
客户端再 GET /api/v1/platform/app-version 读取做 OTA 检查更新。**不是给客户端的接口**:
|
||||
不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。
|
||||
密钥未配置(默认空)时直接 503,避免裸奔的写端点。也是应急改版本信息(紧急下线/改 apk_url)的入口。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.config import settings
|
||||
from app.repositories import app_config
|
||||
from app.schemas.platform import AppVersionOut, AppVersionWriteIn
|
||||
|
||||
logger = logging.getLogger("shagua.internal.app_version")
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
def _check_secret(x_internal_secret: str | None) -> None:
|
||||
"""共享密钥校验。未配置 → 503(挡住裸奔写端点);不匹配 → 401(常量时间比较)。"""
|
||||
configured = settings.INTERNAL_API_SECRET
|
||||
if not configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal api not configured",
|
||||
)
|
||||
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid internal secret",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/app-version",
|
||||
response_model=AppVersionOut,
|
||||
summary="写最新 App 版本(发布流程→app-server,落 app_config)",
|
||||
)
|
||||
def write_app_version(
|
||||
payload: AppVersionWriteIn,
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> AppVersionOut:
|
||||
_check_secret(x_internal_secret)
|
||||
data = payload.model_dump()
|
||||
app_config.set_app_version(db, data)
|
||||
logger.info(
|
||||
"app_version set code=%d name=%s url=%s",
|
||||
payload.latest_version_code, payload.latest_version_name, payload.apk_url,
|
||||
)
|
||||
return AppVersionOut(**data)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""启动确认窗兜底样本内部上报端点(pricebot → app-server)。
|
||||
|
||||
pricebot 的 launch_confirm_agent 每次靠 LLM 兜底放行一个"静态 PROFILES 没认出"的跨 App
|
||||
启动确认窗时,把完整样本 POST 到这里落库。**不是给客户端的接口**:不走用户 JWT,靠
|
||||
server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。密钥未配置
|
||||
(默认空)时直接 503,避免裸奔写端点。
|
||||
|
||||
研发定期人工把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.config import settings
|
||||
from app.repositories import launch_confirm_sample as repo
|
||||
from app.schemas.launch_confirm_sample import (
|
||||
LaunchConfirmSampleIn,
|
||||
LaunchConfirmSampleOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.internal.launch_confirm")
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
def _check_secret(x_internal_secret: str | None) -> None:
|
||||
"""共享密钥校验。未配置 → 503(挡裸奔写端点);不匹配 → 401(常量时间比较)。"""
|
||||
configured = settings.INTERNAL_API_SECRET
|
||||
if not configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal api not configured",
|
||||
)
|
||||
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid internal secret",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/launch-confirm-sample",
|
||||
response_model=LaunchConfirmSampleOut,
|
||||
summary="启动确认窗兜底样本上报(pricebot→app-server,落 launch_confirm_sample)",
|
||||
)
|
||||
def report_launch_confirm_sample(
|
||||
payload: LaunchConfirmSampleIn,
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> LaunchConfirmSampleOut:
|
||||
_check_secret(x_internal_secret)
|
||||
sid = repo.insert_sample(db, payload)
|
||||
logger.info(
|
||||
"launch_confirm_sample id=%d host=%s locale=%s success=%s trace=%s",
|
||||
sid, payload.host_package, payload.system_locale,
|
||||
payload.exec_success, payload.trace_id,
|
||||
)
|
||||
return LaunchConfirmSampleOut(id=sid)
|
||||
@@ -17,12 +17,7 @@ from fastapi import APIRouter, Header
|
||||
from app.api.deps import DbSession
|
||||
from app.api.internal.price import _check_secret
|
||||
from app.repositories import store_mapping as repo
|
||||
from app.schemas.store_mapping import (
|
||||
StoreMappingIn,
|
||||
StoreMappingInvalidateIn,
|
||||
StoreMappingInvalidateOut,
|
||||
StoreMappingOut,
|
||||
)
|
||||
from app.schemas.store_mapping import StoreMappingIn, StoreMappingOut
|
||||
|
||||
logger = logging.getLogger("shagua.internal.store")
|
||||
|
||||
@@ -82,29 +77,3 @@ def report_store_mapping(
|
||||
payload.source_device_id, payload.source_user_id,
|
||||
)
|
||||
return StoreMappingOut(inserted=created, row_id=row_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/store-mapping/invalidate",
|
||||
response_model=StoreMappingInvalidateOut,
|
||||
summary="标记某平台 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报,lookup 不再返回)",
|
||||
)
|
||||
def invalidate_store_mapping(
|
||||
payload: StoreMappingInvalidateIn,
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> StoreMappingInvalidateOut:
|
||||
_check_secret(x_internal_secret)
|
||||
if payload.platform == "taobao":
|
||||
affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id)
|
||||
elif payload.platform == "jd":
|
||||
affected = repo.mark_jd_deeplink_invalid(db, payload.shop_id)
|
||||
else:
|
||||
# 当前只接淘宝/京东; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。
|
||||
logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform)
|
||||
return StoreMappingInvalidateOut(ok=True, affected=0)
|
||||
logger.info(
|
||||
"store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d 行",
|
||||
payload.platform, payload.shop_id, affected,
|
||||
)
|
||||
return StoreMappingInvalidateOut(ok=True, affected=affected)
|
||||
|
||||
+3
-47
@@ -24,7 +24,6 @@ from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories import ad_feed_reward as crud_feed
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
from app.repositories import ad_watch as crud_watch
|
||||
from app.repositories import app_config
|
||||
from app.repositories import signin as crud_signin
|
||||
from app.schemas.ad import (
|
||||
AdRewardStatusOut,
|
||||
@@ -33,8 +32,6 @@ from app.schemas.ad import (
|
||||
FeedRewardIn,
|
||||
FeedRewardOut,
|
||||
PangleCallbackOut,
|
||||
RewardNoShowIn,
|
||||
RewardNoShowOut,
|
||||
TestGrantIn,
|
||||
TestGrantOut,
|
||||
WatchReportIn,
|
||||
@@ -81,22 +78,14 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
|
||||
granted / capped → is_verify=true + reason=0。
|
||||
"""
|
||||
if not settings.PANGLE_CALLBACK_ENABLED:
|
||||
if not settings.pangle_callback_configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
|
||||
)
|
||||
|
||||
params = dict(request.query_params)
|
||||
|
||||
# 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。
|
||||
# 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。
|
||||
ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or ""
|
||||
secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets
|
||||
if not secrets:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
|
||||
)
|
||||
if not pangle.verify_callback_sign_any(params, secrets):
|
||||
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")
|
||||
|
||||
@@ -253,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)
|
||||
|
||||
@@ -372,10 +359,6 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
|
||||
ad_session_id=payload.ad_session_id,
|
||||
adn=payload.adn,
|
||||
slot_id=payload.slot_id,
|
||||
feed_scene=payload.feed_scene,
|
||||
app_env=payload.app_env,
|
||||
our_code_id=payload.our_code_id,
|
||||
aborted=payload.aborted,
|
||||
)
|
||||
logger.info(
|
||||
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
|
||||
@@ -388,30 +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(
|
||||
"/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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -15,13 +15,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.repositories import comparison as crud_compare
|
||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||
from app.schemas.compare_record import (
|
||||
CompareStatsOut,
|
||||
ComparisonRecordCreatedOut,
|
||||
@@ -42,18 +39,11 @@ router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
|
||||
summary="上报一次比价结果(幂等)",
|
||||
)
|
||||
def report_record(
|
||||
payload: ComparisonRecordIn,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
background_tasks: BackgroundTasks,
|
||||
payload: ComparisonRecordIn, user: CurrentUser, db: DbSession
|
||||
) -> ComparisonRecordCreatedOut:
|
||||
rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload)
|
||||
# LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台
|
||||
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
|
||||
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
|
||||
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
|
||||
logger.info(
|
||||
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
|
||||
"compare record user=%s trace=%s biz=%s status=%s saved=%s",
|
||||
user.id,
|
||||
rec.trace_id,
|
||||
rec.business_type,
|
||||
@@ -63,28 +53,6 @@ def report_record(
|
||||
return ComparisonRecordCreatedOut(id=rec.id)
|
||||
|
||||
|
||||
def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
|
||||
"""后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。
|
||||
独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报。"""
|
||||
calls = fetch_llm_calls(trace_id)
|
||||
if not calls:
|
||||
return
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rec = db.get(ComparisonRecord, record_id)
|
||||
if rec is None:
|
||||
return
|
||||
rec.llm_calls = calls
|
||||
rec.llm_call_count = len(calls)
|
||||
rec.retry_count = sum(1 for c in calls if c.get("error"))
|
||||
db.commit()
|
||||
logger.info("backfill llm_calls trace=%s n=%d", trace_id, len(calls))
|
||||
except Exception as e: # noqa: BLE001 best-effort
|
||||
logger.warning("backfill llm_calls failed trace=%s: %s", trace_id, e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=CompareStatsOut,
|
||||
@@ -105,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)
|
||||
|
||||
+12
-57
@@ -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
|
||||
@@ -27,8 +27,6 @@ from app.schemas.coupon_state import (
|
||||
CouponCompletedTodayOut,
|
||||
CouponPromptDismissIn,
|
||||
CouponPromptShouldShowOut,
|
||||
CouponPromptShownIn,
|
||||
CouponStatsOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.coupon")
|
||||
@@ -68,11 +66,11 @@ def _extract_coupon_results(resp_json: dict) -> list[dict]:
|
||||
|
||||
|
||||
def _mark_engagement_blocking(
|
||||
device_id: str, package: str, user_id: int | None, engage_type: str
|
||||
device_id: str, user_id: int | None, engage_type: str
|
||||
) -> None:
|
||||
"""独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。"""
|
||||
with SessionLocal() as db:
|
||||
coupon_repo.mark_engagement(db, device_id, package, user_id, engage_type)
|
||||
coupon_repo.mark_engagement(db, device_id, user_id, engage_type)
|
||||
|
||||
|
||||
def _record_claims_blocking(
|
||||
@@ -113,17 +111,14 @@ async def coupon_step(
|
||||
device_id = meta.get("device_id")
|
||||
user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用
|
||||
trace_id = meta.get("trace_id")
|
||||
# 发起领券时前台 App 包名(step body 带 "package")。频控按 App,这条 engagement 要记到
|
||||
# 对应 App 上。App 内「去领取」发起时 package 可能缺/为空 → 退化为 "" 占位(全局态)。
|
||||
pkg = meta.get("package") or ""
|
||||
|
||||
# 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started),
|
||||
# 今天**这个 App** 不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
|
||||
# 今天这台设备不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
|
||||
# 连累领券主流程,整段吞掉。
|
||||
if device_id and meta.get("step") == 0:
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_mark_engagement_blocking, device_id, pkg, user_id, "claim_started"
|
||||
_mark_engagement_blocking, device_id, user_id, "claim_started"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon engagement write failed: %s", e)
|
||||
@@ -193,29 +188,14 @@ async def coupon_step(
|
||||
return resp_json
|
||||
|
||||
|
||||
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
|
||||
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
|
||||
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。
|
||||
|
||||
频控主判据(管跨重装):弹出即占用今天这个 App 的"一次"。用户领/拒/无视都算用掉。
|
||||
后续点领取/拒绝再由 step/dismiss 把 type 升级。按 (device, package, 日) 记。
|
||||
"""
|
||||
coupon_repo.mark_engagement(
|
||||
db, payload.device_id, payload.package, payload.user_id, "shown"
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)")
|
||||
def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
|
||||
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天**这个 App** 不再弹。
|
||||
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天不再弹。
|
||||
|
||||
server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。
|
||||
频控按 (device, package, 日),各 App 独立。MVP 不鉴权,按 device_id 记。
|
||||
MVP 不鉴权,按 device_id 记。
|
||||
"""
|
||||
coupon_repo.mark_engagement(
|
||||
db, payload.device_id, payload.package, payload.user_id, "dismissed"
|
||||
)
|
||||
coupon_repo.mark_engagement(db, payload.device_id, payload.user_id, "dismissed")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -225,12 +205,12 @@ def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict
|
||||
summary="切到外卖 App 时是否还应弹领券引导窗",
|
||||
)
|
||||
def coupon_prompt_should_show(
|
||||
device_id: str, db: DbSession, package: str = ""
|
||||
device_id: str, db: DbSession
|
||||
) -> CouponPromptShouldShowOut:
|
||||
"""今天这台设备**这个 App** 已 engage(弹/领/拒)过 → should_show=false。各 App 独立:
|
||||
美团弹过不压淘宝/京东。客户端切到目标 App 时带 package 查(老客户端不带 → "" 全局态)。"""
|
||||
"""今天这台设备已 engage(领或拒)过 → should_show=false。客户端据此决定弹不弹
|
||||
(纯后台判据,客户端不再做前台 SP 缓存判断)。"""
|
||||
return CouponPromptShouldShowOut(
|
||||
should_show=not coupon_repo.has_engaged_today(db, device_id, package)
|
||||
should_show=not coupon_repo.has_engaged_today(db, device_id)
|
||||
)
|
||||
|
||||
|
||||
@@ -256,28 +236,3 @@ def coupon_completed_today(
|
||||
return CouponCompletedTodayOut(
|
||||
completed=coupon_repo.has_completed_today(db, device_id)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/completed-today/reset",
|
||||
summary="重置今日已完成(开发设置全重置用,恢复首页「去领取」卡可点)",
|
||||
)
|
||||
def coupon_completed_today_reset(
|
||||
payload: CouponPromptDismissIn, db: DbSession
|
||||
) -> dict[str, bool]:
|
||||
"""删这台设备今天的 completion → has_completed_today 变 false,首页「去领取」卡恢复可点。
|
||||
与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。"""
|
||||
coupon_repo.reset_today_completion(db, payload.device_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=CouponStatsOut,
|
||||
summary="累计领券数(「我的」页战绩卡「领取优惠券 X 张」)",
|
||||
)
|
||||
def coupon_stats(user: CurrentUser, db: DbSession) -> CouponStatsOut:
|
||||
"""该登录用户累计领到的券数(SUM(claimed_count),口径见 coupon_repo.sum_claimed_count)。
|
||||
**鉴权(CurrentUser)**——区别于同文件不鉴权的 /step 透传与 /prompt 频控(那些按 device_id):
|
||||
个人战绩按 user_id 聚合,必须有登录态。"""
|
||||
return CouponStatsOut(coupon_count=coupon_repo.sum_claimed_count(db, user.id))
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"""CPS 群发短链落地:用户点 /c/{code} → 记点击 → 按平台 302 跳 或 返回淘宝落地页。
|
||||
|
||||
公网无鉴权(群里任何人点都要能跳/能领)。记点击失败绝不影响用户。
|
||||
- 美团/京东:记 visit + 302 跳 target(美团短链 / 京东链接)
|
||||
- 淘宝:记 visit + 返回 H5 落地页(整段淘口令复制按钮);点"复制口令"→ POST /c/{code}/copy 记 copy
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import media
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_db
|
||||
from app.integrations import wx_oauth
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_link import CpsLink
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
from app.repositories import cps_wx_user as cps_wx_user_repo
|
||||
|
||||
logger = logging.getLogger("shagua.cps_redirect")
|
||||
|
||||
router = APIRouter(tags=["cps-redirect"])
|
||||
|
||||
# code 不存在/失效时的兜底落地
|
||||
_FALLBACK_URL = "https://www.meituan.com/"
|
||||
|
||||
# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发)
|
||||
_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg"
|
||||
|
||||
# 微信网页授权拿到的用户标识 cookie(种在 coupon 域,30 天免重复授权)
|
||||
_WX_OPENID_COOKIE = "wx_openid"
|
||||
_WX_UINFO_COOKIE = "wx_uinfo" # "1" = 已拿过昵称头像(userinfo),点领券不再跳授权
|
||||
_COOKIE_MAX_AGE = 30 * 86400
|
||||
|
||||
|
||||
def _is_wechat(request: Request) -> bool:
|
||||
"""是否微信内置浏览器(网页授权只在微信内有意义,外部浏览器不跳授权)。"""
|
||||
return "micromessenger" in (request.headers.get("user-agent", "").lower())
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str | None:
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def _record(
|
||||
db: Session, link: CpsLink, request: Request, event_type: str, openid: str | None = None
|
||||
) -> None:
|
||||
try:
|
||||
cps_link_repo.record_click(
|
||||
db, link=link, ip=_client_ip(request),
|
||||
ua=request.headers.get("user-agent"), event_type=event_type, openid=openid,
|
||||
)
|
||||
except Exception:
|
||||
pass # 记点击失败不阻断
|
||||
|
||||
|
||||
@router.get("/MP_verify_F7wnRQ7xPbhVOWC8.txt", include_in_schema=False)
|
||||
def wx_mp_domain_verify() -> PlainTextResponse:
|
||||
"""微信「网页授权域名」归属校验文件。coupon.shaguabijia.com 全反代 app-server,
|
||||
微信请求 coupon.shaguabijia.com/MP_verify_xxx.txt 验证域名归属;内容=文件名核心串。
|
||||
放域名根目录(本端点即根路径),为微信网页授权(拿 openid)接入做准备。"""
|
||||
return PlainTextResponse("F7wnRQ7xPbhVOWC8")
|
||||
|
||||
|
||||
@router.get("/c/{code}", summary="群发短链落地(微信授权拿 openid + 记点击 + 跳转/淘宝落地页)")
|
||||
def cps_landing(code: str, request: Request, db: Session = Depends(get_db)):
|
||||
link = cps_link_repo.get_by_code(db, code)
|
||||
if link is None:
|
||||
return RedirectResponse(_FALLBACK_URL, status_code=302)
|
||||
openid = request.cookies.get(_WX_OPENID_COOKIE)
|
||||
# 微信内 + 还没拿到 openid + 配了服务号 → 先 base 静默授权拿 openid,再 302 回本页。
|
||||
# 非微信 / 已有 cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,绝不阻断领券)。
|
||||
if openid is None and settings.wx_oauth_active and _is_wechat(request):
|
||||
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
|
||||
auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_base", f"base:{code}")
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
_record(db, link, request, "visit", openid=openid)
|
||||
if link.platform == "taobao":
|
||||
activity = db.get(CpsActivity, link.activity_id)
|
||||
image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE
|
||||
has_uinfo = request.cookies.get(_WX_UINFO_COOKIE) == "1"
|
||||
return HTMLResponse(
|
||||
_taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo)
|
||||
)
|
||||
# 美团短链 / 京东链接:直接 302 跳
|
||||
return RedirectResponse(link.target_url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/wx/oauth/cb", include_in_schema=False)
|
||||
def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)):
|
||||
"""微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。
|
||||
换 openid(+userinfo)→ upsert 用户 → 种 cookie → 302 回落地页。任何失败兜底回落地页,
|
||||
绝不阻断用户领券。"""
|
||||
kind, _, orig_code = state.partition(":")
|
||||
try:
|
||||
token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...}
|
||||
openid = token["openid"]
|
||||
link = cps_link_repo.get_by_code(db, orig_code)
|
||||
group_id = link.group_id if link else None
|
||||
nickname = headimgurl = unionid = None
|
||||
if kind == "uinfo" and "userinfo" in (token.get("scope") or ""):
|
||||
info = wx_oauth.get_userinfo(token["access_token"], openid)
|
||||
nickname, headimgurl, unionid = (
|
||||
info.get("nickname"), info.get("headimgurl"), info.get("unionid"),
|
||||
)
|
||||
cps_wx_user_repo.upsert(
|
||||
db, openid=openid, code=orig_code, group_id=group_id,
|
||||
nickname=nickname, headimgurl=headimgurl, unionid=unionid,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[wx_oauth] callback failed state=%s", state)
|
||||
return RedirectResponse(f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302)
|
||||
# userinfo 授权回来带 ?authed=1,落地页据此自动触发复制(把"点领券→授权→复制"衔接为一步)
|
||||
target = f"/c/{orig_code}" + ("?authed=1" if kind == "uinfo" else "")
|
||||
resp = RedirectResponse(target, status_code=302)
|
||||
resp.set_cookie(_WX_OPENID_COOKIE, openid, max_age=_COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
||||
if nickname:
|
||||
resp.set_cookie(_WX_UINFO_COOKIE, "1", max_age=_COOKIE_MAX_AGE, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件,带 openid)")
|
||||
def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
link = cps_link_repo.get_by_code(db, code)
|
||||
if link is not None:
|
||||
_record(db, link, request, "copy", openid=request.cookies.get(_WX_OPENID_COOKIE))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _taobao_landing_html(
|
||||
token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
|
||||
) -> str:
|
||||
"""淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。
|
||||
|
||||
image_url 经 html.escape 嵌入 <img src>(防注入);token 经 json.dumps 安全嵌入 JS。
|
||||
uinfo_url:已有 openid 但还没拿过昵称头像时,生成 userinfo 授权链接 —— 用户点「领券」
|
||||
时先跳它(交互触发,避免微信快照页),授权回来自动复制。已拿过 / 无 openid 则为空,直接复制。
|
||||
"""
|
||||
safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True)
|
||||
uinfo_url = ""
|
||||
if openid and not has_uinfo and settings.wx_oauth_active:
|
||||
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
|
||||
uinfo_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}")
|
||||
return (
|
||||
_TAOBAO_HTML
|
||||
.replace("__IMAGE_URL__", safe_img)
|
||||
.replace("__UINFO_URL__", json.dumps(uinfo_url))
|
||||
.replace("__TOKEN_JS__", json.dumps(token))
|
||||
)
|
||||
|
||||
|
||||
_TAOBAO_HTML = """<!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__;
|
||||
var UINFO_URL = __UINFO_URL__; // 非空=还没拿昵称头像,点领券先跳它补(授权回来自动复制)
|
||||
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 doCopy(){
|
||||
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(TOKEN).then(done).catch(fallback)}
|
||||
else{fallback()}
|
||||
}
|
||||
function copyToken(){
|
||||
// 第一次领券且还没授权过昵称头像:先跳 userinfo 授权(用户点击=交互触发,避免快照页),
|
||||
// 授权回来 ?authed=1 自动复制;已授权过/无 openid 则直接复制。
|
||||
if(UINFO_URL){location.href=UINFO_URL;return}
|
||||
doCopy();
|
||||
}
|
||||
// userinfo 授权回流(?authed=1):自动复制,把"点领券→授权→复制"衔接成一步无感
|
||||
if(location.search.indexOf('authed=1')>=0){doCopy()}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -14,15 +14,14 @@ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import media
|
||||
from app.repositories import feedback as feedback_repo
|
||||
from app.repositories import feedback_qr as feedback_qr_repo
|
||||
from app.schemas.feedback import FeedbackOut, FeedbackQrConfigOut
|
||||
from app.schemas.feedback import FeedbackOut
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -61,9 +60,3 @@ async def submit_feedback(
|
||||
)
|
||||
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
|
||||
return FeedbackOut.model_validate(fb)
|
||||
|
||||
|
||||
@router.get("/config", response_model=FeedbackQrConfigOut, summary="反馈页二维码卡配置")
|
||||
def feedback_config(user: CurrentUser, db: DbSession) -> FeedbackQrConfigOut:
|
||||
"""运营后台配的反馈页「加群二维码」卡(开关 + 二维码图 + 三行文案)。客户端进反馈页时拉取。"""
|
||||
return FeedbackQrConfigOut(**feedback_qr_repo.get_config(db))
|
||||
|
||||
+1
-45
@@ -2,7 +2,6 @@
|
||||
|
||||
路由前缀 `/api/v1/platform`:
|
||||
GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。
|
||||
GET /flags 客户端运营 feature flag(比价/领券期广告开关等),客户端拉取后缓存。
|
||||
|
||||
展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。
|
||||
"""
|
||||
@@ -13,17 +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 (
|
||||
AdConfigPublicOut,
|
||||
AppFlagsOut,
|
||||
AppVersionOut,
|
||||
PlatformStatsOut,
|
||||
SavingsFeedItem,
|
||||
SavingsFeedOut,
|
||||
)
|
||||
from app.schemas.platform import PlatformStatsOut, SavingsFeedItem, SavingsFeedOut
|
||||
|
||||
logger = logging.getLogger("shagua.platform")
|
||||
|
||||
@@ -44,38 +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")),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ad-config", response_model=AdConfigPublicOut, summary="客户端拉广告配置(穿山甲ID+场景开关,不鉴权)")
|
||||
def ad_config(db: DbSession) -> AdConfigPublicOut:
|
||||
"""客户端启动/每场广告前拉,缓存后用:app_id + 各位ID + 各场景开关。
|
||||
不含验签密钥;空库回退默认(=客户端内置值,维持现状)。"""
|
||||
c = app_config.get_ad_config(db)
|
||||
return AdConfigPublicOut(
|
||||
app_id=c["app_id"],
|
||||
reward_code_id=c["reward_code_id"],
|
||||
compare_feed_code_id=c["compare_feed_code_id"],
|
||||
coupon_feed_code_id=c["coupon_feed_code_id"],
|
||||
reward_enabled=c["reward_enabled"],
|
||||
compare_ad_enabled=c["compare_ad_enabled"],
|
||||
coupon_ad_enabled=c["coupon_ad_enabled"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)")
|
||||
def app_version(db: DbSession) -> AppVersionOut:
|
||||
"""客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。
|
||||
用 latest_version_code 与本机 versionCode 比;未配置(返回默认 0)时客户端视为已是最新。"""
|
||||
data = app_config.get_app_version(db)
|
||||
if not data:
|
||||
return AppVersionOut()
|
||||
return AppVersionOut(**data)
|
||||
|
||||
@@ -18,7 +18,6 @@ from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import media
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.repositories import wallet as wallet_repo
|
||||
from app.schemas.auth import UserOut
|
||||
from app.schemas.user import (
|
||||
OkResponse,
|
||||
@@ -88,17 +87,6 @@ def onboarding_status(
|
||||
|
||||
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
|
||||
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
|
||||
# 资金前置校验:注销是软删 + 匿名化,余额一旦留在 deleted 行就锁死(既退不出、新号也
|
||||
# 拿不回)。有可提现现金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。
|
||||
# (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。)
|
||||
if wallet_repo.get_cash_balance_cents(db, user.id) > 0:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="账户还有未提现的现金余额,请先提现后再注销"
|
||||
)
|
||||
if wallet_repo.has_reviewing_withdraw(db, user.id):
|
||||
raise HTTPException(
|
||||
status_code=409, detail="有提现正在审核中,请等审核完成后再注销"
|
||||
)
|
||||
media.delete_avatar(user.avatar_url)
|
||||
user_repo.soft_delete_account(db, user)
|
||||
logger.info("delete account user_id=%d", user.id)
|
||||
|
||||
+6
-56
@@ -95,25 +95,6 @@ class Settings(BaseSettings):
|
||||
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
|
||||
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
|
||||
|
||||
# ===== 微信服务号(网页授权) =====
|
||||
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
|
||||
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
|
||||
WX_MP_APPID: str = ""
|
||||
WX_MP_SECRET: str = ""
|
||||
# 落地页微信网页授权【总开关】。默认关:服务号认证审核中/未就绪时,落地页走原逻辑
|
||||
# (不跳授权、不拿 openid),整套微信代码保留。审核通过后 .env 置 true + 重启即启用,无需改代码。
|
||||
WX_MP_OAUTH_ENABLED: bool = False
|
||||
|
||||
@property
|
||||
def wx_mp_configured(self) -> bool:
|
||||
"""服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。"""
|
||||
return bool(self.WX_MP_APPID and self.WX_MP_SECRET)
|
||||
|
||||
@property
|
||||
def wx_oauth_active(self) -> bool:
|
||||
"""落地页是否真正发起微信授权 = 凭证齐全 且 总开关开。"""
|
||||
return self.wx_mp_configured and self.WX_MP_OAUTH_ENABLED
|
||||
|
||||
# ===== 微信支付(商家转账到零钱 / 提现)=====
|
||||
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
|
||||
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
|
||||
@@ -129,14 +110,10 @@ class Settings(BaseSettings):
|
||||
WITHDRAW_AUTO_RECONCILE_ENABLED: bool = False
|
||||
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC: int = 300
|
||||
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES: int = 15
|
||||
# 0 点自动兑金币:由 App 进程内任务 app.core.daily_exchange_worker 跨 0 点跑一轮
|
||||
# wallet.daily_auto_exchange(原 deploy/daily-exchange.timer 已不再需要,见 deploy/daily-exchange.md)。
|
||||
# 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停在 .env 置 false
|
||||
# (worker 不启动;脚本也 no-op)。
|
||||
# 0 点自动兑金币(由 deploy/daily-exchange.timer 触发 scripts.daily_auto_exchange)。
|
||||
# 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停可在 .env 置 false,
|
||||
# 无需动 systemd timer——脚本读此开关,false 时直接 no-op 退出。
|
||||
AUTO_EXCHANGE_ENABLED: bool = True
|
||||
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
|
||||
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
|
||||
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
|
||||
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
|
||||
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
|
||||
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
||||
@@ -159,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,让(已登录的)客户端在没部署公网、
|
||||
@@ -175,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
|
||||
@@ -233,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 = ""
|
||||
|
||||
|
||||
@@ -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 控制。关掉只停自动扫描,"
|
||||
"提现页「批量对账」手动按钮不受影响。"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
"""0 点金币→现金自动兑换的进程内定时任务。
|
||||
|
||||
替代原 systemd timer(deploy/daily-exchange.*):App 启动即自带,每
|
||||
`AUTO_EXCHANGE_CHECK_INTERVAL_SEC` 醒一次,跨进北京新的一天(0 点后)就跑一轮
|
||||
`wallet.daily_auto_exchange`。
|
||||
|
||||
健壮性:
|
||||
- **逐用户幂等**:当天已有 exchange_in 流水的用户跳过(见 wallet._has_exchange_in_on),
|
||||
故启动补跑 / 多次唤醒 / 进程重启都安全,不会重复兑。
|
||||
- **当天首跑即补**:进程起来时若当天还没兑过,立即兑一轮(等价原 timer 的 Persistent 补跑)。
|
||||
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑(防跨进程并发导致 TOCTOU 双兑)。
|
||||
- **开关**:settings.AUTO_EXCHANGE_ENABLED=false 时不启动(与脚本/原 timer 同一开关)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
logger = logging.getLogger("shagua.daily_exchange")
|
||||
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "daily_exchange.lock"
|
||||
|
||||
|
||||
def _touch_lock() -> None:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.utime(_LOCK_PATH, None)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
|
||||
"""同机多进程保护:同一时间只允许一个自动兑换 worker 运行。"""
|
||||
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd: int | None = None
|
||||
try:
|
||||
try:
|
||||
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
try:
|
||||
age = time.time() - _LOCK_PATH.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
age = stale_after_sec + 1
|
||||
if age > stale_after_sec:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
_LOCK_PATH.unlink()
|
||||
try:
|
||||
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
fd = None
|
||||
|
||||
if fd is None:
|
||||
yield False
|
||||
return
|
||||
|
||||
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
|
||||
yield True
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
_LOCK_PATH.unlink()
|
||||
|
||||
|
||||
def _exchange_once() -> dict:
|
||||
with SessionLocal() as db:
|
||||
return wallet_repo.daily_auto_exchange(db)
|
||||
|
||||
|
||||
async def _run_loop() -> None:
|
||||
interval = max(60, int(settings.AUTO_EXCHANGE_CHECK_INTERVAL_SEC))
|
||||
lock_stale_after = max(interval * 3, 1800)
|
||||
with _single_instance_lock(lock_stale_after) as lock_acquired:
|
||||
if not lock_acquired:
|
||||
logger.warning("daily auto-exchange skipped: another worker owns lock")
|
||||
return
|
||||
await _run_locked_loop(interval)
|
||||
|
||||
|
||||
async def _run_locked_loop(interval: int) -> None:
|
||||
logger.info("daily auto-exchange worker started interval=%ss", interval)
|
||||
# 本进程上次跑过的北京日;None=尚未跑过本进程(启动即补当天)。
|
||||
last_run: date | None = None
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
_touch_lock()
|
||||
today = rewards.cn_today()
|
||||
if last_run != today:
|
||||
result = await asyncio.to_thread(_exchange_once)
|
||||
last_run = today
|
||||
logger.info("daily auto-exchange done date=%s result=%s", today, result)
|
||||
except SQLAlchemyError:
|
||||
logger.exception("daily auto-exchange db error")
|
||||
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
|
||||
logger.exception("daily auto-exchange unexpected error")
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("daily auto-exchange worker stopped")
|
||||
raise
|
||||
|
||||
|
||||
def start_daily_exchange_worker() -> asyncio.Task | None:
|
||||
if not settings.AUTO_EXCHANGE_ENABLED:
|
||||
logger.info("daily auto-exchange disabled (AUTO_EXCHANGE_ENABLED=false)")
|
||||
return None
|
||||
return asyncio.create_task(_run_loop(), name="daily-auto-exchange")
|
||||
|
||||
|
||||
async def stop_daily_exchange_worker(task: asyncio.Task | None) -> None:
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
+6
-43
@@ -37,7 +37,7 @@ def _sniff_ext(data: bytes) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _save_named(subdir: str, name_prefix: str, data: bytes) -> str:
|
||||
def _save_image(subdir: str, user_id: int, data: bytes) -> str:
|
||||
"""通用图片落盘:校验非空 / ≤上限 / 魔数为图片,随机文件名,返回相对 URL。"""
|
||||
if not data:
|
||||
raise MediaError("空文件")
|
||||
@@ -47,16 +47,11 @@ def _save_named(subdir: str, name_prefix: str, data: bytes) -> str:
|
||||
if ext is None:
|
||||
raise MediaError("仅支持 JPEG / PNG / WebP 图片")
|
||||
|
||||
fname = f"{name_prefix}_{secrets.token_hex(8)}{ext}"
|
||||
fname = f"u{user_id}_{secrets.token_hex(8)}{ext}"
|
||||
(_media_dir(subdir) / fname).write_bytes(data)
|
||||
return f"{settings.MEDIA_URL_PREFIX}/{subdir}/{fname}"
|
||||
|
||||
|
||||
def _save_image(subdir: str, user_id: int, data: bytes) -> str:
|
||||
"""用户上传图片落盘(文件名带 user_id 前缀)。"""
|
||||
return _save_named(subdir, f"u{user_id}", data)
|
||||
|
||||
|
||||
def save_avatar(user_id: int, data: bytes) -> str:
|
||||
"""保存头像,返回相对 URL(`/media/avatars/<file>`)。"""
|
||||
return _save_image("avatars", user_id, data)
|
||||
@@ -72,47 +67,15 @@ def save_report_image(user_id: int, data: bytes) -> str:
|
||||
return _save_image("price_report", user_id, data)
|
||||
|
||||
|
||||
def save_feedback_qr(data: bytes) -> str:
|
||||
"""保存反馈页二维码(运营后台上传的运营素材,非用户文件),返回相对 URL(`/media/feedback_qr/<file>`)。"""
|
||||
return _save_named("feedback_qr", "qr", 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_managed(subdir: str, url: str | None) -> None:
|
||||
"""删除本服务托管的某子目录下文件;外部 URL / 空值 / 非本子目录的不处理。"""
|
||||
prefix = f"{settings.MEDIA_URL_PREFIX}/{subdir}/"
|
||||
def delete_avatar(url: str | None) -> None:
|
||||
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
|
||||
prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/"
|
||||
if not url or not url.startswith(prefix):
|
||||
return
|
||||
fname = url[len(prefix):]
|
||||
if not fname or "/" in fname or "\\" in fname or ".." in fname:
|
||||
return # 防路径穿越
|
||||
try:
|
||||
(_media_dir(subdir) / fname).unlink(missing_ok=True)
|
||||
(_media_dir("avatars") / fname).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def delete_avatar(url: str | None) -> None:
|
||||
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
|
||||
_delete_managed("avatars", url)
|
||||
|
||||
|
||||
def delete_feedback_qr(url: str | None) -> None:
|
||||
"""删除本服务托管的旧反馈页二维码文件;外部 URL 或空值不处理。"""
|
||||
_delete_managed("feedback_qr", url)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"""微信服务号网页授权:构造授权链接 + code 换 openid + 拉 userinfo。
|
||||
|
||||
用于 CPS 落地页(coupon.shaguabijia.com)在微信内拿用户身份。流程(见微信文档):
|
||||
授权链接(snsapi_base|snsapi_userinfo) → 回调带 code → sns/oauth2/access_token 换 openid
|
||||
→ (userinfo 时)sns/userinfo 拿昵称头像。
|
||||
|
||||
注意:
|
||||
- 这里的 access_token 是【网页授权 token】,与基础 access_token(cgi-bin/token)不同,
|
||||
走 sns/* 接口,不需要 IP 白名单。
|
||||
- secret 只在服务器,不下发客户端。
|
||||
- 用 WX_MP_APPID/SECRET(已认证服务号),区别于 WECHAT_APP_ID(App 移动应用)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_API = "https://api.weixin.qq.com"
|
||||
_AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize"
|
||||
_TIMEOUT = 10
|
||||
|
||||
|
||||
class WxOauthError(Exception):
|
||||
"""网页授权调用失败(凭证/网络/code 失效等)。调用方兜底为无 openid。"""
|
||||
|
||||
|
||||
def build_authorize_url(redirect_uri: str, scope: str, state: str) -> str:
|
||||
"""构造网页授权跳转链接。scope: 'snsapi_base'(静默,仅 openid) | 'snsapi_userinfo'(昵称头像)。
|
||||
|
||||
微信要求 redirect_uri urlEncode、参数顺序固定、结尾 #wechat_redirect。
|
||||
"""
|
||||
return (
|
||||
f"{_AUTHORIZE}?appid={settings.WX_MP_APPID}"
|
||||
f"&redirect_uri={quote(redirect_uri, safe='')}"
|
||||
f"&response_type=code&scope={scope}&state={quote(state, safe='')}"
|
||||
f"#wechat_redirect"
|
||||
)
|
||||
|
||||
|
||||
def exchange_code(code: str) -> dict:
|
||||
"""code 换网页授权 access_token + openid。返回含 openid/access_token/scope(/unionid)。
|
||||
|
||||
code 5 分钟内一次性有效。失败(errcode)抛 WxOauthError。
|
||||
"""
|
||||
resp = httpx.get(
|
||||
f"{_API}/sns/oauth2/access_token",
|
||||
params={
|
||||
"appid": settings.WX_MP_APPID,
|
||||
"secret": settings.WX_MP_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
data = resp.json()
|
||||
if "openid" not in data:
|
||||
raise WxOauthError(f"exchange_code failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def get_userinfo(access_token: str, openid: str) -> dict:
|
||||
"""拉用户信息(nickname/headimgurl/unionid)。需 scope=snsapi_userinfo 的 access_token。"""
|
||||
resp = httpx.get(
|
||||
f"{_API}/sns/userinfo",
|
||||
params={"access_token": access_token, "openid": openid, "lang": "zh_CN"},
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
resp.encoding = "utf-8" # 昵称含中文/emoji,强制 utf-8 避免乱码
|
||||
data = resp.json()
|
||||
if "openid" not in data:
|
||||
raise WxOauthError(f"get_userinfo failed: {data}")
|
||||
return data
|
||||
-13
@@ -20,9 +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.app_version import router as internal_app_version_router
|
||||
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
|
||||
from app.api.internal.price import router as internal_price_router
|
||||
from app.api.internal.store import router as internal_store_router
|
||||
from app.api.v1.feedback import router as feedback_router
|
||||
@@ -38,10 +35,6 @@ from app.api.v1.user import router as user_router
|
||||
from app.api.v1.wallet import router as wallet_router
|
||||
from app.api.v1.wxpay import router as wxpay_router
|
||||
from app.core.config import settings
|
||||
from app.core.daily_exchange_worker import (
|
||||
start_daily_exchange_worker,
|
||||
stop_daily_exchange_worker,
|
||||
)
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.withdraw_reconcile_worker import (
|
||||
start_withdraw_reconcile_worker,
|
||||
@@ -63,12 +56,10 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.DATABASE_URL.split("://", 1)[0],
|
||||
)
|
||||
reconcile_task = start_withdraw_reconcile_worker()
|
||||
daily_exchange_task = start_daily_exchange_worker()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_withdraw_reconcile_worker(reconcile_task)
|
||||
await stop_daily_exchange_worker(daily_exchange_task)
|
||||
logger.info("shutting down")
|
||||
|
||||
|
||||
@@ -115,11 +106,7 @@ app.include_router(report_router)
|
||||
# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
|
||||
app.include_router(internal_price_router)
|
||||
app.include_router(internal_store_router)
|
||||
app.include_router(internal_app_version_router)
|
||||
app.include_router(internal_launch_confirm_router)
|
||||
app.include_router(platform_router)
|
||||
# CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团)
|
||||
app.include_router(cps_redirect_router)
|
||||
|
||||
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
|
||||
_media_root = Path(settings.MEDIA_ROOT)
|
||||
|
||||
@@ -6,11 +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.cps_wx_user import CpsWxUser # noqa: F401
|
||||
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
|
||||
from app.models.coupon_state import ( # noqa: F401
|
||||
CouponClaimRecord,
|
||||
@@ -20,7 +15,6 @@ from app.models.coupon_state import ( # noqa: F401
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.invite import InviteRelation # noqa: F401
|
||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
|
||||
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
|
||||
from app.models.onboarding import OnboardingCompletion # noqa: F401
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
|
||||
|
||||
@@ -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 比较)
|
||||
|
||||
@@ -30,12 +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)
|
||||
# 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流
|
||||
# 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。
|
||||
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
|
||||
|
||||
|
||||
@@ -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)
|
||||
# 穿山甲上报的奖励名(参考,不作发奖依据)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -137,35 +137,25 @@ class CouponDailyCompletion(Base):
|
||||
|
||||
|
||||
class CouponPromptEngagement(Base):
|
||||
"""按 (device, **App**, 自然日) 记"今天这个 App 是否对领券引导窗表达过意向"——弹窗频控源。
|
||||
|
||||
2026-06-14:频控维度从 (device, 日) 改为 (device, package, 日)。需求是美团/淘宝/京东
|
||||
各自独立——在美团弹过/领过,不影响淘宝、京东今天仍各弹一次。原来缺 package → 任一 App
|
||||
弹过就把整台设备当天标记 engage,其余 App 被压住不弹(bug)。
|
||||
"""
|
||||
"""按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。"""
|
||||
|
||||
__tablename__ = "coupon_prompt_engagement"
|
||||
__table_args__ = (
|
||||
# 一台设备、一个 App、一天一条:今天**这个 App** engage 过(领或拒)才不再弹该 App。
|
||||
# 一台设备一天一条:今天 engage 过(领或拒)就不再弹。
|
||||
UniqueConstraint(
|
||||
"device_id", "package", "engage_date",
|
||||
name="uq_coupon_engage_device_pkg_date",
|
||||
"device_id", "engage_date",
|
||||
name="uq_coupon_engage_device_date",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# 触发弹窗的目标 App 包名(com.sankuai.meituan / com.taobao.taobao / com.jingdong.app.mall)。
|
||||
# 频控维度,各 App 独立。旧行(改造前)无此值 → 迁移用占位 "" 填,不影响新逻辑判断。
|
||||
package: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, server_default=""
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
# Asia/Shanghai 自然日。
|
||||
engage_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
# claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)/ shown(自动弹出即记)。
|
||||
# 仅记录区分,判断只看"今天这个 App 有没有这条",type 不影响弹不弹。
|
||||
# claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)。仅记录区分,
|
||||
# 判断只看"今天有没有这条",type 不影响弹不弹。
|
||||
engage_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -1,59 +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)
|
||||
# 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计
|
||||
openid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
clicked_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<CpsClick id={self.id} link_id={self.link_id} group_id={self.group_id}>"
|
||||
@@ -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,42 +0,0 @@
|
||||
"""CPS 落地页微信用户(cps_wx_user)。
|
||||
|
||||
用户在微信内打开群发短链落地页 /c/{code},经服务号网页授权:
|
||||
- base 静默:拿 openid(唯一标识,统计主力)
|
||||
- userinfo(点领券触发):补 nickname/headimgurl/unionid
|
||||
|
||||
按 openid 唯一,记录首次来源群(first_group_id),用于群内用户级统计(谁领了券)。
|
||||
下单归因到人需 user-level sid(另一期),本表只承载身份 + 领券/点击侧。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class CpsWxUser(Base):
|
||||
__tablename__ = "cps_wx_user"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 服务号下用户唯一标识(网页授权拿到)
|
||||
openid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
# 开放平台 unionid(服务号绑开放平台 + scope=userinfo 才有);跨 App/服务号统一用户
|
||||
unionid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 昵称/头像:userinfo 授权后才有(base 阶段为空)
|
||||
nickname: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
headimgurl: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# 首次进入来源(从哪个群的链接授权进来)
|
||||
first_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
first_group_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
first_seen: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
last_seen: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<CpsWxUser id={self.id} openid={self.openid!r} nickname={self.nickname!r}>"
|
||||
@@ -1,61 +0,0 @@
|
||||
"""启动确认窗 agent 兜底样本表(launch_confirm_sample)。
|
||||
|
||||
pricebot 的 launch_confirm_agent 每次"静态 PROFILES 没认出、靠 LLM 兜底放行"一个跨 App
|
||||
启动确认窗(繁体 / 英文 / 小语种 / ROM 改版)时,把完整样本 server→server 落这里。研发定期
|
||||
**人工**把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES(静态快路径),
|
||||
让以后同款窗不必再调 LLM。
|
||||
|
||||
设计:
|
||||
- 与登录无关、不鉴权(server→server 共享密钥头 X-Internal-Secret)。
|
||||
- **都上报、不去重**(by design):同一种窗多台机器上报多条,研发按 host_package / locale
|
||||
聚合后人工筛。
|
||||
- 大字段(弹窗树快照 + LLM plan + 设备信息)塞 payload(JSONB)兜底,免得加字段就迁移 schema。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 price_observation)。
|
||||
_JSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
class LaunchConfirmSample(Base):
|
||||
__tablename__ = "launch_confirm_sample"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
# pricebot 侧 trace_id:回指原始 trace,能去 price.shaguabijia.com 查完整上下文。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
|
||||
# 弹窗宿主包(= 触发判据, 也是研发沉淀进 PROFILES.host_packages 的键)。按它聚合人工筛。
|
||||
host_package: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
|
||||
# 被打开的目标 App 包名(标题里"想要打开 X"的 X 是变量, 据此把它从 sign 里挖空)。
|
||||
target_app: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 系统语言地区(zh-TW / en-US…)—— 多语言问题的核心维度。
|
||||
# ⚠️ 前端 AgentStepRequest 暂未上报设备信息 → 现恒 None,待前端加 device_info 字段后回填。
|
||||
system_locale: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
|
||||
|
||||
# 这次兜底是否成功放行(弹窗消失=True;放弃 / LLM 没认出=False)。研发只沉淀 True 的。
|
||||
exec_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
# 弹窗标题全文(研发据此提 launch_sign),如"「傻瓜比价」想要開啟「京東」"。
|
||||
dialog_title: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
|
||||
# 大 JSON:完整样本。{is_launch_confirm, llm_model, llm_reason, plan, dialog_tree,
|
||||
# device_info: {screen, locale, brand, model, android_version, rom_version}}
|
||||
payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<LaunchConfirmSample id={self.id} host={self.host_package!r} "
|
||||
f"locale={self.system_locale!r} success={self.exec_success}>"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -86,9 +86,6 @@ class StoreMapping(Base):
|
||||
taobao_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # m.tb.cn 短链
|
||||
taobao_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 解析出的目标 URL(含 shopId)
|
||||
taobao_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 et-store/search deeplink
|
||||
# 淘宝 deeplink 失效标记:比价撞"页面出错了"降级页时被置(pricebot server→server invalidate),
|
||||
# NULL=有效。lookup 反查过滤掉非 NULL 的淘宝候选,不再返回坏 deeplink(重搜会写新行覆盖)。
|
||||
taobao_deeplink_invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# ===== 美团原料(同淘宝;dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink)=====
|
||||
# ⚠️ poi_id_str 每次分享重新加密、非稳定主键(调研文档 §八), 故单列存"可复跳的一次性票据",
|
||||
@@ -105,8 +102,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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -69,21 +69,12 @@ def grant_feed_reward(
|
||||
ad_session_id: str | None = None,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
feed_scene: str | None = None,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
aborted: bool = False,
|
||||
) -> AdFeedRewardRecord:
|
||||
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。
|
||||
"""完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。
|
||||
|
||||
发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份)。
|
||||
- aborted=True(用户中途 ✕ 关闭):整场不发,记 status='closed_early' 留痕(原因可查)。
|
||||
- 总时长不足 10 秒(unit_count==0):记 status='too_short' 不发。
|
||||
- 命中当日条数上限:记 status='capped' 不发。
|
||||
duration_seconds 是整场累计秒数。一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到
|
||||
FEED_MAX_DURATION_SECONDS 限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到
|
||||
AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。
|
||||
feed_scene:点位场景(comparison/coupon/welfare),仅做归类落库,不参与发奖计算。
|
||||
一期 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)
|
||||
if existing is not None:
|
||||
@@ -94,26 +85,6 @@ def grant_feed_reward(
|
||||
safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS))
|
||||
unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS
|
||||
|
||||
# 用户中途关闭广告:整场不发(全程不关才发),留一条 closed_early 记录原因。优先级最高。
|
||||
if aborted:
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=unit_count,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
status="closed_early",
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
@@ -125,34 +96,11 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
status="capped",
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
# 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。
|
||||
if unit_count == 0:
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=0,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
status="too_short",
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
coin = _unit_reward_total(db, user_id, ecpm, unit_count)
|
||||
if coin > 0:
|
||||
crud_wallet.grant_coins(
|
||||
@@ -170,9 +118,6 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=coin,
|
||||
status="granted",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -63,79 +63,3 @@ def list_all(db: Session) -> list[dict]:
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ── 最新 App 版本(OTA 检查更新)──────────────────────────────────────────────
|
||||
# 版本信息不是"运营可配置项",不进 CONFIG_DEFS:它由发布流程(发车)/应急整组写入、客户端读取做
|
||||
# OTA,语义是"发布系统状态"而非"运营调参"。故走下面专用读写,直接操作 AppConfig 表(复用表不复用 repo)。
|
||||
APP_VERSION_KEY = "latest_app_version"
|
||||
|
||||
|
||||
def get_app_version(db: Session) -> dict | None:
|
||||
"""读最新 App 版本信息(OTA)。无记录返回 None(客户端视为无更新)。"""
|
||||
row = db.get(AppConfig, APP_VERSION_KEY)
|
||||
return row.value if row is not None else None
|
||||
|
||||
|
||||
def set_app_version(db: Session, data: dict, *, commit: bool = True) -> AppConfig:
|
||||
"""整组原子覆盖最新版本信息(发布流程/应急写,非 admin,故 updated_by_admin_id 留空)。"""
|
||||
row = db.get(AppConfig, APP_VERSION_KEY)
|
||||
if row is None:
|
||||
row = AppConfig(key=APP_VERSION_KEY, value=data, updated_by_admin_id=None)
|
||||
db.add(row)
|
||||
else:
|
||||
row.value = data
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
else:
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
# ── 穿山甲广告配置(admin 可配,客户端动态拉)──────────────────────────────────
|
||||
# 同 app_version:结构化 dict,复用 AppConfig 表不进 CONFIG_DEFS(它是一组关联配置,不是散项)。
|
||||
# 默认值 = 客户端当前内置的硬编码 ID(AdConfig.kt),空库时下发默认 = 维持现状。
|
||||
# reward_mkey 是 GroMore S2S 验签密钥(机密),只后端验签用、绝不下发客户端(见 platform/ad-config)。
|
||||
AD_CONFIG_KEY = "ad_config"
|
||||
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
|
||||
"app_id": "5830519", # 穿山甲应用ID(正式)
|
||||
"reward_code_id": "104099389", # 福利页激励视频位
|
||||
"compare_feed_code_id": "104090333", # 比价信息流位
|
||||
"coupon_feed_code_id": "104090333", # 领券信息流位(初始同比价,运营可拆)
|
||||
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
|
||||
"reward_enabled": True, # 福利激励视频开关
|
||||
"compare_ad_enabled": True, # 比价广告开关
|
||||
"coupon_ad_enabled": True, # 领券广告开关
|
||||
}
|
||||
|
||||
|
||||
def get_ad_config(db: Session) -> dict:
|
||||
"""读广告配置。DB 无则返回默认(=客户端内置值,维持现状);DB 有则与默认 merge
|
||||
(新增字段向后兼容,老记录缺的字段用默认补)。"""
|
||||
row = db.get(AppConfig, AD_CONFIG_KEY)
|
||||
merged = dict(_AD_CONFIG_DEFAULTS)
|
||||
if row is not None and isinstance(row.value, dict):
|
||||
merged.update(row.value)
|
||||
return merged
|
||||
|
||||
|
||||
def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True) -> AppConfig:
|
||||
"""admin 写广告配置。与现有值 merge(部分更新),只接受已知字段(防脏键)。"""
|
||||
row = db.get(AppConfig, AD_CONFIG_KEY)
|
||||
merged = dict(_AD_CONFIG_DEFAULTS)
|
||||
if row is not None and isinstance(row.value, dict):
|
||||
merged.update(row.value)
|
||||
merged.update({k: v for k, v in data.items() if k in _AD_CONFIG_DEFAULTS})
|
||||
if row is None:
|
||||
row = AppConfig(key=AD_CONFIG_KEY, value=merged, updated_by_admin_id=admin_id)
|
||||
db.add(row)
|
||||
else:
|
||||
row.value = merged
|
||||
row.updated_by_admin_id = admin_id
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
else:
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,13 +31,11 @@ def today_cn() -> date:
|
||||
|
||||
# ===== 弹窗频控(coupon_prompt_engagement)=====
|
||||
|
||||
def has_engaged_today(db: Session, device_id: str, package: str) -> bool:
|
||||
"""这台设备今天**这个 App** 是否已对领券引导窗表达过意向(领/拒/弹出)。有 = 该 App 不再弹。
|
||||
频控按 (device, package, 日):美团弹过不影响淘宝/京东今天各自仍弹一次。"""
|
||||
def has_engaged_today(db: Session, device_id: str) -> bool:
|
||||
"""这台设备今天是否已对领券引导窗表达过意向(领或拒)。有 = 不再弹。"""
|
||||
row = db.execute(
|
||||
select(CouponPromptEngagement.id).where(
|
||||
CouponPromptEngagement.device_id == device_id,
|
||||
CouponPromptEngagement.package == package,
|
||||
CouponPromptEngagement.engage_date == today_cn(),
|
||||
)
|
||||
).first()
|
||||
@@ -45,18 +43,13 @@ def has_engaged_today(db: Session, device_id: str, package: str) -> bool:
|
||||
|
||||
|
||||
def mark_engagement(
|
||||
db: Session, device_id: str, package: str, user_id: int | None, engage_type: str
|
||||
db: Session, device_id: str, user_id: int | None, engage_type: str
|
||||
) -> None:
|
||||
"""记今日意向(shown / claim_started / dismissed)。(device, package, 今天) 唯一,幂等 upsert。
|
||||
|
||||
engage_type 升级口径(同一 (device,package,日) 多次调,只覆盖 type,不新增行):
|
||||
shown(自动弹出)→ claim_started(点一键领取)/ dismissed(点关闭)。判断只看"有没有这条"。
|
||||
"""
|
||||
"""记今日意向(claim_started / dismissed)。(device, 今天) 唯一,幂等 upsert。"""
|
||||
today = today_cn()
|
||||
row = db.execute(
|
||||
select(CouponPromptEngagement).where(
|
||||
CouponPromptEngagement.device_id == device_id,
|
||||
CouponPromptEngagement.package == package,
|
||||
CouponPromptEngagement.engage_date == today,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
@@ -66,20 +59,19 @@ def mark_engagement(
|
||||
row.user_id = user_id
|
||||
else:
|
||||
db.add(CouponPromptEngagement(
|
||||
device_id=device_id, package=package, user_id=user_id,
|
||||
device_id=device_id, user_id=user_id,
|
||||
engage_date=today, engage_type=engage_type,
|
||||
))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发下另一请求刚插了同 (device, package, 日) → 唯一约束撞,回滚忽略(本就幂等)。
|
||||
# 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。
|
||||
db.rollback()
|
||||
|
||||
|
||||
def reset_today_engagement(db: Session, device_id: str) -> int:
|
||||
"""删这台设备今天**所有 App** 的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。
|
||||
删后各 App has_engaged_today → false,今天又都能弹。返回删除行数。
|
||||
(不按 package 过滤:重置是"把今天清干净从头测",清全部 App 最符合预期。)"""
|
||||
"""删这台设备今天的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。
|
||||
删后 has_engaged_today → false,今天又能弹。返回删除行数。"""
|
||||
result = db.execute(
|
||||
delete(CouponPromptEngagement).where(
|
||||
CouponPromptEngagement.device_id == device_id,
|
||||
@@ -131,19 +123,6 @@ def mark_completed_today(
|
||||
db.rollback()
|
||||
|
||||
|
||||
def reset_today_completion(db: Session, device_id: str) -> int:
|
||||
"""删这台设备今天的"已完成"记录(开发设置「重置今日领券弹窗状态」全重置时调)。
|
||||
删后 has_completed_today → false,首页「去领取」卡恢复可点。返回删除行数。"""
|
||||
result = db.execute(
|
||||
delete(CouponDailyCompletion).where(
|
||||
CouponDailyCompletion.device_id == device_id,
|
||||
CouponDailyCompletion.complete_date == today_cn(),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
# ===== 领券记录(coupon_claim_record)=====
|
||||
|
||||
def record_claims(
|
||||
@@ -206,29 +185,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)
|
||||
|
||||
@@ -1,96 +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", openid: str | None = None,
|
||||
) -> None:
|
||||
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。
|
||||
openid: 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计。"""
|
||||
db.add(CpsClick(
|
||||
link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type,
|
||||
ip=ip, ua=(ua[:500] if ua else None), openid=openid,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def click_stats_by_group(
|
||||
db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None,
|
||||
) -> dict[int, dict]:
|
||||
"""按 group_id 聚合点击 → {group_id: {pv, uv, copy_pv, copy_uv}}。
|
||||
visit 计入 pv/uv,copy(淘宝复制口令) 计入 copy_pv/copy_uv;uv 按 (ip, ua) 近似去重。"""
|
||||
stmt = select(CpsClick)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(CpsClick.clicked_at >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(CpsClick.clicked_at <= date_to)
|
||||
clicks = list(db.execute(stmt).scalars().all())
|
||||
|
||||
agg: dict[int, dict] = {}
|
||||
seen_visit: dict[int, set] = {}
|
||||
seen_copy: dict[int, set] = {}
|
||||
for c in clicks:
|
||||
a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0, "copy_pv": 0, "copy_uv": 0})
|
||||
key = (c.ip, c.ua)
|
||||
if c.event_type == "copy":
|
||||
a["copy_pv"] += 1
|
||||
s = seen_copy.setdefault(c.group_id, set())
|
||||
if key not in s:
|
||||
s.add(key)
|
||||
a["copy_uv"] += 1
|
||||
else:
|
||||
a["pv"] += 1
|
||||
s = seen_visit.setdefault(c.group_id, set())
|
||||
if key not in s:
|
||||
s.add(key)
|
||||
a["uv"] += 1
|
||||
return agg
|
||||
@@ -1,43 +0,0 @@
|
||||
"""cps_wx_user 数据访问:按 openid upsert。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
|
||||
|
||||
def get_by_openid(db: Session, openid: str) -> CpsWxUser | None:
|
||||
return db.execute(
|
||||
select(CpsWxUser).where(CpsWxUser.openid == openid)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def upsert(
|
||||
db: Session, *, openid: str, code: str | None = None, group_id: int | None = None,
|
||||
nickname: str | None = None, headimgurl: str | None = None, unionid: str | None = None,
|
||||
) -> CpsWxUser:
|
||||
"""按 openid upsert。
|
||||
|
||||
- 首次:建行,记来源 code/group。
|
||||
- 已存在:仅在传入非 None 时更新昵称/头像/unionid(base 阶段为 None,不覆盖已有画像);
|
||||
并刷新 last_seen(显式 set,因 onupdate 仅在字段有变更时触发)。
|
||||
"""
|
||||
u = get_by_openid(db, openid)
|
||||
if u is None:
|
||||
u = CpsWxUser(
|
||||
openid=openid, first_code=code, first_group_id=group_id,
|
||||
nickname=nickname, headimgurl=headimgurl, unionid=unionid,
|
||||
)
|
||||
db.add(u)
|
||||
else:
|
||||
if nickname is not None:
|
||||
u.nickname = nickname
|
||||
if headimgurl is not None:
|
||||
u.headimgurl = headimgurl
|
||||
if unionid is not None:
|
||||
u.unionid = unionid
|
||||
u.last_seen = func.now()
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
return u
|
||||
@@ -1,106 +0,0 @@
|
||||
"""反馈页二维码卡配置读写(运营后台可配 → App 反馈页同步)。
|
||||
|
||||
整张卡(开关 + 二维码图 + 三行文案)作为一个 JSON 对象,复用通用 app_config 表
|
||||
(key=feedback_qr_card,value 存这个 dict)。表里没这行 → 返回内置默认(= App 反馈页原
|
||||
硬编码文案,空配置 = 改造前现状,图片走 App 本地兜底)。admin 改 → 写一行 → App 下次拉
|
||||
GET /api/v1/feedback/config 即生效(跨进程一致)。
|
||||
|
||||
不经 app.repositories.app_config(那层按 CONFIG_DEFS 白名单限定 key,且会出现在系统配置页);
|
||||
本 key 由本模块独占维护,不进 CONFIG_DEFS,所以不会污染系统配置页。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.app_config import AppConfig
|
||||
|
||||
_KEY = "feedback_qr_card"
|
||||
|
||||
# 默认值镜像 App 反馈页原硬编码:空配置时整体行为与改造前一致。
|
||||
_DEFAULTS: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"image_url": None, # None = App 用本地 asset 兜底(group_qr.png),再不行画占位伪码
|
||||
"title": "长按图片保存二维码",
|
||||
"group_name": "傻瓜比价官方群",
|
||||
"subtitle": "一起唠嗑共创、解锁新玩法",
|
||||
}
|
||||
|
||||
_FIELDS = tuple(_DEFAULTS.keys())
|
||||
|
||||
|
||||
def _merge(raw: Any) -> dict[str, Any]:
|
||||
"""把 DB 存的(可能不全的)dict 叠加到默认上,得到完整配置(5 个字段,无 updated_at)。"""
|
||||
out = dict(_DEFAULTS)
|
||||
if isinstance(raw, dict):
|
||||
for k in _FIELDS:
|
||||
v = raw.get(k)
|
||||
if v is not None: # image_url 的 None 与默认 None 等价,无需特判
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def get_config(db: Session) -> dict[str, Any]:
|
||||
"""完整配置 + updated_at(admin 读 / App 读共用;App schema 会忽略多余的 updated_at)。"""
|
||||
row = db.get(AppConfig, _KEY)
|
||||
cfg = _merge(row.value if row is not None else None)
|
||||
cfg["updated_at"] = row.updated_at.isoformat() if row is not None and row.updated_at else None
|
||||
return cfg
|
||||
|
||||
|
||||
def _write(db: Session, value: dict[str, Any], *, admin_id: int, commit: bool) -> dict[str, Any]:
|
||||
"""整体覆写该行(value 须为完整 5 字段 dict),返回合并后的完整配置(含 updated_at)。"""
|
||||
row = db.get(AppConfig, _KEY)
|
||||
if row is None:
|
||||
row = AppConfig(key=_KEY, value=value, updated_by_admin_id=admin_id)
|
||||
db.add(row)
|
||||
else:
|
||||
row.value = value # 重新赋整个 dict,SQLAlchemy 能侦测到变更(in-place 改才侦测不到)
|
||||
row.updated_by_admin_id = admin_id
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
else:
|
||||
db.flush()
|
||||
out = _merge(row.value)
|
||||
out["updated_at"] = row.updated_at.isoformat() if row.updated_at else None
|
||||
return out
|
||||
|
||||
|
||||
def update_config(
|
||||
db: Session,
|
||||
*,
|
||||
enabled: bool | None = None,
|
||||
title: str | None = None,
|
||||
group_name: str | None = None,
|
||||
subtitle: str | None = None,
|
||||
admin_id: int,
|
||||
commit: bool = True,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""改文案/开关(只改传了的字段;图片走 set_image)。返回 (before, after) 供审计。"""
|
||||
row = db.get(AppConfig, _KEY)
|
||||
before = _merge(row.value if row is not None else None)
|
||||
new_value = {k: before[k] for k in _FIELDS}
|
||||
if enabled is not None:
|
||||
new_value["enabled"] = enabled
|
||||
if title is not None:
|
||||
new_value["title"] = title.strip()
|
||||
if group_name is not None:
|
||||
new_value["group_name"] = group_name.strip()
|
||||
if subtitle is not None:
|
||||
new_value["subtitle"] = subtitle.strip()
|
||||
after = _write(db, new_value, admin_id=admin_id, commit=commit)
|
||||
return before, after
|
||||
|
||||
|
||||
def set_image(
|
||||
db: Session, image_url: str | None, *, admin_id: int, commit: bool = True
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""设置/清空二维码图 URL。返回 (before, after);before['image_url'] 供调用方删旧文件。"""
|
||||
row = db.get(AppConfig, _KEY)
|
||||
before = _merge(row.value if row is not None else None)
|
||||
new_value = {k: before[k] for k in _FIELDS}
|
||||
new_value["image_url"] = image_url
|
||||
after = _write(db, new_value, admin_id=admin_id, commit=commit)
|
||||
return before, after
|
||||
@@ -1,35 +0,0 @@
|
||||
"""启动确认窗兜底样本落库:单条 insert(都上报、不去重,by design)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.launch_confirm_sample import LaunchConfirmSample
|
||||
from app.schemas.launch_confirm_sample import LaunchConfirmSampleIn
|
||||
|
||||
logger = logging.getLogger("shagua.launch_confirm")
|
||||
|
||||
|
||||
def _trunc(s: Optional[str], n: int) -> Optional[str]:
|
||||
"""截断到列长上限(PG varchar 超长会报错;繁体长标题等需要)。空 → None。"""
|
||||
return s[:n] if s else None
|
||||
|
||||
|
||||
def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
|
||||
"""写入一条样本,返回新行 id。"""
|
||||
row = LaunchConfirmSample(
|
||||
trace_id=_trunc(payload.trace_id, 64),
|
||||
device_id=_trunc(payload.device_id, 64),
|
||||
host_package=_trunc(payload.host_package, 128),
|
||||
target_app=_trunc(payload.target_app, 128),
|
||||
system_locale=_trunc(payload.system_locale, 32),
|
||||
exec_success=bool(payload.exec_success),
|
||||
dialog_title=_trunc(payload.dialog_title, 256),
|
||||
payload=payload.payload,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row.id
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import math
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -107,40 +107,6 @@ def upsert(db: Session, payload: StoreMappingIn) -> tuple[int, int | None]:
|
||||
return 0, existing.id
|
||||
|
||||
|
||||
def mark_taobao_deeplink_invalid(db: Session, shop_id: str) -> int:
|
||||
"""把所有 id_taobao=shop_id 的行标记淘宝 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。
|
||||
|
||||
按 shopId 标记**所有**行 —— 同一个坏 shopId(撞淘宝"页面出错了"降级页)可能散在多次比价的
|
||||
多行里, 全标掉才能让后续 lookup 不再返回它。幂等: 已标记的行(invalid_at 非 NULL)跳过。"""
|
||||
result = db.execute(
|
||||
update(StoreMapping)
|
||||
.where(
|
||||
StoreMapping.id_taobao == shop_id,
|
||||
StoreMapping.taobao_deeplink_invalid_at.is_(None),
|
||||
)
|
||||
.values(taobao_deeplink_invalid_at=func.now())
|
||||
)
|
||||
db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def mark_jd_deeplink_invalid(db: Session, store_id: str) -> int:
|
||||
"""把所有 id_jd=store_id 的行标记京东 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。
|
||||
|
||||
同 mark_taobao_deeplink_invalid:按 storeId 标记**所有**行(撞京东"当前门店超出配送范围"页),
|
||||
全标掉才能让后续 lookup 不再返回它。幂等:已标记的行(invalid_at 非 NULL)跳过。"""
|
||||
result = db.execute(
|
||||
update(StoreMapping)
|
||||
.where(
|
||||
StoreMapping.id_jd == store_id,
|
||||
StoreMapping.jd_deeplink_invalid_at.is_(None),
|
||||
)
|
||||
.values(jd_deeplink_invalid_at=func.now())
|
||||
)
|
||||
db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接
|
||||
# deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。
|
||||
@@ -195,12 +161,6 @@ def lookup_nearest(
|
||||
if tgt == src_key:
|
||||
continue # 不返回源平台自己
|
||||
cands = [r for r in rows if getattr(r, id_attr)]
|
||||
if tgt == "taobao":
|
||||
# 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。
|
||||
cands = [r for r in cands if r.taobao_deeplink_invalid_at is None]
|
||||
elif tgt == "jd":
|
||||
# 同上:失效的京东 deeplink(撞"当前门店超出配送范围"被 invalidate)整条排除。
|
||||
cands = [r for r in cands if r.jd_deeplink_invalid_at is None]
|
||||
if not cands:
|
||||
continue
|
||||
best = _pick_best(cands, lat, lng)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -111,27 +63,14 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User:
|
||||
|
||||
|
||||
def soft_delete_account(db: Session, user: User) -> None:
|
||||
"""注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定。
|
||||
"""注销账号:软删除 + 匿名化。
|
||||
|
||||
把 phone 改成 `deleted_<id>` 释放手机号唯一约束,允许同号码重新注册成全新账号;
|
||||
把 phone 改成 `deleted_<id>` 释放唯一约束,允许同号码重新注册成全新账号;
|
||||
清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行
|
||||
(登录接口校验 status==active)。
|
||||
|
||||
⚠️ 注销必须连同释放 user 上其余唯一约束/外部身份,否则 deleted 行永久占着槽位,
|
||||
对应资源再也无法被复用:
|
||||
- wechat_openid: 不清 → 这个微信再也无法被任何账号绑定(提现通道彻底锁死);
|
||||
- invite_code: 不清 → 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind 的
|
||||
status==active 校验兜住,清掉更干净)。
|
||||
资金安全(未提现现金 / 在审提现单)由调用方 delete_account 端点前置校验,这里只匿名化。
|
||||
"""
|
||||
user.status = "deleted"
|
||||
user.phone = f"deleted_{user.id}"
|
||||
user.nickname = None
|
||||
user.avatar_url = None
|
||||
# 等价"解绑微信":释放 openid 唯一槽 + 清微信昵称头像
|
||||
user.wechat_openid = None
|
||||
user.wechat_nickname = None
|
||||
user.wechat_avatar_url = None
|
||||
# 释放邀请码唯一槽
|
||||
user.invite_code = None
|
||||
db.commit()
|
||||
|
||||
@@ -340,15 +340,6 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
|
||||
return info
|
||||
|
||||
|
||||
def get_cash_balance_cents(db: Session, user_id: int) -> int:
|
||||
"""只读查现金余额(分)。账户不存在返回 0,**不创建账户**——注销前置校验用,
|
||||
不该给一个即将被删除的用户凭空建一行空账户(get_or_create_account 会建)。"""
|
||||
val = db.execute(
|
||||
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
|
||||
).scalar_one_or_none()
|
||||
return val or 0
|
||||
|
||||
|
||||
def has_reviewing_withdraw(db: Session, user_id: int) -> bool:
|
||||
"""用户是否有「待审核(reviewing)」的提现单。
|
||||
|
||||
|
||||
+5
-55
@@ -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):
|
||||
@@ -121,68 +115,24 @@ class TestGrantOut(BaseModel):
|
||||
|
||||
|
||||
class FeedRewardIn(BaseModel):
|
||||
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。
|
||||
"""信息流广告完成后结算奖励。
|
||||
|
||||
规则:全程不关广告才发,金额按整场**总观看时长**折份(每 10 秒 1 份)。client_event_id 用于
|
||||
客户端超时重试幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。
|
||||
每展示满 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"
|
||||
)
|
||||
ecpm: str = Field(..., description="本场信息流 eCPM(代表值,按分/千次展示处理;SDK getEcpm 原值,非元)")
|
||||
duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)")
|
||||
ecpm: str = Field(..., description="本条信息流广告 eCPM,按分/千次展示处理(SDK getEcpm 原值,非元)")
|
||||
duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数")
|
||||
adn: str | None = Field(None, description="实际投放 ADN")
|
||||
slot_id: str | None = Field(None, description="实际展示代码位")
|
||||
feed_scene: str | None = Field(
|
||||
None,
|
||||
max_length=16,
|
||||
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
|
||||
"比价与领券共用同一信息流代码位,需客户端在各调用点显式标注,缺省=未分类",
|
||||
)
|
||||
app_env: str | None = Field(
|
||||
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
|
||||
)
|
||||
our_code_id: str | None = Field(
|
||||
None, max_length=64, description="我们后台配置的代码位 ID(AdConfig 的 104xxx,非底层 rit)"
|
||||
)
|
||||
aborted: bool = Field(
|
||||
False, description="用户中途 ✕ 关闭广告(未走完比价):整场不发,记 closed_early"
|
||||
)
|
||||
|
||||
|
||||
class FeedRewardOut(BaseModel):
|
||||
granted: bool = Field(..., description="本次是否入账。达上限时 false")
|
||||
status: str = Field(..., description="granted / capped / 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 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(该次其实已发奖,跳过未写)"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,11 +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「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是
|
||||
# 按平台 id 索引的 dict 而非 list,只作 debug 展示)。
|
||||
platform_results: dict = Field(default_factory=dict)
|
||||
skipped_dish_count: int | None = None
|
||||
skipped_dish_names: list[str] = Field(default_factory=list)
|
||||
total_dish_count: int | None = None
|
||||
@@ -87,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 总步数")
|
||||
|
||||
|
||||
# ===== 读取出参 =====
|
||||
|
||||
|
||||
@@ -7,30 +7,16 @@ from pydantic import BaseModel
|
||||
class CouponPromptDismissIn(BaseModel):
|
||||
"""客户端拒绝/关闭领券引导窗的通知体。
|
||||
|
||||
server 据此记一条今日 engagement(dismissed)→ 今天**这个 App** 不再弹引导窗。
|
||||
频控按 (device, package, 日):各 App 独立。package 缺省 ""(老客户端兼容,退化为全局态)。
|
||||
server 据此记一条今日 engagement(dismissed)→ 今天这台设备不再弹引导窗。
|
||||
MVP 不鉴权,按 device_id 判断;user_id 登录态带上就一并记(资产),可空。
|
||||
"""
|
||||
|
||||
device_id: str
|
||||
package: str = ""
|
||||
user_id: int | None = None
|
||||
|
||||
|
||||
class CouponPromptShownIn(BaseModel):
|
||||
"""客户端弹出领券引导窗即上报(记 shown)。
|
||||
|
||||
弹出那刻就记一条今日 engagement(shown)→ 今天**这个 App** 不再自动弹(频控主判据,
|
||||
管跨重装;本地 SP 兜后台抖动)。后续用户点领取/拒绝再把 type 升级成 claim_started/dismissed。
|
||||
"""
|
||||
|
||||
device_id: str
|
||||
package: str
|
||||
user_id: int | None = None
|
||||
|
||||
|
||||
class CouponPromptShouldShowOut(BaseModel):
|
||||
"""切到外卖 App 时是否还应弹领券引导窗。今天**这个 App** 已 engage(弹/领/拒)过 → false。"""
|
||||
"""切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。"""
|
||||
|
||||
should_show: bool
|
||||
|
||||
@@ -39,13 +25,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
|
||||
|
||||
@@ -12,17 +12,3 @@ class FeedbackOut(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FeedbackQrConfigOut(BaseModel):
|
||||
"""反馈页二维码卡配置(运营后台可配)。App 反馈页据此渲染整张「加群二维码」卡。
|
||||
|
||||
image_url 是相对 /media 路径(客户端按自己的 BASE_URL 拼绝对地址),为空 → 客户端走本地兜底。
|
||||
"""
|
||||
|
||||
enabled: bool
|
||||
image_url: str | None = None
|
||||
title: str
|
||||
group_name: str
|
||||
subtitle: str
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LaunchConfirmSampleIn(BaseModel):
|
||||
"""一次 agent 兜底的完整样本。
|
||||
|
||||
字段一律宽松可空:落盘是辅助数据,绝不能因个别字段缺失就拒收(否则连累 pricebot 兜底)。
|
||||
"""
|
||||
|
||||
trace_id: str | None = None
|
||||
device_id: str | None = None
|
||||
host_package: str | None = None
|
||||
target_app: str | None = None
|
||||
system_locale: str | None = None
|
||||
exec_success: bool = False
|
||||
dialog_title: str | None = None
|
||||
payload: dict | None = None
|
||||
|
||||
|
||||
class LaunchConfirmSampleOut(BaseModel):
|
||||
"""落库结果。"""
|
||||
|
||||
id: int
|
||||
+1
-50
@@ -1,7 +1,7 @@
|
||||
"""首页平台级展示数据 schemas(客户端首页门面数字)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PlatformStatsOut(BaseModel):
|
||||
@@ -22,52 +22,3 @@ class SavingsFeedItem(BaseModel):
|
||||
|
||||
class SavingsFeedOut(BaseModel):
|
||||
items: list[SavingsFeedItem]
|
||||
|
||||
|
||||
class AppFlagsOut(BaseModel):
|
||||
"""客户端拉取的运营 feature flag(不鉴权,登录前也能拉)。客户端缓存后按需读。"""
|
||||
|
||||
comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch)
|
||||
|
||||
|
||||
class AdConfigPublicOut(BaseModel):
|
||||
"""下发给客户端的穿山甲广告配置(不鉴权,客户端缓存后用)。
|
||||
|
||||
⚠️ 不含 reward_mkey(GroMore 验签密钥,机密,只后端验签用,绝不下发)。
|
||||
"""
|
||||
|
||||
app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读)
|
||||
reward_code_id: str # 福利页激励视频位
|
||||
compare_feed_code_id: str # 比价信息流位
|
||||
coupon_feed_code_id: str # 领券信息流位
|
||||
reward_enabled: bool # 福利激励视频开关
|
||||
compare_ad_enabled: bool # 比价广告开关
|
||||
coupon_ad_enabled: bool # 领券广告开关
|
||||
|
||||
|
||||
class AppVersionOut(BaseModel):
|
||||
"""最新 App 版本信息(OTA 检查更新,不鉴权)。
|
||||
|
||||
客户端用 latest_version_code(整数,单调递增)与本机 versionCode 比较,**不要**用 version_name 字符串比。
|
||||
latest_version_code=0 表示后台尚未配置任何版本 → 客户端一律视为"已是最新"。
|
||||
"""
|
||||
|
||||
latest_version_code: int = 0 # 最新版 versionCode;0=未配置(无更新)
|
||||
latest_version_name: str = "" # 展示用,如 "0.1.4"
|
||||
apk_url: str = "" # 下载链接(发车产出的永久版本化链接)
|
||||
update_note: str = "" # 更新说明,弹窗展示
|
||||
min_supported_version_code: int = 0 # 本机低于此版本=强制更新;0=不强更(全可选)
|
||||
apk_size_bytes: int = 0 # 包大小(字节),展示"约 xMB"
|
||||
apk_sha256: str = "" # APK 完整性校验(客户端下载后比对,防损坏/劫持)
|
||||
|
||||
|
||||
class AppVersionWriteIn(BaseModel):
|
||||
"""内部写入最新版本(发布流程/应急,X-Internal-Secret 校验)。整组原子覆盖。"""
|
||||
|
||||
latest_version_code: int = Field(gt=0)
|
||||
latest_version_name: str = Field(min_length=1)
|
||||
apk_url: str = Field(min_length=1)
|
||||
update_note: str = ""
|
||||
min_supported_version_code: int = 0
|
||||
apk_size_bytes: int = 0
|
||||
apk_sha256: str = ""
|
||||
|
||||
@@ -60,17 +60,3 @@ class StoreMappingOut(BaseModel):
|
||||
|
||||
inserted: int
|
||||
row_id: int | None = None
|
||||
|
||||
|
||||
class StoreMappingInvalidateIn(BaseModel):
|
||||
"""标记某平台某 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报)。"""
|
||||
|
||||
platform: str # 目前只支持 "taobao"
|
||||
shop_id: str # 失效的店铺 id(淘宝 = shopId = id_taobao)
|
||||
|
||||
|
||||
class StoreMappingInvalidateOut(BaseModel):
|
||||
"""标记结果。affected = 本次新标记失效的行数(按 shopId 标记所有匹配行)。"""
|
||||
|
||||
ok: bool
|
||||
affected: int
|
||||
|
||||
@@ -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 |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user