Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7a3ef2e0b | |||
| d8358a6816 | |||
| 95a07f75b7 | |||
| 733a51260f | |||
| 7fd70a264d | |||
| 4b98d405d7 | |||
| 63400c29cc | |||
| 42be0f828f | |||
| 5bafe5b71a | |||
| 498da05995 | |||
| 3cab3b9055 | |||
| f790519765 | |||
| a2fd666203 | |||
| aa898fc09a | |||
| 4ee6de2548 | |||
| c78cfc838f | |||
| d55f47fc59 | |||
| 7368a1ca8a | |||
| 3a40f617bd | |||
| 003fd9d986 | |||
| 04edb50acc | |||
| 3483c1bba9 | |||
| 277f9b16a2 | |||
| 9d93b70b9b | |||
| f853938095 | |||
| 783dfd059d | |||
| f97048ff56 | |||
| f15ca74a22 | |||
| f7d86011c1 | |||
| 9ec9d2389d | |||
| 8fa55eec3e | |||
| 27f76918b2 | |||
| 47812f7fcc | |||
| 8659a7ed2b | |||
| cce3a01de1 | |||
| 8d7b91219a | |||
| 25484aadb8 | |||
| e8bd12cc1f | |||
| 57ddcd356b | |||
| 5f9af204ea | |||
| 3d07461ef5 | |||
| db399a40fa | |||
| a753843804 | |||
| bf82c68408 | |||
| 0ae19dc49c | |||
| 389ece9052 | |||
| 455884401f | |||
| dacb37e3e1 | |||
| 0890e693d7 | |||
| 0bddce516f | |||
| 90fe6d6aa7 | |||
| be5e94ed6d | |||
| 953a05a5e6 | |||
| e69788eb41 | |||
| cfc54ac2be | |||
| b2f5a53dd8 | |||
| a828b51d9f | |||
| 25b2b6850b | |||
| d7c29c0883 | |||
| 6432497af1 | |||
| 94b7c027be | |||
| 97f0fb210f | |||
| 4d392f44f5 | |||
| 357ba27499 | |||
| ece41086cd | |||
| 766666601e | |||
| f5d1a1a20d | |||
| 7126fb3ba3 | |||
| f098ba3816 | |||
| b4780e256e | |||
| b59dc3ac19 | |||
| e252277431 | |||
| 0e42e96ddb |
+34
-4
@@ -40,13 +40,29 @@ MT_CPS_APP_KEY=
|
||||
MT_CPS_APP_SECRET=
|
||||
# 默认渠道追踪标识(sid),用于区分不同 app 的 CPS 数据
|
||||
MT_CPS_DEFAULT_SID=sgbjia
|
||||
# 美团调用走的代理。⚠️ 本机/内网开发直连美团会 SSL EOF,必须填本地代理(如 http://127.0.0.1:7897);
|
||||
# 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。
|
||||
MT_CPS_PROXY=
|
||||
|
||||
# ===== Pricebot 上游 (领券业务透传目标) =====
|
||||
# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend 的 /api/coupon/step。
|
||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
||||
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
|
||||
PRICEBOT_BASE_URL=http://localhost:8000
|
||||
# 【多实例】单机多进程部署时,填逗号分隔的实例列表(端口与 pricebot 集群对齐),透传层按
|
||||
# trace_id 一致性 hash 选实例 → 同一比价所有帧落同一进程(进程内维护 state,无需 Redis)。
|
||||
# 留空 = 单实例(用上面的 PRICEBOT_BASE_URL)。详见 pricebot-backend/docs/并发部署设计.md
|
||||
# PRICEBOT_INSTANCES=http://127.0.0.1:8001,http://127.0.0.1:8002,http://127.0.0.1:8003,http://127.0.0.1:8004,http://127.0.0.1:8005,http://127.0.0.1:8006
|
||||
# 领券单帧最多 wait 6s,加网络往返,30s 兜底
|
||||
PRICEBOT_REQUEST_TIMEOUT_SEC=30
|
||||
# 比价(intent/recognize + price/step)透传超时:大上下文 LLM + 逐帧 LLM,给 60s
|
||||
PRICEBOT_COMPARE_TIMEOUT_SEC=60
|
||||
|
||||
# ===== 内部端点 (server→server, pricebot 上报价格观测) =====
|
||||
# pricebot 比价 done 后把各平台到手价 POST 到本服务 /internal/price-observation 落库
|
||||
# (price_observation 表,比价资产沉淀层),靠共享密钥头 X-Internal-Secret 校验。
|
||||
# 必须与 pricebot 侧的 INTERNAL_API_SECRET **同值**;留空 = 内部写端点关闭(返 503)。
|
||||
# 启用前两边都填同一高熵串:python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
INTERNAL_API_SECRET=
|
||||
|
||||
# ===== CORS =====
|
||||
# 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类
|
||||
@@ -63,12 +79,26 @@ WXPAY_MCH_PRIVATE_KEY_PATH=./secrets/apiclient_key.pem
|
||||
WXPAY_PUBLIC_KEY_ID=PUB_KEY_ID_xxxxxxxx
|
||||
WXPAY_PUBLIC_KEY_PATH=./secrets/pub_key.pem
|
||||
WXPAY_TRANSFER_SCENE_ID=1000
|
||||
WXPAY_AUTH_NOTIFY_URL=
|
||||
WITHDRAW_AUTO_RECONCILE_ENABLED=false
|
||||
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC=300
|
||||
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES=15
|
||||
# 0 点自动兑金币开关(deploy/daily-exchange.timer 触发的脚本读它;false=脚本 no-op)。默认 true。
|
||||
AUTO_EXCHANGE_ENABLED=true
|
||||
|
||||
# ===== 穿山甲激励视频(服务端发奖回调)=====
|
||||
# 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。
|
||||
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",用于验签,从后台取到后填这里;
|
||||
# 配齐并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。
|
||||
# 穿山甲"奖励校验密钥"(m-key),验签用,从 GroMore 后台各广告位取到后填这里。
|
||||
# 配齐(任一非空)并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。
|
||||
# 每个激励位 m-key 不同但共用同一回调 URL → 各位分开一行配(留空的忽略);验签逐个试、任一通过即接受。
|
||||
PANGLE_CALLBACK_ENABLED=false
|
||||
# 测试应用 激励位 104099649
|
||||
PANGLE_REWARD_SECRET_TEST=
|
||||
# 测试应用 专属激励位 104127529
|
||||
PANGLE_REWARD_SECRET_TEST_DEDICATED=
|
||||
# 正式应用 激励位 104099389
|
||||
PANGLE_REWARD_SECRET_PROD=
|
||||
# (旧用法,仍兼容:单个或逗号分隔的多个 m-key,会与上面三个合并去重)
|
||||
PANGLE_REWARD_SECRET=
|
||||
# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖,
|
||||
# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。
|
||||
|
||||
+9
-1
@@ -23,7 +23,14 @@ dist/
|
||||
*.db-journal
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
data/
|
||||
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html——
|
||||
# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试
|
||||
# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
|
||||
data/*
|
||||
!data/media/
|
||||
data/media/*
|
||||
!data/media/dl.html
|
||||
!data/media/taobao_landing.jpg
|
||||
|
||||
secrets/*
|
||||
!secrets/.gitkeep
|
||||
@@ -37,3 +44,4 @@ secrets/*
|
||||
|
||||
# 运行日志(run.sh 输出, 不入库)
|
||||
*.log
|
||||
logs/
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge coupon_state and invite heads
|
||||
|
||||
Revision ID: 0cf18d590b1d
|
||||
Revises: coupon_state_tables, invite_code_and_relation
|
||||
Create Date: 2026-06-08 05:49:29.604571
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0cf18d590b1d'
|
||||
down_revision: Union[str, Sequence[str], None] = ('coupon_state_tables', 'invite_code_and_relation')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge meituan_coupon 与 ops/price_observation 迁移头
|
||||
|
||||
Revision ID: 11a1d08c6f55
|
||||
Revises: meituan_coupon_table, opsrename01
|
||||
Create Date: 2026-06-08 00:06:01.633796
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '11a1d08c6f55'
|
||||
down_revision: Union[str, Sequence[str], None] = ('meituan_coupon_table', 'opsrename01')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge store_mapping_jd_dl_invalid and ad_revenue heads (0.1.2)
|
||||
|
||||
Revision ID: 45047b5a884c
|
||||
Revises: d4e68464761d, store_mapping_jd_dl_invalid
|
||||
Create Date: 2026-06-16 01:38:26.273226
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '45047b5a884c'
|
||||
down_revision: Union[str, Sequence[str], None] = ('d4e68464761d', 'store_mapping_jd_dl_invalid')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge user_force_onboarding and comparison_idx_coupon_daily heads
|
||||
|
||||
Revision ID: 9b894f5fff05
|
||||
Revises: d4b87c5847de, user_force_onboarding
|
||||
Create Date: 2026-06-10 22:49:02.248506
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9b894f5fff05'
|
||||
down_revision: Union[str, Sequence[str], None] = ('d4b87c5847de', 'user_force_onboarding')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge invite_fingerprint and ad_reward heads
|
||||
|
||||
Revision ID: a8c47fc4dc39
|
||||
Revises: invite_fingerprint_table, 044dce6e9b1f
|
||||
Create Date: 2026-06-10 01:03:03.699443
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a8c47fc4dc39'
|
||||
down_revision: Union[str, Sequence[str], None] = ('invite_fingerprint_table', '044dce6e9b1f')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,38 @@
|
||||
"""add ad_session_id to ad feed reward record
|
||||
|
||||
Revision ID: ad_feed_reward_session
|
||||
Revises: coin_reward_phase2
|
||||
Create Date: 2026-06-09
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "ad_feed_reward_session"
|
||||
down_revision = "coin_reward_phase2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ad_feed_reward_record",
|
||||
sa.Column("ad_session_id", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_ad_feed_reward_record_ad_session_id"),
|
||||
"ad_feed_reward_record",
|
||||
["ad_session_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_ad_feed_reward_record_ad_session_id"),
|
||||
table_name="ad_feed_reward_record",
|
||||
)
|
||||
op.drop_column("ad_feed_reward_record", "ad_session_id")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""ad revenue report columns: app_env + our_code_id
|
||||
|
||||
给 ad_ecpm_record / ad_reward_record / ad_feed_reward_record 各加两列:
|
||||
- app_env:我们的穿山甲应用环境(prod=傻瓜比价正式 / test=测试应用)
|
||||
- our_code_id:我们在穿山甲后台配置的代码位 ID(104xxx,非底层 mediation rit)
|
||||
|
||||
供「广告收益报表」按 用户/日期/广告类型/应用/代码位 聚合 展示条数/收益/金币。
|
||||
旧数据这两列为 NULL(报表里来源列留空),新数据由客户端上报/发奖时回填。
|
||||
|
||||
Revision ID: ad_revenue_report_cols
|
||||
Revises: coupon_engage_per_package
|
||||
Create Date: 2026-06-15
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "ad_revenue_report_cols"
|
||||
down_revision = "coupon_engage_per_package"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_TABLES = ("ad_ecpm_record", "ad_reward_record", "ad_feed_reward_record")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in _TABLES:
|
||||
op.add_column(table, sa.Column("app_env", sa.String(length=16), nullable=True))
|
||||
op.add_column(table, sa.Column("our_code_id", sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in _TABLES:
|
||||
op.drop_column(table, "our_code_id")
|
||||
op.drop_column(table, "app_env")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""user.username(对外展示账号 ID)+ 默认昵称,并回填存量用户
|
||||
|
||||
Revision ID: b3f1a2c4d5e6
|
||||
Revises: 45047b5a884c
|
||||
Create Date: 2026-06-16 12:00:00.000000
|
||||
|
||||
加 user.username(11 位纯数字、首位非 1 与手机号天然区分、全局唯一)。存量用户:
|
||||
生成唯一 username,昵称为空的补 9 位字母数字随机昵称。
|
||||
迁移自包含(不 import app 业务代码,逻辑冻结为当时快照)。
|
||||
"""
|
||||
import secrets
|
||||
import string
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b3f1a2c4d5e6'
|
||||
down_revision: Union[str, Sequence[str], None] = '45047b5a884c'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
_USERNAME_FIRST = "23456789" # 首位避前导 0、避 1(手机号都以 1 开头)
|
||||
_USERNAME_DIGITS = "0123456789"
|
||||
_NICKNAME_ALPHABET = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
def _gen_username() -> str:
|
||||
return secrets.choice(_USERNAME_FIRST) + "".join(
|
||||
secrets.choice(_USERNAME_DIGITS) for _ in range(10)
|
||||
)
|
||||
|
||||
|
||||
def _gen_nickname() -> str:
|
||||
return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(9))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. 先加可空列(存量行此刻还没值)
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('username', sa.String(length=11), nullable=True))
|
||||
|
||||
# 2. 回填存量:每人一个唯一 username;昵称为空的补随机昵称。
|
||||
# 表里 username 原本全空,本批用 used 防互撞即可(无既有非空值需规避)。
|
||||
# "user" 是 PostgreSQL 保留字,必须加双引号(SQLite 也接受双引号标识符)。
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(sa.text('SELECT id, nickname FROM "user"')).fetchall()
|
||||
used = set()
|
||||
for row in rows:
|
||||
uid, nick = row[0], row[1]
|
||||
while True:
|
||||
uname = _gen_username()
|
||||
if uname not in used:
|
||||
used.add(uname)
|
||||
break
|
||||
if nick is None or not str(nick).strip():
|
||||
bind.execute(
|
||||
sa.text('UPDATE "user" SET username = :u, nickname = :n WHERE id = :i'),
|
||||
{"u": uname, "n": _gen_nickname(), "i": uid},
|
||||
)
|
||||
else:
|
||||
bind.execute(
|
||||
sa.text('UPDATE "user" SET username = :u WHERE id = :i'),
|
||||
{"u": uname, "i": uid},
|
||||
)
|
||||
|
||||
# 3. 收紧:NOT NULL + 唯一索引(对齐模型 unique=True, index=True)
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.alter_column('username', existing_type=sa.String(length=11), nullable=False)
|
||||
batch_op.create_index('ix_user_username', ['username'], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.drop_index('ix_user_username')
|
||||
batch_op.drop_column('username')
|
||||
@@ -0,0 +1,76 @@
|
||||
"""coin reward points phase 1 tables
|
||||
|
||||
Revision ID: coin_reward_phase1
|
||||
Revises: pstat3growth
|
||||
Create Date: 2026-06-07 15:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'coin_reward_phase1'
|
||||
down_revision: Union[str, Sequence[str], None] = 'pstat3growth'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'signin_boost_record',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('signin_date', sa.Date(), nullable=False),
|
||||
sa.Column('coin_awarded', sa.Integer(), nullable=False),
|
||||
sa.Column('ad_ref_id', sa.String(length=64), nullable=True),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'signin_date', name='uq_signin_boost_user_date'),
|
||||
)
|
||||
with op.batch_alter_table('signin_boost_record', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_signin_boost_record_user_id'), ['user_id'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
'ad_feed_reward_record',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('client_event_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('reward_date', sa.String(length=10), nullable=False),
|
||||
sa.Column('duration_seconds', sa.Integer(), nullable=False),
|
||||
sa.Column('unit_count', sa.Integer(), nullable=False),
|
||||
sa.Column('ecpm_raw', sa.String(length=32), nullable=False),
|
||||
sa.Column('adn', sa.String(length=32), nullable=True),
|
||||
sa.Column('slot_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('coin', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('client_event_id', name='uq_ad_feed_reward_client_event'),
|
||||
)
|
||||
with op.batch_alter_table('ad_feed_reward_record', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_user_id'), ['user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_reward_date'), ['reward_date'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_created_at'), ['created_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('ad_feed_reward_record', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_created_at'))
|
||||
batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_reward_date'))
|
||||
batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_user_id'))
|
||||
op.drop_table('ad_feed_reward_record')
|
||||
|
||||
with op.batch_alter_table('signin_boost_record', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_signin_boost_record_user_id'))
|
||||
op.drop_table('signin_boost_record')
|
||||
@@ -0,0 +1,71 @@
|
||||
"""coin reward points phase2
|
||||
|
||||
Revision ID: coin_reward_phase2
|
||||
Revises: 0cf18d590b1d
|
||||
Create Date: 2026-06-08
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "coin_reward_phase2"
|
||||
down_revision = "0cf18d590b1d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ad_ecpm_record",
|
||||
sa.Column("ad_session_id", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_ad_ecpm_record_ad_session_id"),
|
||||
"ad_ecpm_record",
|
||||
["ad_session_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_ad_ecpm_record_session",
|
||||
"ad_ecpm_record",
|
||||
["ad_session_id"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"ad_reward_record",
|
||||
sa.Column(
|
||||
"reward_scene",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default="reward_video",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ad_reward_record",
|
||||
sa.Column("ad_session_id", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ad_reward_record",
|
||||
sa.Column("ecpm_raw", sa.String(length=32), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_ad_reward_record_ad_session_id"),
|
||||
"ad_reward_record",
|
||||
["ad_session_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_ad_reward_record_ad_session_id"), table_name="ad_reward_record")
|
||||
op.drop_column("ad_reward_record", "ecpm_raw")
|
||||
op.drop_column("ad_reward_record", "ad_session_id")
|
||||
op.drop_column("ad_reward_record", "reward_scene")
|
||||
|
||||
op.drop_index("uq_ad_ecpm_record_session", table_name="ad_ecpm_record")
|
||||
op.drop_index(op.f("ix_ad_ecpm_record_ad_session_id"), table_name="ad_ecpm_record")
|
||||
op.drop_column("ad_ecpm_record", "ad_session_id")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""add coin_transaction task ref unique index
|
||||
|
||||
可重复任务(task_enable_notification「打开消息提醒」逐次减半领取)的发放路径是无锁的
|
||||
读-算-写:并发/连点的两个 claim 会都读到同一已领次数、算出同一 ref_id(task_key:N)、
|
||||
各发一笔,导致双倍发钱。给 coin_transaction 加 (user_id, biz_type, ref_id) 部分唯一索引
|
||||
(仅 biz_type LIKE 'task%' 且 ref_id 非空),第二笔撞唯一约束 → IntegrityError,被
|
||||
task.claim_task 兜底成 AlreadyClaimedError(409)。与 cash_transaction 提现退款、
|
||||
withdraw_order 在途单的「唯一约束 + IntegrityError 兜底」同模式。
|
||||
|
||||
Revision ID: coin_txn_task_ref_uq
|
||||
Revises: drop_force_onboarding
|
||||
Create Date: 2026-06-12 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "coin_txn_task_ref_uq"
|
||||
down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"ux_coin_transaction_task_ref",
|
||||
"coin_transaction",
|
||||
["user_id", "biz_type", "ref_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
|
||||
postgresql_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ux_coin_transaction_task_ref", table_name="coin_transaction")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""comparison_record: 客户端环境 / 性能统计 / LLM 明细 debug 字段
|
||||
|
||||
Revision ID: comparison_debug_fields
|
||||
Revises: cps_activity_image
|
||||
Create Date: 2026-06-17 22:00:00.000000
|
||||
|
||||
给 admin 比价记录 debug 页新增采集列,全部 nullable(旧记录 / 未发版客户端为 NULL,向后兼容):
|
||||
- 客户端环境: device_*/rom_*/android_*/app_version*/source_app_version/经纬度
|
||||
- 过程统计: total_ms/step_count/llm_call_count/retry_count
|
||||
- LLM 明细: llm_calls(JSONB,server 按 trace_id 同机拉 pricebot 落库)
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "comparison_debug_fields"
|
||||
down_revision: Union[str, Sequence[str], None] = "cps_activity_image"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("comparison_record", sa.Column("device_model", sa.String(64), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("device_manufacturer", sa.String(64), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("rom_vendor", sa.String(32), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("rom_name", sa.String(32), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("rom_version", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("android_version", sa.String(16), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("android_sdk", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("app_version", sa.String(32), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("app_version_code", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("source_app_version", sa.String(32), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("longitude", sa.Float(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("latitude", sa.Float(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("total_ms", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("step_count", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("llm_call_count", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("retry_count", sa.Integer(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("llm_calls", _JSONB, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for col in (
|
||||
"llm_calls", "retry_count", "llm_call_count", "step_count", "total_ms",
|
||||
"latitude", "longitude", "source_app_version", "app_version_code",
|
||||
"app_version", "android_sdk", "android_version", "rom_version",
|
||||
"rom_name", "rom_vendor", "device_manufacturer", "device_model",
|
||||
):
|
||||
op.drop_column("comparison_record", col)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add composite index (status, created_at) on comparison_record
|
||||
|
||||
首页轮播 feed(按 status='success' 过滤 + created_at 近期排序)与省钱战绩聚合
|
||||
都吃这个过滤+排序;复合索引避免随数据量增大退化成全表扫。
|
||||
|
||||
Revision ID: comparison_status_created_idx
|
||||
Revises: a8c47fc4dc39
|
||||
Create Date: 2026-06-10
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "comparison_status_created_idx"
|
||||
down_revision = "a8c47fc4dc39"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
INDEX_NAME = "ix_comparison_status_created"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
# PG 上 comparison_record 已有数据量, 普通 CREATE INDEX 持表写锁会阻塞线上写入;
|
||||
# 用 CONCURRENTLY 不锁表(须脱离事务, autocommit_block 切到自动提交)。
|
||||
with op.get_context().autocommit_block():
|
||||
op.create_index(
|
||||
INDEX_NAME, "comparison_record", ["status", "created_at"],
|
||||
unique=False, postgresql_concurrently=True,
|
||||
)
|
||||
else:
|
||||
op.create_index(
|
||||
INDEX_NAME, "comparison_record", ["status", "created_at"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
with op.get_context().autocommit_block():
|
||||
op.drop_index(
|
||||
INDEX_NAME, table_name="comparison_record",
|
||||
postgresql_concurrently=True,
|
||||
)
|
||||
else:
|
||||
op.drop_index(INDEX_NAME, table_name="comparison_record")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""coupon daily completion table(今日跑完整轮领券 → 首页置灰源)
|
||||
|
||||
Revision ID: coupon_daily_completion
|
||||
Revises: f8d3b1e60a27
|
||||
Create Date: 2026-06-10 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "coupon_daily_completion"
|
||||
down_revision: str | Sequence[str] | None = "f8d3b1e60a27"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"coupon_daily_completion",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("device_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("complete_date", sa.Date(), nullable=False),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("device_id", "complete_date", name="uq_coupon_completion_device_date"),
|
||||
)
|
||||
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_coupon_daily_completion_user_id"), ["user_id"], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_coupon_daily_completion_trace_id"), ["trace_id"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_trace_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_user_id"))
|
||||
op.drop_table("coupon_daily_completion")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""coupon_prompt_engagement 频控加 package 维度(美团/淘宝/京东各自独立弹)
|
||||
|
||||
Revision ID: coupon_engage_per_package
|
||||
Revises: store_mapping_jd_cols
|
||||
Create Date: 2026-06-14 11:00:00.000000
|
||||
|
||||
需求:领券引导窗按 (device, App, 自然日) 频控——在美团弹过/领过,不影响淘宝、京东今天
|
||||
仍各弹一次。原表唯一键是 (device_id, engage_date),缺 package → 任一 App 弹过就把整台
|
||||
设备当天标记 engage,其余 App 被压住不弹(bug)。
|
||||
|
||||
本迁移:
|
||||
1. 加 package 列(NOT NULL,旧行用 server_default "" 填占位,不影响新逻辑判断)。
|
||||
2. 旧唯一约束 (device_id, engage_date) → 新 (device_id, package, engage_date)。
|
||||
|
||||
SQLite 不支持直接 drop/add 约束,用 batch_alter_table(建临时表 + 拷数据 + 换名,
|
||||
与 store_mapping_* 同款)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'coupon_engage_per_package'
|
||||
down_revision: Union[str, Sequence[str], None] = 'store_mapping_jd_cols'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
|
||||
# 加 package 列。旧行(改造前的全局记录)填 "" 占位:它们对应的是"老的全局态",
|
||||
# 新逻辑按 (device, package, 日) 判,占位 "" 不会与真实包名(com.xxx)碰撞。
|
||||
batch_op.add_column(
|
||||
sa.Column('package', sa.String(length=64), nullable=False, server_default='')
|
||||
)
|
||||
# 旧唯一约束 (device_id, engage_date) → 新三元组 (device_id, package, engage_date)。
|
||||
batch_op.drop_constraint('uq_coupon_engage_device_date', type_='unique')
|
||||
batch_op.create_unique_constraint(
|
||||
'uq_coupon_engage_device_pkg_date',
|
||||
['device_id', 'package', 'engage_date'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('uq_coupon_engage_device_pkg_date', type_='unique')
|
||||
batch_op.create_unique_constraint(
|
||||
'uq_coupon_engage_device_date',
|
||||
['device_id', 'engage_date'],
|
||||
)
|
||||
batch_op.drop_column('package')
|
||||
@@ -0,0 +1,77 @@
|
||||
"""coupon state tables (领券今日状态:弹窗频控 engagement + 领券记录 claim)
|
||||
|
||||
Revision ID: coupon_state_tables
|
||||
Revises: 11a1d08c6f55
|
||||
Create Date: 2026-06-08 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'coupon_state_tables'
|
||||
down_revision: Union[str, Sequence[str], None] = '11a1d08c6f55'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# PG 上为 JSONB,其它(SQLite dev)为 JSON——与模型层 with_variant 对齐
|
||||
_JSON = sa.JSON().with_variant(postgresql.JSONB(), 'postgresql')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 领券记录(资产层,当前不参与去重判断)
|
||||
op.create_table(
|
||||
'coupon_claim_record',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('device_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('coupon_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('claim_date', sa.Date(), nullable=False),
|
||||
sa.Column('status', sa.String(length=24), nullable=False),
|
||||
sa.Column('vendor', sa.String(length=48), nullable=True),
|
||||
sa.Column('coupon_name', sa.String(length=128), nullable=True),
|
||||
sa.Column('claimed_count', sa.Integer(), nullable=True),
|
||||
sa.Column('trace_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('reason', sa.String(length=255), nullable=True),
|
||||
sa.Column('extra', _JSON, nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('device_id', 'coupon_id', 'claim_date', name='uq_coupon_claim_device_coupon_date'),
|
||||
)
|
||||
with op.batch_alter_table('coupon_claim_record', schema=None) as batch_op:
|
||||
batch_op.create_index('ix_coupon_claim_device_date', ['device_id', 'claim_date'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_coupon_claim_record_user_id'), ['user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_coupon_claim_record_trace_id'), ['trace_id'], unique=False)
|
||||
|
||||
# 弹窗频控(今天 engage 过就不再弹)
|
||||
op.create_table(
|
||||
'coupon_prompt_engagement',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('device_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('engage_date', sa.Date(), nullable=False),
|
||||
sa.Column('engage_type', sa.String(length=16), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('device_id', 'engage_date', name='uq_coupon_engage_device_date'),
|
||||
)
|
||||
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_coupon_prompt_engagement_user_id'), ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_coupon_prompt_engagement_user_id'))
|
||||
op.drop_table('coupon_prompt_engagement')
|
||||
|
||||
with op.batch_alter_table('coupon_claim_record', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_coupon_claim_record_trace_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_coupon_claim_record_user_id'))
|
||||
batch_op.drop_index('ix_coupon_claim_device_date')
|
||||
op.drop_table('coupon_claim_record')
|
||||
@@ -0,0 +1,38 @@
|
||||
"""cps activity 落地页图: cps_activity.image_url + 回填存量淘宝活动
|
||||
|
||||
新建淘宝活动时运营提供落地页主视觉图(上传或选已有),落地页按活动展示对应图。
|
||||
存量淘宝活动回填为此前写死的那张图,无缝衔接。
|
||||
|
||||
Revision ID: cps_activity_image
|
||||
Revises: cps_v2_platforms
|
||||
Create Date: 2026-06-17 12:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
revision: str = "cps_activity_image"
|
||||
down_revision: Union[str, Sequence[str], None] = "cps_v2_platforms"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("cps_activity", sa.Column("image_url", sa.String(512), nullable=True))
|
||||
# 回填存量淘宝活动 = 此前写死的那张图(绝对 URL,基于落地页域名 CPS_REDIRECT_BASE;
|
||||
# dev 未配则相对路径)。新活动由运营自传/选,不回填。
|
||||
base = settings.CPS_REDIRECT_BASE.rstrip("/")
|
||||
default_img = f"{base}/media/taobao_landing.jpg" if base else "/media/taobao_landing.jpg"
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE cps_activity SET image_url = :img WHERE platform = 'taobao'"
|
||||
).bindparams(img=default_img)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("cps_activity", "image_url")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""cps_link / cps_click tables (群发短链 + 点击统计)
|
||||
|
||||
Revision ID: cps_link_tables
|
||||
Revises: cps_tables
|
||||
Create Date: 2026-06-16 13:20:00.000000
|
||||
|
||||
群发券链接套一层 /c/{code} 做点击统计:cps_link(短码→美团目标) + cps_click(点击事件)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "cps_link_tables"
|
||||
down_revision: Union[str, Sequence[str], None] = "cps_tables"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_NOW = sa.text("CURRENT_TIMESTAMP")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cps_link",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("code", sa.String(length=16), nullable=False),
|
||||
sa.Column("group_id", sa.Integer(), nullable=False),
|
||||
sa.Column("activity_id", sa.Integer(), nullable=False),
|
||||
sa.Column("sid", sa.String(length=64), nullable=False),
|
||||
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan"),
|
||||
sa.Column("target_url", sa.String(length=1024), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("code", name="uq_cps_link_code"),
|
||||
)
|
||||
op.create_index("ix_cps_link_code", "cps_link", ["code"])
|
||||
op.create_index("ix_cps_link_group_id", "cps_link", ["group_id"])
|
||||
op.create_index("ix_cps_link_activity_id", "cps_link", ["activity_id"])
|
||||
op.create_index("ix_cps_link_sid", "cps_link", ["sid"])
|
||||
|
||||
op.create_table(
|
||||
"cps_click",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("link_id", sa.Integer(), nullable=False),
|
||||
sa.Column("group_id", sa.Integer(), nullable=False),
|
||||
sa.Column("sid", sa.String(length=64), nullable=False),
|
||||
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||
sa.Column("ua", sa.String(length=512), nullable=True),
|
||||
sa.Column("clicked_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_cps_click_link_id", "cps_click", ["link_id"])
|
||||
op.create_index("ix_cps_click_group_id", "cps_click", ["group_id"])
|
||||
op.create_index("ix_cps_click_sid", "cps_click", ["sid"])
|
||||
op.create_index("ix_cps_click_clicked_at", "cps_click", ["clicked_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("cps_click")
|
||||
op.drop_table("cps_link")
|
||||
@@ -0,0 +1,92 @@
|
||||
"""cps_group / cps_activity / cps_order tables (CPS 分发与对账)
|
||||
|
||||
Revision ID: cps_tables
|
||||
Revises: b3f1a2c4d5e6
|
||||
Create Date: 2026-06-16 12:40:00.000000
|
||||
|
||||
群发 CPS 优惠券的分发与对账:cps_group(群=sid) + cps_activity(可推广活动池) +
|
||||
cps_order(美团 query_order 按 sid 拉回的订单明细)。金额统一存「分」。
|
||||
order_id 全局唯一(reconcile 按它 upsert,订单状态会变则更新)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "cps_tables"
|
||||
down_revision: Union[str, Sequence[str], None] = "b3f1a2c4d5e6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_NOW = sa.text("CURRENT_TIMESTAMP")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cps_group",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("sid", sa.String(length=64), nullable=False),
|
||||
sa.Column("name", sa.String(length=128), nullable=False),
|
||||
sa.Column("member_count", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
|
||||
sa.Column("remark", sa.String(length=256), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("sid", name="uq_cps_group_sid"),
|
||||
)
|
||||
op.create_index("ix_cps_group_sid", "cps_group", ["sid"])
|
||||
|
||||
op.create_table(
|
||||
"cps_activity",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan"),
|
||||
sa.Column("name", sa.String(length=128), nullable=False),
|
||||
sa.Column("act_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("product_view_sign", sa.String(length=128), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
|
||||
sa.Column("remark", sa.String(length=256), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"cps_order",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("order_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("sid", sa.String(length=64), nullable=True),
|
||||
sa.Column("act_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("biz_line", sa.Integer(), nullable=True),
|
||||
sa.Column("trade_type", sa.Integer(), nullable=True),
|
||||
sa.Column("pay_price_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("commission_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("commission_rate", sa.String(length=16), nullable=True),
|
||||
sa.Column("refund_price_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("refund_profit_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("mt_status", sa.String(length=8), nullable=True),
|
||||
sa.Column("invalid_reason", sa.String(length=128), nullable=True),
|
||||
sa.Column("product_name", sa.String(length=512), nullable=True),
|
||||
sa.Column("pay_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("mt_update_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"raw",
|
||||
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("first_seen", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("order_id", name="uq_cps_order_order_id"),
|
||||
)
|
||||
op.create_index("ix_cps_order_order_id", "cps_order", ["order_id"])
|
||||
op.create_index("ix_cps_order_sid", "cps_order", ["sid"])
|
||||
op.create_index("ix_cps_order_act_id", "cps_order", ["act_id"])
|
||||
op.create_index("ix_cps_order_mt_status", "cps_order", ["mt_status"])
|
||||
op.create_index("ix_cps_order_pay_time", "cps_order", ["pay_time"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("cps_order")
|
||||
op.drop_table("cps_activity")
|
||||
op.drop_table("cps_group")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""cps v2: 活动 payload(淘口令/京东链接) + 群多平台/sid 可空 + 点击 event_type
|
||||
|
||||
Revision ID: cps_v2_platforms
|
||||
Revises: cps_link_tables
|
||||
Create Date: 2026-06-17 11:00:00.000000
|
||||
|
||||
接入淘宝/京东:
|
||||
- cps_activity.payload 淘宝整段淘口令文本 / 京东推广链接(美团仍用 act_id)
|
||||
- cps_group.platforms 该群发哪些平台(多选);sid 改可空(纯淘宝/京东无 sid)
|
||||
- cps_link.sid 可空 + target_url 加长(淘口令较长)
|
||||
- cps_click.event_type visit / copy(淘宝落地页"复制口令")
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "cps_v2_platforms"
|
||||
down_revision: Union[str, Sequence[str], None] = "cps_link_tables"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 活动:淘宝淘口令 / 京东链接
|
||||
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
|
||||
|
||||
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空
|
||||
op.add_column(
|
||||
"cps_group",
|
||||
sa.Column(
|
||||
"platforms",
|
||||
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[\"meituan\"]'::jsonb"),
|
||||
),
|
||||
)
|
||||
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True)
|
||||
|
||||
# link:sid 可空 + target 加长
|
||||
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True)
|
||||
op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048))
|
||||
|
||||
# click:sid 可空 + 事件类型
|
||||
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True)
|
||||
op.add_column(
|
||||
"cps_click",
|
||||
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("cps_click", "event_type")
|
||||
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024))
|
||||
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.drop_column("cps_group", "platforms")
|
||||
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.drop_column("cps_activity", "payload")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""cps 微信用户表 cps_wx_user + cps_click 加 openid
|
||||
|
||||
CPS 落地页微信网页授权拿 openid/昵称头像,做用户级群统计。
|
||||
|
||||
Revision ID: cps_wx_user
|
||||
Revises: comparison_debug_fields
|
||||
Create Date: 2026-06-19 12:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "cps_wx_user"
|
||||
down_revision: Union[str, Sequence[str], None] = "comparison_debug_fields"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cps_wx_user",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("openid", sa.String(64), nullable=False),
|
||||
sa.Column("unionid", sa.String(64), nullable=True),
|
||||
sa.Column("nickname", sa.String(128), nullable=True),
|
||||
sa.Column("headimgurl", sa.String(512), nullable=True),
|
||||
sa.Column("first_code", sa.String(16), nullable=True),
|
||||
sa.Column("first_group_id", sa.Integer(), nullable=True),
|
||||
sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_cps_wx_user_openid", "cps_wx_user", ["openid"], unique=True)
|
||||
op.create_index("ix_cps_wx_user_unionid", "cps_wx_user", ["unionid"])
|
||||
op.create_index("ix_cps_wx_user_first_group_id", "cps_wx_user", ["first_group_id"])
|
||||
|
||||
op.add_column("cps_click", sa.Column("openid", sa.String(64), nullable=True))
|
||||
op.create_index("ix_cps_click_openid", "cps_click", ["openid"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_cps_click_openid", table_name="cps_click")
|
||||
op.drop_column("cps_click", "openid")
|
||||
op.drop_index("ix_cps_wx_user_first_group_id", table_name="cps_wx_user")
|
||||
op.drop_index("ix_cps_wx_user_unionid", table_name="cps_wx_user")
|
||||
op.drop_index("ix_cps_wx_user_openid", table_name="cps_wx_user")
|
||||
op.drop_table("cps_wx_user")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge comparison idx and coupon daily completion heads
|
||||
|
||||
Revision ID: d4b87c5847de
|
||||
Revises: comparison_status_created_idx, onboarding_completion
|
||||
Create Date: 2026-06-10 16:45:50.285917
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd4b87c5847de'
|
||||
down_revision: Union[str, Sequence[str], None] = ('comparison_status_created_idx', 'onboarding_completion')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge ad_revenue_report_cols and store_mapping_tb_dl_invalid heads
|
||||
|
||||
Revision ID: d4e68464761d
|
||||
Revises: ad_revenue_report_cols, store_mapping_tb_dl_invalid
|
||||
Create Date: 2026-06-15 21:55:58.115692
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd4e68464761d'
|
||||
down_revision: Union[str, Sequence[str], None] = ('ad_revenue_report_cols', 'store_mapping_tb_dl_invalid')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,40 @@
|
||||
"""drop user.force_onboarding column
|
||||
|
||||
force_onboarding(运营「按用户」强制新手引导)机制废弃:客户端实际按 onboarding_completion
|
||||
(设备+账号 维度)判断是否走引导,该 force_onboarding 列从未被客户端正确响应(被
|
||||
onboarding_completed 压过)。改用 admin「设备维度」重置(删 onboarding_completion 记录)替代,
|
||||
见 app/admin/routers/onboarding.py。本迁移删列。
|
||||
|
||||
Revision ID: drop_force_onboarding
|
||||
Revises: 9b894f5fff05
|
||||
Create Date: 2026-06-12 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "drop_force_onboarding"
|
||||
down_revision: Union[str, Sequence[str], None] = "9b894f5fff05"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("user", "force_onboarding")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 回滚:重建列(同 user_force_onboarding 迁移的 upgrade)。sa.false() 两端兼容。
|
||||
op.add_column(
|
||||
"user",
|
||||
sa.Column(
|
||||
"force_onboarding",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""add user.debug_trace_enabled + comparison_record.trace_url
|
||||
|
||||
调试链接权限功能:
|
||||
- user.debug_trace_enabled:运营后台给指定用户开「复制调试链接」权限
|
||||
- comparison_record.trace_url:比价记录页「复制调试链接」的数据(pricebot done 帧给,
|
||||
dir 名含落盘时分秒、前端/server 拼不出,必须落库)
|
||||
|
||||
注:本迁移最初基于旧 main(#31,双 head)写、down_revision 曾指向那对父;但 #33 已用
|
||||
a8c47fc4dc39 合并了那对双 head,故改为线性接在 a8c47fc4dc39 之后、只负责加两列
|
||||
(避免与 a8c47fc4dc39 平行再造一个双 head)。
|
||||
|
||||
Revision ID: f8d3b1e60a27
|
||||
Revises: a8c47fc4dc39
|
||||
Create Date: 2026-06-10 02:40:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f8d3b1e60a27"
|
||||
down_revision: Union[str, Sequence[str], None] = "a8c47fc4dc39"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(避免字符串 "false" 在 SQLite 上存歪)
|
||||
op.add_column(
|
||||
"user",
|
||||
sa.Column(
|
||||
"debug_trace_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"comparison_record",
|
||||
sa.Column("trace_url", sa.String(length=512), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("comparison_record", "trace_url")
|
||||
op.drop_column("user", "debug_trace_enabled")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""user.invite_code + invite_relation table
|
||||
|
||||
Revision ID: invite_code_and_relation
|
||||
Revises: 11a1d08c6f55
|
||||
Create Date: 2026-06-08 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'invite_code_and_relation'
|
||||
down_revision: Union[str, Sequence[str], None] = '11a1d08c6f55'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# user 加邀请码列(唯一索引;nullable 允许多个 NULL=未生成的用户)
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('invite_code', sa.String(length=16), nullable=True))
|
||||
batch_op.create_index('ix_user_invite_code', ['invite_code'], unique=True)
|
||||
|
||||
# 邀请关系表
|
||||
op.create_table(
|
||||
'invite_relation',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('inviter_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('invitee_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('channel', sa.String(length=16), nullable=False, server_default='clipboard'),
|
||||
sa.Column('status', sa.String(length=16), nullable=False, server_default='effective'),
|
||||
sa.Column('inviter_coin', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('invitee_coin', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['inviter_user_id'], ['user.id']),
|
||||
sa.ForeignKeyConstraint(['invitee_user_id'], ['user.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_invite_relation_inviter_user_id', 'invite_relation', ['inviter_user_id'])
|
||||
op.create_index('ix_invite_relation_invitee_user_id', 'invite_relation', ['invitee_user_id'], unique=True)
|
||||
op.create_index('ix_invite_relation_created_at', 'invite_relation', ['created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_invite_relation_created_at', table_name='invite_relation')
|
||||
op.drop_index('ix_invite_relation_invitee_user_id', table_name='invite_relation')
|
||||
op.drop_index('ix_invite_relation_inviter_user_id', table_name='invite_relation')
|
||||
op.drop_table('invite_relation')
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.drop_index('ix_user_invite_code')
|
||||
batch_op.drop_column('invite_code')
|
||||
@@ -0,0 +1,50 @@
|
||||
"""invite_fingerprint table (任务 3 指纹归因兜底)
|
||||
|
||||
Revision ID: invite_fingerprint_table
|
||||
Revises: 0cf18d590b1d
|
||||
Create Date: 2026-06-08 14:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'invite_fingerprint_table'
|
||||
down_revision: Union[str, Sequence[str], None] = '0cf18d590b1d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 邀请指纹归因表:落地页访问时记一行,登录后剪贴板没拿到邀请码时反查
|
||||
op.create_table(
|
||||
'invite_fingerprint',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('inviter_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('ip', sa.String(length=64), nullable=False),
|
||||
sa.Column('device_model', sa.String(length=64), nullable=False, server_default=''),
|
||||
sa.Column('screen', sa.String(length=32), nullable=False, server_default=''),
|
||||
sa.Column('user_agent', sa.Text(), nullable=False, server_default=''),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['inviter_user_id'], ['user.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
# 反查主索引:(ip, screen, device_model, created_at)
|
||||
# /bind 兜底分支 WHERE ip=? AND screen=? AND device_model=? AND created_at > now - 7d
|
||||
# ORDER BY created_at DESC LIMIT 1 — SQLite/PG 优化器都能反向扫该索引
|
||||
op.create_index(
|
||||
'ix_invite_fp_match',
|
||||
'invite_fingerprint',
|
||||
['ip', 'screen', 'device_model', 'created_at'],
|
||||
)
|
||||
# 兜底/统计索引:按 inviter 看"这个邀请人的落地页被谁访问过"
|
||||
op.create_index('ix_invite_fp_inviter', 'invite_fingerprint', ['inviter_user_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_invite_fp_inviter', table_name='invite_fingerprint')
|
||||
op.drop_index('ix_invite_fp_match', table_name='invite_fingerprint')
|
||||
op.drop_table('invite_fingerprint')
|
||||
@@ -0,0 +1,53 @@
|
||||
"""launch_confirm_sample table (启动确认窗 agent 兜底样本: LLM 放行的弹窗样本沉淀)
|
||||
|
||||
Revision ID: launch_confirm_sample_table
|
||||
Revises: cps_wx_user
|
||||
Create Date: 2026-06-20 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "launch_confirm_sample_table"
|
||||
down_revision: Union[str, Sequence[str], None] = "cps_wx_user"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"launch_confirm_sample",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("device_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("host_package", sa.String(length=128), nullable=True),
|
||||
sa.Column("target_app", sa.String(length=128), nullable=True),
|
||||
sa.Column("system_locale", sa.String(length=32), nullable=True),
|
||||
sa.Column("exec_success", sa.Boolean(), nullable=False),
|
||||
sa.Column("dialog_title", sa.String(length=256), nullable=True),
|
||||
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
|
||||
sa.Column("payload", sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), "postgresql"), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_created_at"), ["created_at"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_trace_id"), ["trace_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_device_id"), ["device_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_host_package"), ["host_package"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_system_locale"), ["system_locale"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_system_locale"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_host_package"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_device_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_trace_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_created_at"))
|
||||
|
||||
op.drop_table("launch_confirm_sample")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""marquee_seed 升级为「生成规则」:用户名可空 + 金额拆 min/max 区间
|
||||
|
||||
把种子从「死记录」升级:
|
||||
- masked_user 改 nullable(留空 → feed 随机合成脱敏用户名)
|
||||
- saved_amount_cents(单值)拆成 min_cents / max_cents(feed 在区间内随机取值;固定金额则相等)
|
||||
现有 6 条种子:min_cents = max_cents = 原 saved_amount_cents,masked_user 原样保留。
|
||||
|
||||
Revision ID: marquee_seed02
|
||||
Revises: marquee_seed01
|
||||
Create Date: 2026-06-07 11:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'marquee_seed02'
|
||||
down_revision: Union[str, Sequence[str], None] = 'marquee_seed01'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) 先加可空新列,使现有行合法
|
||||
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('min_cents', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('max_cents', sa.Integer(), nullable=True))
|
||||
# 2) 用旧单值回填区间(固定金额)
|
||||
op.execute('UPDATE marquee_seed SET min_cents = saved_amount_cents, max_cents = saved_amount_cents')
|
||||
# 3) 收紧:区间非空、用户名可空、删旧列
|
||||
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
|
||||
batch_op.alter_column('min_cents', existing_type=sa.Integer(), nullable=False)
|
||||
batch_op.alter_column('max_cents', existing_type=sa.Integer(), nullable=False)
|
||||
batch_op.alter_column('masked_user', existing_type=sa.String(length=64), nullable=True)
|
||||
batch_op.drop_column('saved_amount_cents')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 反向:恢复单值列(取下限),用户名回非空
|
||||
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('saved_amount_cents', sa.Integer(), nullable=True))
|
||||
op.execute('UPDATE marquee_seed SET saved_amount_cents = min_cents')
|
||||
# 自动合成名的种子 masked_user 为 NULL,降级前补占位符以满足非空
|
||||
op.execute("UPDATE marquee_seed SET masked_user = '用户********000' WHERE masked_user IS NULL")
|
||||
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
|
||||
batch_op.alter_column('saved_amount_cents', existing_type=sa.Integer(), nullable=False)
|
||||
batch_op.alter_column('masked_user', existing_type=sa.String(length=64), nullable=False)
|
||||
batch_op.drop_column('max_cents')
|
||||
batch_op.drop_column('min_cents')
|
||||
@@ -0,0 +1,52 @@
|
||||
"""marquee_seed table (首页轮播种子) + 合并 platform_stat / price_observation 两 head
|
||||
|
||||
Revision ID: marquee_seed01
|
||||
Revises: pstat2anchor01, price_observation_table
|
||||
Create Date: 2026-06-07 10:00:00.000000
|
||||
|
||||
兼并当前两个 head 为单 head,同时建 marquee_seed 表并播种客户端原写死的 6 条轮播
|
||||
(作为初始种子,保证上线即有内容)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'marquee_seed01'
|
||||
down_revision: Union[str, Sequence[str], None] = ('pstat2anchor01', 'price_observation_table')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
table = op.create_table(
|
||||
'marquee_seed',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('masked_user', sa.String(length=64), nullable=False),
|
||||
sa.Column('saved_amount_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
# 初始种子 = 客户端原写死的 6 条(金额转成分)
|
||||
op.bulk_insert(
|
||||
table,
|
||||
[
|
||||
{'masked_user': '用户********a52', 'saved_amount_cents': 1860, 'enabled': True, 'sort_order': 1},
|
||||
{'masked_user': '用户********k89', 'saved_amount_cents': 4240, 'enabled': True, 'sort_order': 2},
|
||||
{'masked_user': '用户********m71', 'saved_amount_cents': 790, 'enabled': True, 'sort_order': 3},
|
||||
{'masked_user': '用户********p46', 'saved_amount_cents': 15630, 'enabled': True, 'sort_order': 4},
|
||||
{'masked_user': '用户********r93', 'saved_amount_cents': 2350, 'enabled': True, 'sort_order': 5},
|
||||
{'masked_user': '用户********c28', 'saved_amount_cents': 8940, 'enabled': True, 'sort_order': 6},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('marquee_seed')
|
||||
@@ -0,0 +1,62 @@
|
||||
"""meituan_coupon table(美团 CPS 券本地缓存,供销量/佣金排序从库里捞、本地排序)
|
||||
|
||||
Revision ID: meituan_coupon_table
|
||||
Revises: withdraw_review_ad_watch
|
||||
Create Date: 2026-06-06 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'meituan_coupon_table'
|
||||
down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'meituan_coupon',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('source', sa.String(length=16), nullable=False),
|
||||
sa.Column('platform', sa.Integer(), nullable=False),
|
||||
sa.Column('biz_line', sa.Integer(), nullable=True),
|
||||
sa.Column('city_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('product_view_sign', sa.String(length=128), nullable=False),
|
||||
sa.Column('sku_view_id', sa.String(length=128), nullable=True),
|
||||
sa.Column('name', sa.String(length=256), nullable=True),
|
||||
sa.Column('brand_name', sa.String(length=128), nullable=True),
|
||||
sa.Column('sell_price_cents', sa.Integer(), nullable=True),
|
||||
sa.Column('original_price_cents', sa.Integer(), nullable=True),
|
||||
sa.Column('head_url', sa.String(length=512), nullable=True),
|
||||
sa.Column('sale_volume', sa.String(length=32), nullable=True),
|
||||
sa.Column('sale_volume_num', sa.Integer(), nullable=True),
|
||||
sa.Column('commission_percent', sa.Float(), nullable=True),
|
||||
sa.Column('commission_amount_cents', sa.Integer(), nullable=True),
|
||||
sa.Column('poi_name', sa.String(length=128), nullable=True),
|
||||
sa.Column('available_poi_num', sa.Integer(), nullable=True),
|
||||
sa.Column('delivery_distance_m', sa.Float(), nullable=True),
|
||||
sa.Column('dedup_key', sa.String(length=64), nullable=False),
|
||||
sa.Column('raw', sa.JSON().with_variant(postgresql.JSONB(), 'postgresql'), nullable=False),
|
||||
sa.Column('first_seen', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('last_seen', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('source', 'product_view_sign', name='uq_meituan_coupon_source_sign'),
|
||||
)
|
||||
op.create_index('ix_meituan_coupon_source', 'meituan_coupon', ['source'])
|
||||
op.create_index('ix_meituan_coupon_city_id', 'meituan_coupon', ['city_id'])
|
||||
op.create_index('ix_meituan_coupon_brand_name', 'meituan_coupon', ['brand_name'])
|
||||
op.create_index('ix_meituan_coupon_sale_volume_num', 'meituan_coupon', ['sale_volume_num'])
|
||||
op.create_index('ix_meituan_coupon_commission_percent', 'meituan_coupon', ['commission_percent'])
|
||||
op.create_index('ix_meituan_coupon_dedup_key', 'meituan_coupon', ['dedup_key'])
|
||||
op.create_index('ix_meituan_coupon_last_seen', 'meituan_coupon', ['last_seen'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('meituan_coupon')
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge ad feed reward session and withdraw safety heads
|
||||
|
||||
Revision ID: 044dce6e9b1f
|
||||
Revises: ad_feed_reward_session, withdraw_safety_indexes
|
||||
Create Date: 2026-06-09 09:52:08.276767
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '044dce6e9b1f'
|
||||
down_revision: Union[str, Sequence[str], None] = ('ad_feed_reward_session', 'withdraw_safety_indexes')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,39 @@
|
||||
"""onboarding_completion table (新手引导完成标记,按 user+device 去重)
|
||||
|
||||
Revision ID: onboarding_completion
|
||||
Revises: coupon_daily_completion
|
||||
Create Date: 2026-06-10 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'onboarding_completion'
|
||||
down_revision: Union[str, Sequence[str], None] = 'coupon_daily_completion'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# (账号, 设备) 一条完成标记;device_id = 客户端硬件级 ANDROID_ID(跨卸载重装稳定)。
|
||||
op.create_table(
|
||||
'onboarding_completion',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('device_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'device_id', name='uq_onboarding_user_device'),
|
||||
)
|
||||
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_onboarding_completion_user_id'), ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_onboarding_completion_user_id'))
|
||||
op.drop_table('onboarding_completion')
|
||||
@@ -0,0 +1,28 @@
|
||||
"""运营可配表加 ops_ 前缀:platform_stat_display→ops_stat_config / marquee_seed→ops_marquee_seed
|
||||
|
||||
把首页门面数据的两张「运营可配」表重命名,与代码侧 ops_ 前缀(模型 OpsStatConfig/OpsMarqueeSeed、
|
||||
repo ops_stat/ops_marquee、admin ops_stat_config/ops_marquee_seed)对齐。仅改表名,不动列与数据。
|
||||
|
||||
Revision ID: opsrename01
|
||||
Revises: coin_reward_phase1
|
||||
Create Date: 2026-06-07 16:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = 'opsrename01'
|
||||
down_revision: Union[str, Sequence[str], None] = 'coin_reward_phase1'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.rename_table('platform_stat_display', 'ops_stat_config')
|
||||
op.rename_table('marquee_seed', 'ops_marquee_seed')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.rename_table('ops_stat_config', 'platform_stat_display')
|
||||
op.rename_table('ops_marquee_seed', 'marquee_seed')
|
||||
@@ -0,0 +1,30 @@
|
||||
"""platform_stat_display 增加 random_anchor_minutes(自增长触发时刻对齐偏移)
|
||||
|
||||
Revision ID: pstat2anchor01
|
||||
Revises: pstat1d2e3f4a
|
||||
Create Date: 2026-06-06 13:00:00.000000
|
||||
|
||||
自增长 tick 从「启动时刻 + 间隔」改为「北京时间钟点对齐(anchor + k*interval)」,
|
||||
新增列存对齐偏移(距 0 点分钟数)。已有行默认 0(= 对齐到自然边界:天→0点、小时→整点)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'pstat2anchor01'
|
||||
down_revision: Union[str, Sequence[str], None] = 'pstat1d2e3f4a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'platform_stat_display',
|
||||
sa.Column('random_anchor_minutes', sa.Integer(), nullable=False, server_default='0'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('platform_stat_display', 'random_anchor_minutes')
|
||||
@@ -0,0 +1,59 @@
|
||||
"""platform_stat_display table (首页三统计展示配置:每指标独立 real/manual/random)
|
||||
|
||||
Revision ID: pstat1d2e3f4a
|
||||
Revises: withdraw_review_ad_watch
|
||||
Create Date: 2026-06-06 12:00:00.000000
|
||||
|
||||
播种 3 行:默认 manual + 客户端原写死门面值(12847 人 / 86532 次 / 37621.4 元),
|
||||
保证上线前后展示一致,运营再按需切模式。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'pstat1d2e3f4a'
|
||||
down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
table = op.create_table(
|
||||
'platform_stat_display',
|
||||
sa.Column('metric', sa.String(length=32), nullable=False),
|
||||
sa.Column('mode', sa.String(length=16), nullable=False),
|
||||
sa.Column('manual_value', sa.Integer(), nullable=True),
|
||||
sa.Column('random_mult_min', sa.Integer(), nullable=False),
|
||||
sa.Column('random_mult_max', sa.Integer(), nullable=False),
|
||||
sa.Column('random_tick_seconds', sa.Integer(), nullable=False),
|
||||
sa.Column('random_current', sa.Integer(), nullable=True),
|
||||
sa.Column('random_last_tick_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('updated_by_admin_id', sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
'updated_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint('metric'),
|
||||
)
|
||||
|
||||
_common = {
|
||||
'mode': 'manual',
|
||||
'random_mult_min': 1000, # 1.000(只增不减下限)
|
||||
'random_mult_max': 1100, # 1.100
|
||||
'random_tick_seconds': 86400, # 1 天
|
||||
}
|
||||
op.bulk_insert(
|
||||
table,
|
||||
[
|
||||
{'metric': 'help_users', 'manual_value': 12847, **_common},
|
||||
{'metric': 'total_compares', 'manual_value': 86532, **_common},
|
||||
{'metric': 'total_saved', 'manual_value': 3762140, **_common}, # 37621.40 元
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('platform_stat_display')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""platform_stat_display 增强:绝对增量增长 / 真实值偏移 / 只增不减护栏
|
||||
|
||||
给首页三统计配置加 5 列:
|
||||
- random_kind ('mult'/'add') + random_step_min/max:自增长支持「绝对增量」(每周期 +[a,b]),不只是 ×倍率
|
||||
- real_offset:真实值模式基数偏移(展示=真实+偏移,冷启动既真实又体面)
|
||||
- allow_decrease:门面数字默认「只增不减」,开此开关才允许展示值下降
|
||||
|
||||
Revision ID: pstat3growth
|
||||
Revises: marquee_seed02
|
||||
Create Date: 2026-06-07 12:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'pstat3growth'
|
||||
down_revision: Union[str, Sequence[str], None] = 'marquee_seed02'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('platform_stat_display', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('random_kind', sa.String(length=8), nullable=False, server_default='mult'))
|
||||
batch_op.add_column(sa.Column('random_step_min', sa.Integer(), nullable=False, server_default='0'))
|
||||
batch_op.add_column(sa.Column('random_step_max', sa.Integer(), nullable=False, server_default='0'))
|
||||
batch_op.add_column(sa.Column('real_offset', sa.Integer(), nullable=False, server_default='0'))
|
||||
batch_op.add_column(sa.Column('allow_decrease', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('platform_stat_display', schema=None) as batch_op:
|
||||
batch_op.drop_column('allow_decrease')
|
||||
batch_op.drop_column('real_offset')
|
||||
batch_op.drop_column('random_step_max')
|
||||
batch_op.drop_column('random_step_min')
|
||||
batch_op.drop_column('random_kind')
|
||||
@@ -0,0 +1,74 @@
|
||||
"""price_observation table (价格观测:比价沉淀的价格资产层,server 侧无条件落库)
|
||||
|
||||
Revision ID: price_observation_table
|
||||
Revises: wx_transfer_auth
|
||||
Create Date: 2026-06-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'price_observation_table'
|
||||
down_revision: Union[str, Sequence[str], None] = 'wx_transfer_auth'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'price_observation',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('observed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('trace_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('business_type', sa.String(length=16), nullable=False),
|
||||
sa.Column('platform', sa.String(length=32), nullable=False),
|
||||
sa.Column('platform_store_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('store_name', sa.String(length=128), nullable=True),
|
||||
sa.Column('city', sa.String(length=64), nullable=True),
|
||||
sa.Column('geohash', sa.String(length=16), nullable=True),
|
||||
sa.Column('lng', sa.Float(), nullable=True),
|
||||
sa.Column('lat', sa.Float(), nullable=True),
|
||||
sa.Column('is_source', sa.Boolean(), nullable=False),
|
||||
sa.Column('scope', sa.String(length=16), nullable=False),
|
||||
sa.Column('price_cents', sa.Integer(), nullable=True),
|
||||
sa.Column('coupon_saved_cents', sa.Integer(), nullable=True),
|
||||
sa.Column('coupon_name', sa.String(length=64), nullable=True),
|
||||
sa.Column('store_closed', sa.String(length=32), nullable=True),
|
||||
sa.Column('rank', sa.Integer(), nullable=True),
|
||||
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
|
||||
sa.Column('dishes', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
|
||||
sa.Column('attrs', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
|
||||
sa.Column('source_device_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('source_user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('confidence', sa.Float(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('trace_id', 'platform', 'scope', name='uq_price_obs_trace_platform_scope'),
|
||||
)
|
||||
with op.batch_alter_table('price_observation', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_price_observation_observed_at'), ['observed_at'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_price_observation_trace_id'), ['trace_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_price_observation_business_type'), ['business_type'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_price_observation_platform'), ['platform'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_price_observation_store_name'), ['store_name'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_price_observation_geohash'), ['geohash'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_price_observation_source_device_id'), ['source_device_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_price_observation_source_user_id'), ['source_user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('price_observation', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_price_observation_source_user_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_price_observation_source_device_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_price_observation_geohash'))
|
||||
batch_op.drop_index(batch_op.f('ix_price_observation_store_name'))
|
||||
batch_op.drop_index(batch_op.f('ix_price_observation_platform'))
|
||||
batch_op.drop_index(batch_op.f('ix_price_observation_business_type'))
|
||||
batch_op.drop_index(batch_op.f('ix_price_observation_trace_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_price_observation_observed_at'))
|
||||
|
||||
op.drop_table('price_observation')
|
||||
@@ -0,0 +1,40 @@
|
||||
"""store_mapping 加京东原料列(jd_vender_id + 分享链/反查 URL/deeplink)
|
||||
|
||||
Revision ID: store_mapping_jd_cols
|
||||
Revises: store_mapping_meituan_cols
|
||||
Create Date: 2026-06-13 00:00:00.000000
|
||||
|
||||
京东秒送接入店内搜索 deeplink 链路: 3.cn 短链 → 跟随重定向反查 venderId+storeId →
|
||||
openapp.jdmobile:// deeplink。京东店铺身份是**两个**稳定数字 id: storeId 进 id_jd(稳定
|
||||
店主键, 同 taobao 的 shopId→id_taobao), venderId 进单列 jd_vender_id(deeplink 还需它)。
|
||||
其余三列与 taobao_*/meituan_* 原料列平行。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'store_mapping_jd_cols'
|
||||
down_revision: Union[str, Sequence[str], None] = 'store_mapping_meituan_cols'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('jd_vender_id', sa.String(length=64), nullable=True))
|
||||
batch_op.add_column(sa.Column('jd_share_url', sa.String(length=256), nullable=True))
|
||||
batch_op.add_column(sa.Column('jd_resolved_url', sa.Text(), nullable=True))
|
||||
batch_op.add_column(sa.Column('jd_deeplink', sa.Text(), nullable=True))
|
||||
batch_op.create_index(batch_op.f('ix_store_mapping_jd_vender_id'), ['jd_vender_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_store_mapping_jd_vender_id'))
|
||||
batch_op.drop_column('jd_deeplink')
|
||||
batch_op.drop_column('jd_resolved_url')
|
||||
batch_op.drop_column('jd_share_url')
|
||||
batch_op.drop_column('jd_vender_id')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""store_mapping 加京东 deeplink 失效标记列 jd_deeplink_invalid_at
|
||||
|
||||
Revision ID: store_mapping_jd_dl_invalid
|
||||
Revises: store_mapping_tb_dl_invalid
|
||||
Create Date: 2026-06-15 00:00:00.000000
|
||||
|
||||
京东同淘宝: 缓存的店内搜索 deeplink 会失效(打开是"当前门店超出配送范围"页)。pricebot 比价撞到
|
||||
失效页时回退正常搜店, 并 server→server 通知把该 storeId 的 deeplink 标记失效。本列记失效时刻
|
||||
(NULL=有效); lookup 反查时过滤掉已失效的京东候选, 不再返回坏 deeplink。
|
||||
与淘宝 taobao_deeplink_invalid_at 对称; 用时间戳而非布尔: 留痕可审计、可统计失效率, 不销毁原 deeplink。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'store_mapping_jd_dl_invalid'
|
||||
down_revision: Union[str, Sequence[str], None] = 'store_mapping_tb_dl_invalid'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('jd_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.drop_column('jd_deeplink_invalid_at')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""store_mapping 加美团原料列(poi_id_str + 分享链/反查 URL/deeplink)
|
||||
|
||||
Revision ID: store_mapping_meituan_cols
|
||||
Revises: store_mapping_table
|
||||
Create Date: 2026-06-13 00:00:00.000000
|
||||
|
||||
美团接入店内搜索 deeplink 链路: dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink。
|
||||
poi_id_str 每次分享重新加密、非稳定主键, 单列 meituan_poi_id_str 存(不占 id_meituan,
|
||||
后者留给将来 CPS API 的稳定数字 poi_id)。其余三列与 taobao_* 原料列平行。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'store_mapping_meituan_cols'
|
||||
down_revision: Union[str, Sequence[str], None] = 'store_mapping_table'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('meituan_poi_id_str', sa.String(length=64), nullable=True))
|
||||
batch_op.add_column(sa.Column('meituan_share_url', sa.String(length=256), nullable=True))
|
||||
batch_op.add_column(sa.Column('meituan_resolved_url', sa.Text(), nullable=True))
|
||||
batch_op.add_column(sa.Column('meituan_deeplink', sa.Text(), nullable=True))
|
||||
batch_op.create_index(batch_op.f('ix_store_mapping_meituan_poi_id_str'), ['meituan_poi_id_str'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_store_mapping_meituan_poi_id_str'))
|
||||
batch_op.drop_column('meituan_deeplink')
|
||||
batch_op.drop_column('meituan_resolved_url')
|
||||
batch_op.drop_column('meituan_share_url')
|
||||
batch_op.drop_column('meituan_poi_id_str')
|
||||
@@ -0,0 +1,77 @@
|
||||
"""store_mapping table (平台店铺表:跨平台同店 id/名 映射,server 侧无条件落库)
|
||||
|
||||
Revision ID: store_mapping_table
|
||||
Revises: coin_txn_task_ref_uq
|
||||
Create Date: 2026-06-13 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'store_mapping_table'
|
||||
down_revision: Union[str, Sequence[str], None] = 'coin_txn_task_ref_uq'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'store_mapping',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
# 跨平台身份
|
||||
sa.Column('id_taobao', sa.String(length=64), nullable=True),
|
||||
sa.Column('name_taobao', sa.String(length=128), nullable=True),
|
||||
sa.Column('id_meituan', sa.String(length=64), nullable=True),
|
||||
sa.Column('name_meituan', sa.String(length=128), nullable=True),
|
||||
sa.Column('id_jd', sa.String(length=64), nullable=True),
|
||||
sa.Column('name_jd', sa.String(length=128), nullable=True),
|
||||
# 地理
|
||||
sa.Column('city', sa.String(length=64), nullable=True),
|
||||
sa.Column('geohash', sa.String(length=16), nullable=True),
|
||||
sa.Column('lng', sa.Float(), nullable=True),
|
||||
sa.Column('lat', sa.Float(), nullable=True),
|
||||
sa.Column('taobao_address', sa.String(length=256), nullable=True),
|
||||
# 溯源
|
||||
sa.Column('source_platform', sa.String(length=32), nullable=True),
|
||||
sa.Column('business_type', sa.String(length=16), nullable=False),
|
||||
sa.Column('trace_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('source_device_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('source_user_id', sa.Integer(), nullable=True),
|
||||
# 淘宝原料(URL 可能很长 → Text)
|
||||
sa.Column('taobao_share_url', sa.String(length=256), nullable=True),
|
||||
sa.Column('taobao_resolved_url', sa.Text(), nullable=True),
|
||||
sa.Column('taobao_deeplink', sa.Text(), nullable=True),
|
||||
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
|
||||
sa.Column('attrs', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
# 一次比价一行,trace_id 幂等去重(防 pricebot 重试 / replay 重复写)
|
||||
sa.UniqueConstraint('trace_id', name='uq_store_mapping_trace'),
|
||||
)
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_store_mapping_id_taobao'), ['id_taobao'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_store_mapping_id_meituan'), ['id_meituan'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_store_mapping_id_jd'), ['id_jd'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_store_mapping_geohash'), ['geohash'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_store_mapping_source_platform'), ['source_platform'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_store_mapping_source_device_id'), ['source_device_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_store_mapping_source_user_id'), ['source_user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_store_mapping_created_at'), ['created_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_store_mapping_created_at'))
|
||||
batch_op.drop_index(batch_op.f('ix_store_mapping_source_user_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_store_mapping_source_device_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_store_mapping_source_platform'))
|
||||
batch_op.drop_index(batch_op.f('ix_store_mapping_geohash'))
|
||||
batch_op.drop_index(batch_op.f('ix_store_mapping_id_jd'))
|
||||
batch_op.drop_index(batch_op.f('ix_store_mapping_id_meituan'))
|
||||
batch_op.drop_index(batch_op.f('ix_store_mapping_id_taobao'))
|
||||
|
||||
op.drop_table('store_mapping')
|
||||
@@ -0,0 +1,33 @@
|
||||
"""store_mapping 加淘宝 deeplink 失效标记列 taobao_deeplink_invalid_at
|
||||
|
||||
Revision ID: store_mapping_tb_dl_invalid
|
||||
Revises: coupon_engage_per_package
|
||||
Create Date: 2026-06-15 00:00:00.000000
|
||||
|
||||
缓存的淘宝店内搜索 deeplink 会失效(打开是"页面出错了"降级页)。pricebot 比价撞到错误页时
|
||||
回退正常搜店, 并 server→server 通知把该 shopId 的 deeplink 标记失效。本列记失效时刻
|
||||
(NULL=有效); lookup 反查时过滤掉已失效的淘宝候选, 不再返回坏 deeplink。
|
||||
只加淘宝一列(当前只接淘宝); 用时间戳而非布尔: 留痕可审计、可统计失效率, 且不销毁原 deeplink。
|
||||
无需索引: 过滤总叠在 name_taobao== 之后, 候选集已小。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'store_mapping_tb_dl_invalid'
|
||||
down_revision: Union[str, Sequence[str], None] = 'coupon_engage_per_package'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('taobao_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.drop_column('taobao_deeplink_invalid_at')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""add user.force_onboarding
|
||||
|
||||
运营后台「一键开启新手引导」:置 true → 用户下次启动 App 被强制重走引导教程(即便本地已完成),
|
||||
走完引导(/api/v1/user/onboarding/complete)后端自动清回 false,不无限循环。
|
||||
见 app/models/user.py force_onboarding 列、admin POST /admin/api/users/{id}/force-onboarding。
|
||||
|
||||
Revision ID: user_force_onboarding
|
||||
Revises: onboarding_completion
|
||||
Create Date: 2026-06-10 21:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "user_force_onboarding"
|
||||
down_revision: Union[str, Sequence[str], None] = "onboarding_completion"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(同 debug_trace_enabled)。
|
||||
op.add_column(
|
||||
"user",
|
||||
sa.Column(
|
||||
"force_onboarding",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user", "force_onboarding")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""wechat_transfer_authorization 表(免确认收款授权)
|
||||
|
||||
商家转账「用户授权免确认收款模式」:用户授权一次后,后续提现免逐笔确认直接到账。
|
||||
一个用户一条(user_id 主键)。out_authorization_no 我方生成,authorization_id 微信 active 后返回。
|
||||
|
||||
Revision ID: wx_transfer_auth
|
||||
Revises: withdraw_review_ad_watch
|
||||
Create Date: 2026-06-06 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'wx_transfer_auth'
|
||||
down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'wechat_transfer_authorization',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('openid', sa.String(length=64), nullable=False),
|
||||
sa.Column('out_authorization_no', sa.String(length=64), nullable=False),
|
||||
sa.Column('authorization_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('state', sa.String(length=16), server_default='pending', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('user_id'),
|
||||
)
|
||||
with op.batch_alter_table('wechat_transfer_authorization', schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f('ix_wechat_transfer_authorization_out_authorization_no'),
|
||||
['out_authorization_no'],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('wechat_transfer_authorization', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_wechat_transfer_authorization_out_authorization_no'))
|
||||
op.drop_table('wechat_transfer_authorization')
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add withdraw concurrency safety indexes
|
||||
|
||||
Revision ID: withdraw_safety_indexes
|
||||
Revises: 0cf18d590b1d
|
||||
Create Date: 2026-06-08 16:20:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "withdraw_safety_indexes"
|
||||
down_revision: Union[str, Sequence[str], None] = "0cf18d590b1d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"ux_withdraw_order_user_active",
|
||||
"withdraw_order",
|
||||
["user_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("status IN ('reviewing', 'pending')"),
|
||||
postgresql_where=sa.text("status IN ('reviewing', 'pending')"),
|
||||
)
|
||||
op.create_index(
|
||||
"ux_cash_transaction_withdraw_refund_ref",
|
||||
"cash_transaction",
|
||||
["ref_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("biz_type = 'withdraw_refund' AND ref_id IS NOT NULL"),
|
||||
postgresql_where=sa.text("biz_type = 'withdraw_refund' AND ref_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ux_cash_transaction_withdraw_refund_ref", table_name="cash_transaction")
|
||||
op.drop_index("ux_withdraw_order_user_active", table_name="withdraw_order")
|
||||
@@ -13,12 +13,21 @@ from contextlib import asynccontextmanager
|
||||
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.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
|
||||
from app.admin.routers.users import router as users_router
|
||||
from app.admin.routers.wallet import router as wallet_router
|
||||
from app.admin.routers.withdraw import router as withdraw_router
|
||||
@@ -73,10 +82,19 @@ def health() -> dict[str, str]:
|
||||
|
||||
admin_app.include_router(auth_router)
|
||||
admin_app.include_router(dashboard_router)
|
||||
admin_app.include_router(ops_stat_config_router)
|
||||
admin_app.include_router(ops_marquee_seed_router)
|
||||
admin_app.include_router(users_router)
|
||||
admin_app.include_router(onboarding_router)
|
||||
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(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)
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""看广告金币审计:复算 expected_coin 并与实发对比。
|
||||
|
||||
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
|
||||
- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号
|
||||
(与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置,
|
||||
故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。
|
||||
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计**
|
||||
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置,
|
||||
复算需叠加本日之前的累计份数)。
|
||||
|
||||
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, 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.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
|
||||
|
||||
|
||||
def _prior_granted_counts(
|
||||
db: Session, *, date: str, user_id: int | None
|
||||
) -> dict[int, int]:
|
||||
"""各用户在 date **之前**已发奖的 reward_video 累计份数,作为当日复算的 LT 序号起点。
|
||||
LT 因子改账号累计后,当日第 1 份并非全局第 1 份,需叠加历史累计。"""
|
||||
stmt = (
|
||||
select(AdRewardRecord.user_id, func.count())
|
||||
.where(
|
||||
AdRewardRecord.reward_date < date,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
AdRewardRecord.status == "granted",
|
||||
)
|
||||
.group_by(AdRewardRecord.user_id)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
||||
return {uid: n for uid, n in db.execute(stmt).all()}
|
||||
|
||||
|
||||
def _reward_video_rows(
|
||||
db: Session, *, date: str, user_id: int | None
|
||||
) -> list[dict]:
|
||||
"""看视频记录复算。按 (user_id, created_at) 升序还原账号累计第 N 份(含本日之前的累计)。"""
|
||||
stmt = (
|
||||
select(AdRewardRecord)
|
||||
.where(
|
||||
AdRewardRecord.reward_date == date,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
)
|
||||
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at, AdRewardRecord.id)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
||||
|
||||
# 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐
|
||||
granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id)
|
||||
rows: list[dict] = []
|
||||
for rec in db.execute(stmt).scalars():
|
||||
if rec.status == "granted":
|
||||
nth = granted_n.get(rec.user_id, 0) + 1
|
||||
granted_n[rec.user_id] = nth
|
||||
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
|
||||
rows.append({
|
||||
"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,
|
||||
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
||||
"units": 1,
|
||||
"lt_index_start": nth,
|
||||
"lt_index_end": nth,
|
||||
"lt_factor_start": rewards.ad_lt_factor(nth),
|
||||
"lt_factor_end": rewards.ad_lt_factor(nth),
|
||||
"expected_coin": expected,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": expected == rec.coin,
|
||||
})
|
||||
else:
|
||||
# capped / ecpm_missing:不发金币,校验实发确为 0
|
||||
rows.append({
|
||||
"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,
|
||||
"ecpm_factor": None,
|
||||
"units": 1,
|
||||
"lt_index_start": None,
|
||||
"lt_index_end": None,
|
||||
"lt_factor_start": None,
|
||||
"lt_factor_end": None,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": rec.coin == 0,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _feed_prior_granted_units(
|
||||
db: Session, *, date: str, user_id: int | None
|
||||
) -> dict[int, int]:
|
||||
"""各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。"""
|
||||
stmt = (
|
||||
select(
|
||||
AdFeedRewardRecord.user_id,
|
||||
func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0),
|
||||
)
|
||||
.where(
|
||||
AdFeedRewardRecord.reward_date < date,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
)
|
||||
.group_by(AdFeedRewardRecord.user_id)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
||||
return {uid: int(n) for uid, n in db.execute(stmt).all()}
|
||||
|
||||
|
||||
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。"""
|
||||
stmt = (
|
||||
select(AdFeedRewardRecord)
|
||||
.where(AdFeedRewardRecord.reward_date == date)
|
||||
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at, AdFeedRewardRecord.id)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
||||
|
||||
# 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐
|
||||
granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id)
|
||||
rows: list[dict] = []
|
||||
for rec in db.execute(stmt).scalars():
|
||||
if rec.status == "granted":
|
||||
existing = granted_units.get(rec.user_id, 0)
|
||||
units = rec.unit_count
|
||||
expected = sum(
|
||||
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
|
||||
for offset in range(1, units + 1)
|
||||
)
|
||||
granted_units[rec.user_id] = existing + units
|
||||
start = existing + 1 if units > 0 else None
|
||||
end = existing + units if units > 0 else None
|
||||
rows.append({
|
||||
"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,
|
||||
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
||||
"units": units,
|
||||
"lt_index_start": start,
|
||||
"lt_index_end": end,
|
||||
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
|
||||
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
|
||||
"expected_coin": expected,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": expected == rec.coin,
|
||||
})
|
||||
else:
|
||||
rows.append({
|
||||
"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,
|
||||
"ecpm_factor": None,
|
||||
"units": rec.unit_count,
|
||||
"lt_index_start": None,
|
||||
"lt_index_end": None,
|
||||
"lt_factor_start": None,
|
||||
"lt_factor_end": None,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": rec.coin == 0,
|
||||
})
|
||||
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,
|
||||
*,
|
||||
date: str,
|
||||
user_id: int | None,
|
||||
scene: str | None,
|
||||
limit: int,
|
||||
only_mismatch: bool = False,
|
||||
) -> dict:
|
||||
"""当日发奖复算。返回 {total, mismatch_count, truncated, items}。
|
||||
|
||||
scene: None=两类都要 / "reward_video" / "feed";only_mismatch=True 只展示不一致(✗)行。
|
||||
关键:`total` 与 `mismatch_count` 在**全量**(截断前)上统计,故对账数字始终可信,不受 limit
|
||||
影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。
|
||||
份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。
|
||||
"""
|
||||
rows = audit_rows(db, date=date, user_id=user_id, scene=scene)
|
||||
rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True)
|
||||
|
||||
total = len(rows)
|
||||
mismatch_count = sum(1 for r in rows if not r["matched"])
|
||||
display = [r for r in rows if not r["matched"]] if only_mismatch else rows
|
||||
return {
|
||||
"total": total,
|
||||
"mismatch_count": mismatch_count,
|
||||
"truncated": len(display) > limit,
|
||||
"items": display[:limit],
|
||||
}
|
||||
|
||||
|
||||
def formula_snapshot() -> dict:
|
||||
"""当前公式参数快照(给前端展示规则参照)。直接读 rewards 常量,与发奖同源。"""
|
||||
return {
|
||||
"coin_per_yuan": rewards.COIN_PER_YUAN,
|
||||
"feed_unit_seconds": FEED_REWARD_UNIT_SECONDS,
|
||||
"ecpm_factor_tiers": [list(t) for t in rewards.AD_ECPM_FACTOR_TABLE],
|
||||
"lt_factor_tiers": [list(t) for t in rewards.AD_LT_FACTOR_TABLE],
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"""admin 广告收益报表:按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合(单表含发奖对账)。
|
||||
|
||||
只读。聚合键 = user_id × ad_type × app_env × our_code_id;每组一行同时给出:
|
||||
- 展示条数 + 收益:`ad_ecpm_record`(每行 = 客户端一次广告展示;收益 = Σ eCPM元 ÷ 1000)。
|
||||
激励视频每次展示上报一行;信息流轮播每条展示各上报一行(每条独立 id,不复用会话)。
|
||||
- 应发金币 / 实发金币:复用金币审计的**逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,
|
||||
不另写公式),把每条发奖记录的 expected/actual 按同维度求和;`matched` = 组内**逐条**全部一致
|
||||
(任一条不符该组即不符,不用「应发和==实发和」以免互相抵消掩盖错误)。**不改发奖逻辑**,只读复算。
|
||||
|
||||
展示与发奖来自不同表,做并集:有展示无发奖(用户中途关 / 未达发奖)、有发奖无展示
|
||||
(未上报 eCPM)都各自成行。app_env/our_code_id 旧数据为 NULL → 归到「来源未知」组。
|
||||
|
||||
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 `feed`(迁移后 Draw
|
||||
不再产生新数据)。② 聚合级只能看出「某组应发≠实发」,定位到具体哪条仍需逐条审计接口(ad-coin-audit)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories import ad_audit
|
||||
from app.core import rewards
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
"""created_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(rewards.CN_TZ).hour
|
||||
|
||||
|
||||
def _key(
|
||||
report_date: str,
|
||||
user_id: int,
|
||||
ad_type: str,
|
||||
app_env: str | None,
|
||||
our_code_id: str | None,
|
||||
hour: int | None,
|
||||
) -> tuple:
|
||||
return (report_date, user_id, ad_type, app_env or None, our_code_id or None, hour)
|
||||
|
||||
|
||||
def _date_range(date_from: str, date_to: str) -> list[str]:
|
||||
"""闭区间内逐日 'YYYY-MM-DD' 串(含首尾)。date_from > date_to 时返回空。"""
|
||||
d0 = _date.fromisoformat(date_from)
|
||||
d1 = _date.fromisoformat(date_to)
|
||||
out: list[str] = []
|
||||
d = d0
|
||||
while d <= d1:
|
||||
out.append(d.isoformat())
|
||||
d += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
# 审计行的 scene 与报表 ad_type 一一对应
|
||||
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
|
||||
|
||||
|
||||
def ad_revenue_report(
|
||||
db: Session,
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
user_id: int | None = None,
|
||||
ad_type: str | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
) -> dict:
|
||||
"""日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 date_from==date_to。
|
||||
|
||||
聚合键含**日期**:report_date × user × ad_type × app_env × our_code_id(× 北京小时,granularity=hour)。
|
||||
ad_type: None=全部 / reward_video / feed / draw。
|
||||
granularity: "day"=按天 / "hour"=按小时(聚合键再加北京小时 0–23,每组一行)。
|
||||
limit 只截断展示明细,total 与 total_* / daily 在全量上统计(不受 limit 影响),数字始终可信。
|
||||
|
||||
返回额外含 `daily`(按日期汇总的展示/收益/应发/实发,供前端按天趋势图;不受 limit 影响)。
|
||||
|
||||
注:按小时下,展示按 ecpm 记录的小时、金币按发奖记录的小时各自归桶——S2S 回调可能比展示晚
|
||||
一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
groups: dict[tuple, dict] = {}
|
||||
|
||||
def _grp(key: tuple) -> dict:
|
||||
g = groups.get(key)
|
||||
if g is None:
|
||||
rdate, uid, atype, app_env, code_id, hour = key
|
||||
g = {
|
||||
"report_date": rdate,
|
||||
"user_id": uid,
|
||||
"ad_type": atype,
|
||||
"app_env": app_env,
|
||||
"our_code_id": code_id,
|
||||
"hour": hour,
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
"adns": set(),
|
||||
"impression_records": [], # 该组逐条展示明细(展开下钻用)
|
||||
"records": [], # 该组逐条发奖复算明细(展开下钻用)
|
||||
}
|
||||
groups[key] = g
|
||||
return g
|
||||
|
||||
# 1) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序)
|
||||
stmt = select(AdEcpmRecord).where(
|
||||
AdEcpmRecord.report_date >= date_from,
|
||||
AdEcpmRecord.report_date <= date_to,
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
|
||||
if ad_type is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
|
||||
for rec in db.execute(stmt).scalars():
|
||||
hour = _cn_hour(rec.created_at) if by_hour else None
|
||||
g = _grp(_key(rec.report_date, rec.user_id, rec.ad_type, rec.app_env, rec.our_code_id, hour))
|
||||
g["impressions"] += 1
|
||||
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次);用与发奖同源的解析,口径一致。
|
||||
rev = rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0
|
||||
g["revenue_yuan"] += rev
|
||||
if rec.adn:
|
||||
g["adns"].add(rec.adn)
|
||||
g["impression_records"].append({
|
||||
"id": rec.id,
|
||||
"created_at": rec.created_at,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"revenue_yuan": round(rev, 6),
|
||||
"adn": rec.adn,
|
||||
"slot_id": rec.slot_id,
|
||||
})
|
||||
|
||||
# 2) 应发 / 实发金币 ← 复用金币审计逐条复算(同一公式口径),按同维度求和。
|
||||
# audit_rows 是单日的,区间逐日调用,每天的行归到当天 report_date(语义与单日报表完全一致)。
|
||||
# ad_type=draw 时审计无对应记录(scene 只有 reward_video/feed),金币侧自然为空。
|
||||
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
|
||||
if ad_type is None or audit_scene is not None:
|
||||
for d in _date_range(date_from, date_to):
|
||||
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
|
||||
atype = _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"])
|
||||
hour = _cn_hour(row["created_at"]) if by_hour else None
|
||||
g = _grp(_key(d, row["user_id"], atype, row.get("app_env"), row.get("our_code_id"), hour))
|
||||
g["expected_coin"] += int(row["expected_coin"])
|
||||
g["actual_coin"] += int(row["actual_coin"])
|
||||
# 逐条明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致)——前端展开该组时下钻展示。
|
||||
g["records"].append({
|
||||
"record_id": row["record_id"],
|
||||
"created_at": row["created_at"],
|
||||
"status": row["status"],
|
||||
"ecpm": row["ecpm"],
|
||||
"ecpm_factor": row["ecpm_factor"],
|
||||
"units": row["units"],
|
||||
"lt_index_start": row["lt_index_start"],
|
||||
"lt_index_end": row["lt_index_end"],
|
||||
"lt_factor_start": row["lt_factor_start"],
|
||||
"lt_factor_end": row["lt_factor_end"],
|
||||
"expected_coin": row["expected_coin"],
|
||||
"actual_coin": row["actual_coin"],
|
||||
"matched": row["matched"],
|
||||
})
|
||||
|
||||
rows = list(groups.values())
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
r["report_date"],
|
||||
r["user_id"],
|
||||
r["hour"] if r["hour"] is not None else -1,
|
||||
r["ad_type"] or "",
|
||||
r["our_code_id"] or "",
|
||||
)
|
||||
)
|
||||
|
||||
total_impressions = sum(r["impressions"] for r in rows)
|
||||
total_expected_coin = sum(r["expected_coin"] for r in rows)
|
||||
total_actual_coin = sum(r["actual_coin"] for r in rows)
|
||||
total_revenue_yuan = round(sum(r["revenue_yuan"] for r in rows), 6)
|
||||
|
||||
# 按日期汇总(全量,不受 limit):供前端按天趋势图。
|
||||
daily_map: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
d = daily_map.get(r["report_date"])
|
||||
if d is None:
|
||||
d = {
|
||||
"date": r["report_date"],
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
}
|
||||
daily_map[r["report_date"]] = d
|
||||
d["impressions"] += r["impressions"]
|
||||
d["revenue_yuan"] += r["revenue_yuan"]
|
||||
d["expected_coin"] += r["expected_coin"]
|
||||
d["actual_coin"] += r["actual_coin"]
|
||||
daily = [
|
||||
{**d, "revenue_yuan": round(d["revenue_yuan"], 6)}
|
||||
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||
]
|
||||
|
||||
items = [
|
||||
{
|
||||
"report_date": r["report_date"],
|
||||
"user_id": r["user_id"],
|
||||
"ad_type": r["ad_type"],
|
||||
"app_env": r["app_env"],
|
||||
"our_code_id": r["our_code_id"],
|
||||
"hour": r["hour"],
|
||||
"impressions": r["impressions"],
|
||||
"revenue_yuan": round(r["revenue_yuan"], 6),
|
||||
"expected_coin": r["expected_coin"],
|
||||
"actual_coin": r["actual_coin"],
|
||||
# 组内**逐条**全部一致才记一致——不能用「应发和==实发和」,否则一条多发+一条少发会互相
|
||||
# 抵消、求和相等被误判为 ✓,掩盖真实发奖错误。纯展示无发奖记录的组 all([]) → True。
|
||||
"matched": all(rec["matched"] for rec in r["records"]),
|
||||
"adns": sorted(r["adns"]),
|
||||
"impression_records": sorted(
|
||||
r["impression_records"], key=lambda x: (x["created_at"], x["id"])
|
||||
),
|
||||
"records": sorted(r["records"], key=lambda x: (x["created_at"], x["record_id"])),
|
||||
}
|
||||
for r in rows[:limit]
|
||||
]
|
||||
|
||||
return {
|
||||
"total": len(rows),
|
||||
"truncated": len(rows) > limit,
|
||||
"total_impressions": total_impressions,
|
||||
"total_revenue_yuan": total_revenue_yuan,
|
||||
"total_expected_coin": total_expected_coin,
|
||||
"total_actual_coin": total_actual_coin,
|
||||
"mismatch_count": sum(
|
||||
1 for r in rows if not all(rec["matched"] for rec in r["records"])
|
||||
),
|
||||
"daily": daily,
|
||||
"items": items,
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.admin import AdminAuditLog
|
||||
@@ -49,8 +49,9 @@ def list_audit_logs(
|
||||
admin_id: int | None = None,
|
||||
limit: int = 50,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[AdminAuditLog], int | None]:
|
||||
"""游标分页(id 倒序),与现有 list_* 约定一致。返回 (rows, next_cursor)。"""
|
||||
) -> tuple[list[AdminAuditLog], int | None, int]:
|
||||
"""offset 分页(id 倒序)+ total。cursor 即 offset((page-1)*pageSize),支持页码跳页。
|
||||
返回 (rows, next_cursor, total)。"""
|
||||
stmt = select(AdminAuditLog)
|
||||
if action:
|
||||
stmt = stmt.where(AdminAuditLog.action == action)
|
||||
@@ -58,13 +59,15 @@ def list_audit_logs(
|
||||
stmt = stmt.where(AdminAuditLog.target_type == target_type)
|
||||
if admin_id is not None:
|
||||
stmt = stmt.where(AdminAuditLog.admin_id == admin_id)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(AdminAuditLog.id < cursor)
|
||||
stmt = stmt.order_by(AdminAuditLog.id.desc())
|
||||
rows = list(db.execute(stmt.limit(limit + 1)).scalars().all())
|
||||
|
||||
total = int(db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one())
|
||||
offset = max(cursor or 0, 0)
|
||||
rows = list(
|
||||
db.execute(
|
||||
stmt.order_by(AdminAuditLog.id.desc()).offset(offset).limit(limit + 1)
|
||||
).scalars().all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
# next_cursor 必须是"本页返回的最后一条"的 id(下一页查 id < 它),不能用 rows[limit]——
|
||||
# rows[limit] 是探测下一页用的第 limit+1 条,它既不在本页也不在下页 → 每页边界丢一条。
|
||||
next_cursor = items[-1].id if has_more else None
|
||||
return items, next_cursor
|
||||
next_cursor = offset + limit if has_more else None
|
||||
return items, next_cursor, total
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
"""admin CPS 数据访问:群/活动 CRUD + 美团订单拉单入库(对账) + 按群统计聚合。
|
||||
|
||||
美团 query_order 返回的金额是「元」字符串、时间是秒级时间戳,这里统一转「分」+ tz-aware。
|
||||
统计在 Python 侧聚合(订单量级小、admin 低频),逻辑清晰、跨 PG/SQLite 无 SQL 方言坑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.queries import _as_utc, offset_paginate
|
||||
from app.integrations import meituan
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_group import CpsGroup
|
||||
from app.models.cps_link import CpsClick
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
|
||||
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
||||
_INVALID_STATUS = {"4", "5"}
|
||||
_SETTLED_STATUS = "6"
|
||||
|
||||
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
|
||||
_BJ_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
# ───────────── 单位换算 ─────────────
|
||||
def _yuan_to_cents(v: object) -> int | None:
|
||||
"""「元」字符串/数 → 分。None / 空 / 字面 "null" → None。"""
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
if not s or s.lower() == "null":
|
||||
return None
|
||||
try:
|
||||
return int((Decimal(s) * 100).to_integral_value())
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _ts_to_dt(ts: object) -> datetime | None:
|
||||
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
||||
except (ValueError, OSError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ───────────── 群 ─────────────
|
||||
def get_group(db: Session, group_id: int) -> CpsGroup | None:
|
||||
return db.get(CpsGroup, group_id)
|
||||
|
||||
|
||||
def get_group_by_sid(db: Session, sid: str) -> CpsGroup | None:
|
||||
return db.execute(select(CpsGroup).where(CpsGroup.sid == sid)).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_groups(
|
||||
db: Session, *, keyword: str | None = None, status: str | None = None,
|
||||
limit: int = 20, cursor: int | None = None,
|
||||
) -> tuple[list[CpsGroup], int | None, int]:
|
||||
stmt = select(CpsGroup)
|
||||
if keyword and keyword.strip():
|
||||
kw = f"%{keyword.strip()}%"
|
||||
stmt = stmt.where(CpsGroup.name.ilike(kw) | CpsGroup.sid.ilike(kw))
|
||||
if status:
|
||||
stmt = stmt.where(CpsGroup.status == status)
|
||||
return offset_paginate(db, stmt, (desc(CpsGroup.id),), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def create_group(
|
||||
db: Session, *, name: str, platforms: list[str], sid: str | None = None,
|
||||
member_count: int | None = None, remark: str | None = None, commit: bool = True,
|
||||
) -> CpsGroup:
|
||||
"""建群。含 meituan 才有 sid(留空自动 g<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
|
||||
]
|
||||
@@ -8,9 +8,15 @@ set_user_status / update_feedback_status 支持 commit=False,让 router 把"业
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.onboarding import OnboardingCompletion
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@@ -24,6 +30,20 @@ def set_user_status(db: Session, user: User, *, status: str, commit: bool = True
|
||||
return user
|
||||
|
||||
|
||||
def set_user_debug_trace(
|
||||
db: Session, user: User, *, enabled: bool, commit: bool = True
|
||||
) -> User:
|
||||
"""开关用户「复制调试链接」权限。同 set_user_status:支持 commit=False 让 router 把
|
||||
业务写 + 审计写放进同一事务。"""
|
||||
user.debug_trace_enabled = enabled
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
else:
|
||||
db.flush()
|
||||
return user
|
||||
|
||||
|
||||
def update_feedback_status(
|
||||
db: Session, feedback: Feedback, *, status: str, commit: bool = True
|
||||
) -> Feedback:
|
||||
@@ -34,3 +54,54 @@ def update_feedback_status(
|
||||
else:
|
||||
db.flush()
|
||||
return feedback
|
||||
|
||||
|
||||
def review_price_report(
|
||||
db: Session,
|
||||
report: PriceReport,
|
||||
*,
|
||||
status: str,
|
||||
reward_coins: int | None = None,
|
||||
reject_reason: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> PriceReport:
|
||||
"""审核上报:置 approved/rejected + 记审核时间;通过填 reward_coins、拒绝填 reject_reason。
|
||||
|
||||
发金币(wallet.grant_coins)不在这里——由 router 在同一事务里调,涉钱逻辑不重写(见模块头注释)。
|
||||
"""
|
||||
report.status = status
|
||||
if reward_coins is not None:
|
||||
report.reward_coins = reward_coins
|
||||
if reject_reason is not None:
|
||||
report.reject_reason = reject_reason
|
||||
report.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None) # 北京 wall-clock,同 created_at
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(report)
|
||||
else:
|
||||
db.flush()
|
||||
return report
|
||||
|
||||
|
||||
def delete_device_onboarding(db: Session, device_id: str, *, commit: bool = True) -> int:
|
||||
"""删某设备(device_id)的全部 onboarding 完成记录 → 该设备上所有账号下次登录重走引导。
|
||||
返回删除行数。支持 commit=False 让 router 把业务写 + 审计写放进同一事务。"""
|
||||
n = db.execute(
|
||||
delete(OnboardingCompletion).where(OnboardingCompletion.device_id == device_id)
|
||||
).rowcount
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
return n
|
||||
|
||||
|
||||
def delete_all_onboarding(db: Session, *, commit: bool = True) -> int:
|
||||
"""清空 onboarding_completion 表 → 所有用户在所有设备下次登录都重走引导。返回删除行数。
|
||||
支持 commit=False(同 delete_device_onboarding)。"""
|
||||
n = db.execute(delete(OnboardingCompletion)).rowcount
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
return n
|
||||
|
||||
@@ -5,11 +5,17 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import Select, asc, desc, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.admin import AdminAuditLog
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.onboarding import OnboardingCompletion
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
||||
|
||||
@@ -32,15 +38,48 @@ def cursor_paginate(
|
||||
return items, next_cursor
|
||||
|
||||
|
||||
def offset_paginate(
|
||||
db: Session, stmt: Select, sort_clause: tuple, *, limit: int, cursor: int | None
|
||||
) -> tuple[list, int | None, int]:
|
||||
"""offset 分页 + 总数。stmt 只含 where/join,不要预先带 order_by/offset/limit。
|
||||
|
||||
cursor 即 offset(页码分页:offset=(page-1)*pageSize)。返回 (items, next_cursor, total):
|
||||
- total:符合筛选条件的总条数(供 antd pagination 渲染页码/共 N 条),count 在 P0 量级开销可忽略;
|
||||
- next_cursor:下一页 offset(兼容「加载更多」),末页为 None。
|
||||
多取 1 条探测下一页。sort_clause 为 order_by 表达式元组(末位应含 id 保证稳定排序)。"""
|
||||
total = int(
|
||||
db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
|
||||
)
|
||||
offset = max(cursor or 0, 0)
|
||||
rows = list(
|
||||
db.execute(stmt.order_by(*sort_clause).offset(offset).limit(limit + 1)).scalars().all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
next_cursor = offset + limit if has_more else None
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def list_users(
|
||||
db: Session,
|
||||
*,
|
||||
phone: str | None = None,
|
||||
register_channel: str | None = None,
|
||||
status: str | None = None,
|
||||
nickname: str | None = None,
|
||||
created_from: datetime | None = None,
|
||||
created_to: datetime | None = None,
|
||||
last_login_from: datetime | None = None,
|
||||
last_login_to: datetime | None = None,
|
||||
sort_by: str = "id",
|
||||
sort_order: str = "desc",
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[User], int | None]:
|
||||
) -> tuple[list[User], int | None, int]:
|
||||
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
|
||||
按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
|
||||
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
|
||||
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
|
||||
stmt = select(User)
|
||||
if phone:
|
||||
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
|
||||
@@ -48,7 +87,103 @@ def list_users(
|
||||
stmt = stmt.where(User.register_channel == register_channel)
|
||||
if status:
|
||||
stmt = stmt.where(User.status == status)
|
||||
return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor)
|
||||
if nickname and nickname.strip():
|
||||
stmt = stmt.where(User.nickname.ilike(f"%{nickname.strip()}%"))
|
||||
if created_from is not None:
|
||||
stmt = stmt.where(User.created_at >= _as_utc(created_from))
|
||||
if created_to is not None:
|
||||
stmt = stmt.where(User.created_at <= _as_utc(created_to))
|
||||
if last_login_from is not None:
|
||||
stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from))
|
||||
if last_login_to is not None:
|
||||
stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to))
|
||||
|
||||
sort_cols = {
|
||||
"id": User.id,
|
||||
"created_at": User.created_at,
|
||||
"last_login_at": User.last_login_at,
|
||||
}
|
||||
sort_col = sort_cols.get(sort_by, User.id)
|
||||
order_fn = asc if sort_order == "asc" else desc
|
||||
id_order = asc(User.id) if sort_order == "asc" else desc(User.id)
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
|
||||
"""给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。"""
|
||||
uids = {r.user_id for r in records}
|
||||
if not uids:
|
||||
return
|
||||
rows = db.execute(
|
||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
||||
).all()
|
||||
umap = {uid: (phone, nick) for uid, phone, nick in rows}
|
||||
for r in records:
|
||||
phone, nick = umap.get(r.user_id, (None, None))
|
||||
r.phone = phone
|
||||
r.nickname = nick
|
||||
|
||||
|
||||
def list_comparison_records(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
phone: str | None = None,
|
||||
status: str | None = None,
|
||||
business_type: str | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[ComparisonRecord], int | None, int]:
|
||||
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
|
||||
offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。"""
|
||||
stmt = select(ComparisonRecord)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(ComparisonRecord.user_id == user_id)
|
||||
if phone:
|
||||
stmt = stmt.where(
|
||||
ComparisonRecord.user_id.in_(
|
||||
select(User.id).where(User.phone.like(f"{phone}%"))
|
||||
)
|
||||
)
|
||||
if status:
|
||||
stmt = stmt.where(ComparisonRecord.status == status)
|
||||
if business_type:
|
||||
stmt = stmt.where(ComparisonRecord.business_type == business_type)
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt,
|
||||
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
|
||||
limit=limit, cursor=cursor,
|
||||
)
|
||||
_attach_user_info(db, items)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
|
||||
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
|
||||
rec = db.get(ComparisonRecord, record_id)
|
||||
if rec is not None:
|
||||
_attach_user_info(db, [rec])
|
||||
return rec
|
||||
|
||||
|
||||
def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]:
|
||||
"""按设备(device_id, ANDROID_ID)聚合 onboarding_completion:每台设备走过引导的账号数 +
|
||||
最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会
|
||||
引导、无需管理)。当前全量返回(上限 limit;调试期设备少,量大再加分页/搜索)。"""
|
||||
rows = db.execute(
|
||||
select(
|
||||
OnboardingCompletion.device_id,
|
||||
func.count(OnboardingCompletion.id),
|
||||
func.max(OnboardingCompletion.completed_at),
|
||||
)
|
||||
.group_by(OnboardingCompletion.device_id)
|
||||
.order_by(func.max(OnboardingCompletion.completed_at).desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
return [
|
||||
{"device_id": did, "account_count": int(cnt), "last_completed_at": last}
|
||||
for did, cnt, last in rows
|
||||
]
|
||||
|
||||
|
||||
def list_all_coin_transactions(
|
||||
@@ -88,15 +223,112 @@ def list_all_withdraw_orders(
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
status: str | None = None,
|
||||
keyword: str | None = None,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
date_field: str = "created_at",
|
||||
sort_by: str = "created_at",
|
||||
sort_order: str = "desc",
|
||||
quick_filter: str | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[WithdrawOrder], int | None]:
|
||||
) -> tuple[list[WithdrawOrder], int | None, int]:
|
||||
stmt = select(WithdrawOrder)
|
||||
needs_user_join = bool(keyword and keyword.strip()) or quick_filter == "high_risk"
|
||||
if needs_user_join:
|
||||
stmt = stmt.outerjoin(User, User.id == WithdrawOrder.user_id)
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(WithdrawOrder.user_id == user_id)
|
||||
if status:
|
||||
stmt = stmt.where(WithdrawOrder.status == status)
|
||||
return cursor_paginate(db, stmt, WithdrawOrder.id, limit=limit, cursor=cursor)
|
||||
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
pattern = f"%{kw}%"
|
||||
conditions = [
|
||||
WithdrawOrder.out_bill_no.ilike(pattern),
|
||||
WithdrawOrder.transfer_bill_no.ilike(pattern),
|
||||
WithdrawOrder.user_name.ilike(pattern),
|
||||
WithdrawOrder.wechat_state.ilike(pattern),
|
||||
WithdrawOrder.fail_reason.ilike(pattern),
|
||||
User.phone.ilike(pattern),
|
||||
User.nickname.ilike(pattern),
|
||||
User.wechat_nickname.ilike(pattern),
|
||||
]
|
||||
if kw.isdigit():
|
||||
conditions.append(WithdrawOrder.user_id == int(kw))
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
|
||||
date_col = WithdrawOrder.updated_at if date_field == "updated_at" else WithdrawOrder.created_at
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(date_col >= _as_utc(date_from))
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(date_col <= _as_utc(date_to))
|
||||
|
||||
# tz-aware:列为 timestamptz,比较绝对时刻、与 DB 会话时区无关(同 _as_utc / stats.py)
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = (
|
||||
datetime.now(ZoneInfo("Asia/Shanghai"))
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
.astimezone(timezone.utc)
|
||||
)
|
||||
if quick_filter == "abnormal":
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
WithdrawOrder.status.in_(("failed", "rejected")),
|
||||
WithdrawOrder.fail_reason.is_not(None),
|
||||
WithdrawOrder.wechat_state.in_(("FAIL", "CANCELLED", "CLOSED")),
|
||||
)
|
||||
)
|
||||
elif quick_filter == "failed":
|
||||
stmt = stmt.where(WithdrawOrder.status == "failed")
|
||||
elif quick_filter == "overdue_reviewing":
|
||||
stmt = stmt.where(
|
||||
WithdrawOrder.status == "reviewing",
|
||||
WithdrawOrder.created_at <= now - timedelta(minutes=30),
|
||||
)
|
||||
elif quick_filter == "pending_overdue":
|
||||
stmt = stmt.where(
|
||||
WithdrawOrder.status == "pending",
|
||||
WithdrawOrder.updated_at <= now - timedelta(minutes=15),
|
||||
)
|
||||
elif quick_filter == "today":
|
||||
stmt = stmt.where(WithdrawOrder.created_at >= today_start)
|
||||
elif quick_filter == "high_risk":
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
WithdrawOrder.user_name.is_(None),
|
||||
WithdrawOrder.user_name == "",
|
||||
User.status != "active",
|
||||
User.created_at >= now - timedelta(hours=24),
|
||||
WithdrawOrder.status.in_(("failed", "rejected")),
|
||||
WithdrawOrder.fail_reason.is_not(None),
|
||||
)
|
||||
)
|
||||
|
||||
sort_cols = {
|
||||
"id": WithdrawOrder.id,
|
||||
"created_at": WithdrawOrder.created_at,
|
||||
"updated_at": WithdrawOrder.updated_at,
|
||||
"amount_cents": WithdrawOrder.amount_cents,
|
||||
}
|
||||
sort_col = sort_cols.get(sort_by, WithdrawOrder.created_at)
|
||||
order_fn = asc if sort_order == "asc" else desc
|
||||
id_order = asc(WithdrawOrder.id) if sort_order == "asc" else desc(WithdrawOrder.id)
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
"""前端传 ISO 时间 → 统一成 tz-aware UTC 再比较。
|
||||
|
||||
所有时间列均为 `DateTime(timezone=True)`(Postgres timestamptz);用 tz-aware 绑定参数
|
||||
比较的是绝对时刻,与 DB 会话时区无关、恒正确。曾用 naive UTC,正确性依赖会话 TimeZone=UTC,
|
||||
生产会话非 UTC 时筛选边界会整体偏移——故统一 tz-aware(与 stats.py / withdraw_summary 一致)。
|
||||
无时区入参按 UTC 解释。"""
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def list_feedbacks(
|
||||
@@ -104,15 +336,35 @@ def list_feedbacks(
|
||||
*,
|
||||
status: str | None = None,
|
||||
user_id: int | None = None,
|
||||
content: str | None = None,
|
||||
created_from: datetime | None = None,
|
||||
created_to: datetime | None = None,
|
||||
sort_by: str = "id",
|
||||
sort_order: str = "desc",
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[Feedback], int | None]:
|
||||
) -> tuple[list[Feedback], int | None, int]:
|
||||
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
|
||||
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
|
||||
数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。
|
||||
created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
|
||||
stmt = select(Feedback)
|
||||
if status:
|
||||
stmt = stmt.where(Feedback.status == status)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(Feedback.user_id == user_id)
|
||||
return cursor_paginate(db, stmt, Feedback.id, limit=limit, cursor=cursor)
|
||||
if content and content.strip():
|
||||
stmt = stmt.where(Feedback.content.ilike(f"%{content.strip()}%"))
|
||||
if created_from is not None:
|
||||
stmt = stmt.where(Feedback.created_at >= _as_utc(created_from))
|
||||
if created_to is not None:
|
||||
stmt = stmt.where(Feedback.created_at <= _as_utc(created_to))
|
||||
|
||||
sort_cols = {"id": Feedback.id, "created_at": Feedback.created_at}
|
||||
sort_col = sort_cols.get(sort_by, Feedback.id)
|
||||
order_fn = asc if sort_order == "asc" else desc
|
||||
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
|
||||
@@ -122,6 +374,147 @@ def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def withdraw_summary(db: Session) -> dict:
|
||||
"""提现审核台顶部统计。金额单位:分。"""
|
||||
rows = db.execute(
|
||||
select(
|
||||
WithdrawOrder.status,
|
||||
func.count(WithdrawOrder.id),
|
||||
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
|
||||
).group_by(WithdrawOrder.status)
|
||||
).all()
|
||||
by_status = {
|
||||
status: {"count": int(count), "amount_cents": int(amount_cents)}
|
||||
for status, count, amount_cents in rows
|
||||
}
|
||||
|
||||
today_start = (
|
||||
datetime.now(ZoneInfo("Asia/Shanghai"))
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
.astimezone(timezone.utc)
|
||||
)
|
||||
|
||||
def _today_count(status: str) -> int:
|
||||
return db.execute(
|
||||
select(func.count(WithdrawOrder.id)).where(
|
||||
WithdrawOrder.status == status,
|
||||
WithdrawOrder.updated_at >= today_start,
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
today_success_amount = db.execute(
|
||||
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
|
||||
WithdrawOrder.status == "success",
|
||||
WithdrawOrder.updated_at >= today_start,
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
return {
|
||||
"reviewing_count": by_status.get("reviewing", {}).get("count", 0),
|
||||
"reviewing_amount_cents": by_status.get("reviewing", {}).get("amount_cents", 0),
|
||||
"pending_count": by_status.get("pending", {}).get("count", 0),
|
||||
"failed_count": by_status.get("failed", {}).get("count", 0),
|
||||
"today_success_count": _today_count("success"),
|
||||
"today_success_amount_cents": int(today_success_amount),
|
||||
"today_rejected_count": _today_count("rejected"),
|
||||
}
|
||||
|
||||
|
||||
def list_withdraw_audit_logs(
|
||||
db: Session, out_bill_no: str, *, limit: int = 20
|
||||
) -> list[AdminAuditLog]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(AdminAuditLog)
|
||||
.where(AdminAuditLog.target_type == "withdraw", AdminAuditLog.target_id == out_bill_no)
|
||||
.order_by(AdminAuditLog.id.desc())
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
|
||||
def withdraw_risk_flags(
|
||||
order: WithdrawOrder,
|
||||
user: User | None,
|
||||
recent_withdraws: list[WithdrawOrder],
|
||||
cash_balance_cents: int,
|
||||
) -> tuple[list[str], int]:
|
||||
flags: list[str] = []
|
||||
if not order.user_name:
|
||||
flags.append("缺少提现实名")
|
||||
if user and user.status != "active":
|
||||
flags.append(f"账号状态:{user.status}")
|
||||
if user and user.created_at:
|
||||
created_at = user.created_at.replace(tzinfo=timezone.utc) if user.created_at.tzinfo is None else user.created_at
|
||||
if datetime.now(timezone.utc) - created_at < timedelta(hours=24):
|
||||
flags.append("新注册用户")
|
||||
failed_or_rejected = sum(1 for item in recent_withdraws if item.status in {"failed", "rejected"})
|
||||
if failed_or_rejected:
|
||||
flags.append(f"历史异常提现{failed_or_rejected}笔")
|
||||
recent_reviewing = sum(1 for item in recent_withdraws if item.status == "reviewing")
|
||||
if recent_reviewing >= 3:
|
||||
flags.append(f"待审核提现偏多:{recent_reviewing}笔")
|
||||
if cash_balance_cents < 0:
|
||||
flags.append("现金余额为负")
|
||||
score = min(100, len(flags) * 20 + failed_or_rejected * 10)
|
||||
return flags, score
|
||||
|
||||
|
||||
def withdraw_ledger_check(db: Session) -> dict:
|
||||
cash_balance_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
|
||||
)
|
||||
cash_txn_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
|
||||
)
|
||||
|
||||
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
|
||||
cash_txns = list(
|
||||
db.execute(
|
||||
select(CashTransaction).where(
|
||||
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
withdraw_refs = {txn.ref_id for txn in cash_txns if txn.biz_type == "withdraw"}
|
||||
refund_counts: dict[str, int] = {}
|
||||
for txn in cash_txns:
|
||||
if txn.biz_type == "withdraw_refund" and txn.ref_id:
|
||||
refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1
|
||||
|
||||
missing_withdraw = 0
|
||||
missing_refund = 0
|
||||
refund_on_non_terminal = 0
|
||||
for order in orders:
|
||||
if order.out_bill_no not in withdraw_refs:
|
||||
missing_withdraw += 1
|
||||
has_refund = refund_counts.get(order.out_bill_no, 0) > 0
|
||||
if order.status in {"failed", "rejected"} and not has_refund:
|
||||
missing_refund += 1
|
||||
if has_refund and order.status not in {"failed", "rejected"}:
|
||||
refund_on_non_terminal += 1
|
||||
|
||||
duplicate_refund = sum(1 for count in refund_counts.values() if count > 1)
|
||||
diff = cash_balance_total - cash_txn_total
|
||||
ok = (
|
||||
diff == 0
|
||||
and missing_withdraw == 0
|
||||
and missing_refund == 0
|
||||
and duplicate_refund == 0
|
||||
and refund_on_non_terminal == 0
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
"cash_balance_total_cents": cash_balance_total,
|
||||
"cash_transaction_total_cents": cash_txn_total,
|
||||
"balance_diff_cents": diff,
|
||||
"missing_withdraw_txn_count": missing_withdraw,
|
||||
"missing_refund_txn_count": missing_refund,
|
||||
"duplicate_refund_txn_count": duplicate_refund,
|
||||
"refund_txn_on_non_terminal_count": refund_on_non_terminal,
|
||||
}
|
||||
|
||||
|
||||
def get_user_overview(db: Session, user_id: int) -> dict | None:
|
||||
"""用户 360 概览:基础资料 + 钱包余额 + 各项 count。历史明细走各自分页接口(带 user_id 过滤)。"""
|
||||
user = db.get(User, user_id)
|
||||
@@ -151,3 +544,34 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
|
||||
).scalar_one(),
|
||||
"feedback_total": _count(Feedback, Feedback.user_id == user_id),
|
||||
}
|
||||
|
||||
|
||||
def list_price_reports(
|
||||
db: Session,
|
||||
*,
|
||||
status: str | None = None,
|
||||
user_id: int | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[PriceReport], int | None, int]:
|
||||
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,id 倒序。"""
|
||||
stmt = select(PriceReport)
|
||||
if status:
|
||||
stmt = stmt.where(PriceReport.status == status)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(PriceReport.user_id == user_id)
|
||||
return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def price_report_summary(db: Session) -> dict:
|
||||
"""上报审核台各状态计数(待审核/已通过/已拒绝/合计)。"""
|
||||
rows = db.execute(
|
||||
select(PriceReport.status, func.count(PriceReport.id)).group_by(PriceReport.status)
|
||||
).all()
|
||||
by_status = {status: int(count) for status, count in rows}
|
||||
return {
|
||||
"pending": by_status.get("pending", 0),
|
||||
"approved": by_status.get("approved", 0),
|
||||
"rejected": by_status.get("rejected", 0),
|
||||
"total": sum(by_status.values()),
|
||||
}
|
||||
|
||||
@@ -10,8 +10,11 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.signin import SigninBoostRecord, SigninRecord
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||
|
||||
@@ -71,6 +74,37 @@ def dashboard_overview(db: Session) -> dict:
|
||||
"coins": {
|
||||
# 累计发放金币(coin_transaction 里所有 amount>0 之和;负数是兑换/扣减不计)
|
||||
"granted_total": _sum(CoinTransaction.amount, CoinTransaction.amount > 0),
|
||||
"reward_video_coin_total": _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
),
|
||||
"reward_video_watch_count": _count(
|
||||
AdRewardRecord,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
AdRewardRecord.status == "granted",
|
||||
),
|
||||
"feed_ad_coin_total": _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type == "feed_ad_reward",
|
||||
),
|
||||
"feed_ad_watch_count": _count(
|
||||
AdFeedRewardRecord,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
),
|
||||
"signin_coin_total": _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type == "signin",
|
||||
),
|
||||
"signin_count": _count(SigninRecord),
|
||||
"signin_boost_coin_total": _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type == "signin_boost",
|
||||
),
|
||||
"signin_boost_watch_count": _count(SigninBoostRecord),
|
||||
},
|
||||
"cash": {
|
||||
"withdraw_success_cents": _sum(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""admin 看广告金币审计:只读对账,核对发奖金币是否按公式计算。
|
||||
|
||||
任意已登录 admin 可看(只读,不涉及资金操作)。复算逻辑在 app/admin/repositories/ad_audit.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import ad_audit
|
||||
from app.admin.schemas.ad_audit import AdCoinAuditOut, AdCoinAuditRow, AdCoinFormulaOut
|
||||
from app.core.rewards import cn_today
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/ad-coin-audit",
|
||||
tags=["admin-ad-coin-audit"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AdCoinAuditOut, summary="看广告金币公式审计(复算对比)")
|
||||
def get_ad_coin_audit(
|
||||
db: AdminDb,
|
||||
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
|
||||
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
|
||||
scene: Annotated[
|
||||
str | None, Query(description="reward_video / feed;不传=两类都要")
|
||||
] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
||||
only_mismatch: Annotated[
|
||||
bool, Query(description="只看不一致(✗)行;统计数仍按全量,不受影响")
|
||||
] = False,
|
||||
) -> AdCoinAuditOut:
|
||||
audit_date = date or cn_today().isoformat()
|
||||
result = ad_audit.ad_coin_audit(
|
||||
db, date=audit_date, user_id=user_id, scene=scene, limit=limit,
|
||||
only_mismatch=only_mismatch,
|
||||
)
|
||||
return AdCoinAuditOut(
|
||||
date=audit_date,
|
||||
formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()),
|
||||
total=result["total"],
|
||||
mismatch_count=result["mismatch_count"],
|
||||
truncated=result["truncated"],
|
||||
items=[AdCoinAuditRow(**r) for r in result["items"]],
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""admin 广告配置:读/写穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
|
||||
|
||||
存在 app_config 表的 ad_config dict(见 repositories/app_config.get_ad_config/set_ad_config)。
|
||||
客户端经 /api/v1/platform/ad-config 拉取(不含 reward_mkey)。权限 operator/finance + 审计。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.schemas.ad_config import AdConfigOut, AdConfigUpdate
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import app_config
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/ad-config",
|
||||
tags=["admin-ad-config"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AdConfigOut, summary="广告配置(穿山甲ID/验签密钥/各场景开关)")
|
||||
def get_ad_config(db: AdminDb) -> AdConfigOut:
|
||||
return AdConfigOut(**app_config.get_ad_config(db))
|
||||
|
||||
|
||||
@router.patch("", response_model=AdConfigOut, summary="改广告配置(部分更新,带审计)")
|
||||
def update_ad_config(
|
||||
body: AdConfigUpdate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))],
|
||||
db: AdminDb,
|
||||
) -> AdConfigOut:
|
||||
data = body.model_dump(exclude_none=True)
|
||||
app_config.set_ad_config(db, data, admin_id=admin.id, commit=False)
|
||||
# 审计只记改了哪些字段,不记 mkey 明文(机密)
|
||||
write_audit(
|
||||
db, admin, action="ad_config.set", target_type="ad_config", target_id="ad_config",
|
||||
detail={"changed": sorted(data.keys())}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return AdConfigOut(**app_config.get_ad_config(db))
|
||||
@@ -0,0 +1,75 @@
|
||||
"""admin 广告收益报表:按 用户/日期/广告类型/应用/代码位 聚合 展示条数 / 收益 / 金币。
|
||||
|
||||
任意已登录 admin 可看(只读,不涉及资金操作)。聚合逻辑在 app/admin/repositories/ad_revenue.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import ad_revenue
|
||||
from app.admin.schemas.ad_revenue import AdRevenueDaily, AdRevenueReportOut, AdRevenueRow
|
||||
from app.core.rewards import cn_today
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/ad-revenue-report",
|
||||
tags=["admin-ad-revenue-report"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
# 区间最大跨度(天);超出拒绝,避免审计页一次拉过多天(逐日审计 + 大查询)拖垮接口。
|
||||
_MAX_RANGE_DAYS = 92
|
||||
|
||||
|
||||
def _parse_day(value: str | None, *, field: str, default: _date) -> _date:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return _date.fromisoformat(value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
|
||||
|
||||
|
||||
@router.get("", response_model=AdRevenueReportOut, summary="广告收益报表(按 日期区间/用户/类型/应用/代码位 聚合)")
|
||||
def get_ad_revenue_report(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[str | None, Query(description="起始日 北京时间 YYYY-MM-DD,默认今天")] = None,
|
||||
date_to: Annotated[str | None, Query(description="结束日 北京时间 YYYY-MM-DD,闭区间,默认=date_from")] = None,
|
||||
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
|
||||
ad_type: Annotated[
|
||||
str | None,
|
||||
Query(description="reward_video / feed / draw;不传=全部类型"),
|
||||
] = None,
|
||||
granularity: Annotated[
|
||||
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
|
||||
] = "day",
|
||||
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
|
||||
) -> AdRevenueReportOut:
|
||||
today = cn_today()
|
||||
d_from = _parse_day(date_from, field="date_from", default=today)
|
||||
d_to = _parse_day(date_to, field="date_to", default=d_from)
|
||||
if d_to < d_from:
|
||||
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
|
||||
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
|
||||
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS} 天")
|
||||
|
||||
result = ad_revenue.ad_revenue_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
||||
user_id=user_id, ad_type=ad_type, granularity=granularity, limit=limit,
|
||||
)
|
||||
return AdRevenueReportOut(
|
||||
date_from=d_from.isoformat(),
|
||||
date_to=d_to.isoformat(),
|
||||
daily=[AdRevenueDaily(**d) for d in result["daily"]],
|
||||
total=result["total"],
|
||||
truncated=result["truncated"],
|
||||
total_impressions=result["total_impressions"],
|
||||
total_revenue_yuan=result["total_revenue_yuan"],
|
||||
total_expected_coin=result["total_expected_coin"],
|
||||
total_actual_coin=result["total_actual_coin"],
|
||||
mismatch_count=result["mismatch_count"],
|
||||
items=[AdRevenueRow(**r) for r in result["items"]],
|
||||
)
|
||||
@@ -17,6 +17,12 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
def _active_super_count(db: AdminDb) -> int:
|
||||
return sum(
|
||||
1 for a in admin_repo.list_admins(db) if a.role == "super_admin" and a.status == "active"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdminOut], summary="管理员列表")
|
||||
def list_admins(db: AdminDb) -> list[AdminOut]:
|
||||
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
|
||||
@@ -48,16 +54,29 @@ def update_admin(
|
||||
if admin_id == admin.id and body.status == "disabled":
|
||||
raise HTTPException(status_code=400, detail="不能禁用自己")
|
||||
|
||||
# 防自锁:降级 / 禁用某个 super_admin 前,确认操作后仍至少剩 1 个 active super_admin,
|
||||
# 否则会进入「零可用超管」死局——本路由仅 super 可进,只能改库恢复。
|
||||
demotes_super = (
|
||||
target.role == "super_admin"
|
||||
and target.status == "active"
|
||||
and (
|
||||
(body.role is not None and body.role != "super_admin")
|
||||
or body.status == "disabled"
|
||||
)
|
||||
)
|
||||
if demotes_super and _active_super_count(db) <= 1:
|
||||
raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员")
|
||||
|
||||
changes: dict = {}
|
||||
if body.role is not None:
|
||||
if body.role is not None and body.role != target.role:
|
||||
changes["role"] = {"before": target.role, "after": body.role}
|
||||
target.role = body.role
|
||||
changes["role"] = body.role
|
||||
if body.status is not None:
|
||||
if body.status is not None and body.status != target.status:
|
||||
changes["status"] = {"before": target.status, "after": body.status}
|
||||
target.status = body.status
|
||||
changes["status"] = body.status
|
||||
if body.password is not None:
|
||||
target.password_hash = hash_password(body.password)
|
||||
changes["password"] = "reset"
|
||||
target.password_hash = hash_password(body.password)
|
||||
if not changes:
|
||||
raise HTTPException(status_code=400, detail="无任何变更字段")
|
||||
db.commit()
|
||||
|
||||
@@ -26,9 +26,11 @@ def list_audit_logs(
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminAuditLogOut]:
|
||||
items, next_cursor = audit_repo.list_audit_logs(
|
||||
items, next_cursor, total = audit_repo.list_audit_logs(
|
||||
db, action=action, target_type=target_type, admin_id=admin_id, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AdminAuditLogOut.model_validate(x) for x in items], next_cursor=next_cursor,
|
||||
items=[AdminAuditLogOut.model_validate(x) for x in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""admin 比价记录 debug:按 user_id / phone 查列表 + 单条详情(含 LLM 每次调用明细 / 原始 payload)。
|
||||
|
||||
只读。读权限默认即可(get_current_admin),不额外 require_role(查记录是基础 debug 能力)。
|
||||
trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.debug_trace_enabled 权限闸。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import queries
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/comparison-records",
|
||||
tags=["admin-comparison"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=CursorPage[AdminComparisonListItem],
|
||||
summary="比价记录列表(按 user_id/phone 筛 + 分页)",
|
||||
)
|
||||
def list_comparison_records(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
|
||||
business_type: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminComparisonListItem]:
|
||||
items, next_cursor, total = queries.list_comparison_records(
|
||||
db, user_id=user_id, phone=phone, status=status,
|
||||
business_type=business_type, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AdminComparisonListItem.model_validate(r) for r in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{record_id}",
|
||||
response_model=AdminComparisonDetail,
|
||||
summary="比价记录详情(含 LLM 每次调用明细 + 原始 payload)",
|
||||
)
|
||||
def get_comparison_record(record_id: int, db: AdminDb) -> AdminComparisonDetail:
|
||||
rec = queries.get_comparison_record(db, record_id)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
return AdminComparisonDetail.model_validate(rec)
|
||||
@@ -13,6 +13,7 @@ from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.schemas.config import ConfigItemOut, ConfigUpdateRequest
|
||||
from app.core.config_schema import CONFIG_DEFS
|
||||
from app.core.rewards import SIGNIN_CYCLE_LEN
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import app_config
|
||||
|
||||
@@ -34,14 +35,17 @@ def _validate(key: str, value: Any) -> None:
|
||||
raise ValueError("需为非空整数列表")
|
||||
if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value):
|
||||
raise ValueError("列表元素需为非负整数")
|
||||
if key == "signin_rewards" and len(value) != 7:
|
||||
raise ValueError("签到档位必须正好 7 个(对应 7 天循环)")
|
||||
if key == "signin_rewards" and len(value) != SIGNIN_CYCLE_LEN:
|
||||
raise ValueError(f"签到档位必须正好 {SIGNIN_CYCLE_LEN} 个(对应 {SIGNIN_CYCLE_LEN} 天循环)")
|
||||
elif t == "dict_str_int":
|
||||
if not isinstance(value, dict) or not all(
|
||||
isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool)
|
||||
for k, v in value.items()
|
||||
):
|
||||
raise ValueError("需为 {字符串: 整数} 映射")
|
||||
elif t == "bool":
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError("需为布尔值")
|
||||
|
||||
|
||||
def _item(db, key: str) -> ConfigItemOut:
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
|
||||
|
||||
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
|
||||
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。
|
||||
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.repositories import cps as cps_repo
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.admin.schemas.cps import (
|
||||
CpsActivityCreate,
|
||||
CpsActivityOut,
|
||||
CpsActivityUpdate,
|
||||
CpsGroupCreate,
|
||||
CpsGroupOut,
|
||||
CpsGroupStat,
|
||||
CpsGroupUpdate,
|
||||
CpsOrderOut,
|
||||
CpsReconcileResult,
|
||||
CpsReferralLinkItem,
|
||||
CpsReferralLinksOut,
|
||||
CpsReferralLinksRequest,
|
||||
CpsStatsOut,
|
||||
)
|
||||
from app.core import media
|
||||
from app.core.config import settings
|
||||
from app.integrations import meituan
|
||||
from app.integrations.meituan import MeituanCpsError
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_group import CpsGroup
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/cps",
|
||||
tags=["admin-cps"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
_VALID_PLATFORMS = {"meituan", "taobao", "jd"}
|
||||
|
||||
|
||||
# ───────────── 群 ─────────────
|
||||
@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群列表")
|
||||
def list_groups(
|
||||
db: AdminDb,
|
||||
keyword: Annotated[str | None, Query(max_length=100)] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(active|archived)$")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[CpsGroupOut]:
|
||||
items, next_cursor, total = cps_repo.list_groups(
|
||||
db, keyword=keyword, status=status, limit=limit, cursor=cursor
|
||||
)
|
||||
return CursorPage(
|
||||
items=[CpsGroupOut.model_validate(g) for g in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/groups", response_model=CpsGroupOut, summary="新建群")
|
||||
def create_group(
|
||||
body: CpsGroupCreate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> CpsGroupOut:
|
||||
bad = [p for p in body.platforms if p not in _VALID_PLATFORMS]
|
||||
if bad:
|
||||
raise HTTPException(status_code=400, detail=f"非法平台: {bad}")
|
||||
has_meituan = "meituan" in body.platforms
|
||||
if has_meituan and body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None:
|
||||
raise HTTPException(status_code=409, detail="sid 已存在")
|
||||
group = cps_repo.create_group(
|
||||
db, name=body.name, platforms=body.platforms, sid=body.sid,
|
||||
member_count=body.member_count, remark=body.remark, commit=False,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="cps.group.create", target_type="cps_group", target_id=group.id,
|
||||
detail={"name": group.name, "platforms": group.platforms, "sid": group.sid},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return CpsGroupOut.model_validate(group)
|
||||
|
||||
|
||||
@router.patch("/groups/{group_id}", response_model=CpsGroupOut, summary="编辑群")
|
||||
def update_group(
|
||||
group_id: int,
|
||||
body: CpsGroupUpdate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> CpsGroupOut:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
if body.platforms is not None:
|
||||
bad = [p for p in body.platforms if p not in _VALID_PLATFORMS]
|
||||
if bad:
|
||||
raise HTTPException(status_code=400, detail=f"非法平台: {bad}")
|
||||
cps_repo.update_group(
|
||||
db, group, name=body.name, platforms=body.platforms,
|
||||
member_count=body.member_count, status=body.status, remark=body.remark, commit=False,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="cps.group.update", target_type="cps_group", target_id=group_id,
|
||||
detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return CpsGroupOut.model_validate(group)
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}", summary="删除群")
|
||||
def delete_group(
|
||||
group_id: int,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> dict:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
write_audit(
|
||||
db, admin, action="cps.group.delete", target_type="cps_group", target_id=group_id,
|
||||
detail={"name": group.name, "sid": group.sid, "platforms": list(group.platforms or [])},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
cps_repo.delete_group(db, group, commit=False)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ───────────── 活动 ─────────────
|
||||
@router.get("/activities", response_model=CursorPage[CpsActivityOut], summary="活动列表")
|
||||
def list_activities(
|
||||
db: AdminDb,
|
||||
platform: Annotated[str | None, Query(max_length=20)] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(active|archived)$")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[CpsActivityOut]:
|
||||
items, next_cursor, total = cps_repo.list_activities(
|
||||
db, platform=platform, status=status, limit=limit, cursor=cursor
|
||||
)
|
||||
return CursorPage(
|
||||
items=[CpsActivityOut.model_validate(a) for a in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/activities", response_model=CpsActivityOut, summary="新建活动")
|
||||
def create_activity(
|
||||
body: CpsActivityCreate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> CpsActivityOut:
|
||||
if body.platform == "meituan":
|
||||
if not body.act_id and not body.product_view_sign:
|
||||
raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign")
|
||||
else: # taobao / jd
|
||||
if not body.payload:
|
||||
label = "淘口令" if body.platform == "taobao" else "推广链接"
|
||||
raise HTTPException(status_code=400, detail=f"{body.platform} 活动需填{label}")
|
||||
if body.platform == "taobao" and not body.image_url:
|
||||
raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)")
|
||||
activity = cps_repo.create_activity(
|
||||
db, name=body.name, platform=body.platform, act_id=body.act_id,
|
||||
product_view_sign=body.product_view_sign, payload=body.payload,
|
||||
image_url=media.to_abs_media_url(body.image_url), remark=body.remark, commit=False,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="cps.activity.create", target_type="cps_activity", target_id=activity.id,
|
||||
detail={"name": activity.name, "platform": activity.platform},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(activity)
|
||||
return CpsActivityOut.model_validate(activity)
|
||||
|
||||
|
||||
@router.patch("/activities/{activity_id}", response_model=CpsActivityOut, summary="编辑活动")
|
||||
def update_activity(
|
||||
activity_id: int,
|
||||
body: CpsActivityUpdate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> CpsActivityOut:
|
||||
activity = cps_repo.get_activity(db, activity_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="活动不存在")
|
||||
# 用「合并后最终值」校验平台必填项(同新建口径),允许只改部分字段
|
||||
platform = body.platform or activity.platform
|
||||
act_id = body.act_id if body.act_id is not None else activity.act_id
|
||||
pvs = body.product_view_sign if body.product_view_sign is not None else activity.product_view_sign
|
||||
payload = body.payload if body.payload is not None else activity.payload
|
||||
image_url = body.image_url if body.image_url is not None else activity.image_url
|
||||
if platform == "meituan":
|
||||
if not act_id and not pvs:
|
||||
raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign")
|
||||
else: # taobao / jd
|
||||
if not payload:
|
||||
label = "淘口令" if platform == "taobao" else "推广链接"
|
||||
raise HTTPException(status_code=400, detail=f"{platform} 活动需填{label}")
|
||||
if platform == "taobao" and not image_url:
|
||||
raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)")
|
||||
cps_repo.update_activity(
|
||||
db, activity, name=body.name, platform=body.platform, act_id=body.act_id,
|
||||
product_view_sign=body.product_view_sign, payload=body.payload,
|
||||
image_url=media.to_abs_media_url(body.image_url) if body.image_url else None,
|
||||
remark=body.remark, status=body.status, commit=False,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="cps.activity.update", target_type="cps_activity", target_id=activity_id,
|
||||
detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(activity)
|
||||
return CpsActivityOut.model_validate(activity)
|
||||
|
||||
|
||||
@router.post("/upload-image", summary="上传活动落地页图(返回绝对 URL)")
|
||||
async def upload_activity_image(
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
file: UploadFile = File(..., description="落地页图(jpeg/png/webp,≤5MB)"),
|
||||
) -> dict:
|
||||
data = await file.read()
|
||||
try:
|
||||
url = media.save_cps_image(admin.id, data)
|
||||
except media.MediaError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"url": media.to_abs_media_url(url)}
|
||||
|
||||
|
||||
@router.get("/activity-images", summary="已有活动落地页图(供新建复用选择)")
|
||||
def activity_images(db: AdminDb) -> dict:
|
||||
return {"images": cps_repo.list_activity_images(db)}
|
||||
|
||||
|
||||
@router.delete("/activities/{activity_id}", summary="删除活动")
|
||||
def delete_activity(
|
||||
activity_id: int,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> dict:
|
||||
activity = cps_repo.get_activity(db, activity_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="活动不存在")
|
||||
write_audit(
|
||||
db, admin, action="cps.activity.delete", target_type="cps_activity", target_id=activity_id,
|
||||
detail={"name": activity.name, "platform": activity.platform},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
cps_repo.delete_activity(db, activity, commit=False)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ───────────── 生成链接(批量,按平台分支) ─────────────
|
||||
def _gen_one_link(db, group: CpsGroup, activity: CpsActivity):
|
||||
"""给一个活动生成一条落地页 link。美团:转链拿短链(需群 sid);淘宝/京东:用 payload。"""
|
||||
if activity.platform == "meituan":
|
||||
if not group.sid:
|
||||
raise HTTPException(status_code=400, detail=f"群「{group.name}」无 sid,无法生成美团链接")
|
||||
try:
|
||||
resp = meituan.get_referral_link(
|
||||
act_id=activity.act_id, product_view_sign=activity.product_view_sign,
|
||||
sid=group.sid, link_type_list=[1, 2, 3],
|
||||
)
|
||||
except MeituanCpsError as e:
|
||||
raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e
|
||||
link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()}
|
||||
if not link_map and resp.get("data"):
|
||||
link_map = {"1": resp["data"]}
|
||||
target = link_map.get("2") or link_map.get("1") or link_map.get("3")
|
||||
if not target:
|
||||
raise HTTPException(status_code=502, detail=f"美团未返回有效链接(活动「{activity.name}」)")
|
||||
else: # taobao(淘口令) / jd(链接)
|
||||
if not activity.payload:
|
||||
raise HTTPException(status_code=400, detail=f"活动「{activity.name}」缺少内容")
|
||||
target = activity.payload
|
||||
return cps_link_repo.create_link(
|
||||
db, group_id=group.id, activity_id=activity.id, sid=group.sid,
|
||||
target_url=target, platform=activity.platform, commit=False,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/referral-links", response_model=CpsReferralLinksOut, summary="批量生成落地页短链")
|
||||
def generate_referral_links(
|
||||
body: CpsReferralLinksRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> CpsReferralLinksOut:
|
||||
group = cps_repo.get_group(db, body.group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
group_platforms = set(group.platforms or [])
|
||||
base = settings.CPS_REDIRECT_BASE.rstrip("/")
|
||||
|
||||
items: list[CpsReferralLinkItem] = []
|
||||
for aid in body.activity_ids:
|
||||
activity = cps_repo.get_activity(db, aid)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail=f"活动 {aid} 不存在")
|
||||
if activity.platform not in group_platforms:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"活动「{activity.name}」平台({activity.platform})不在群「{group.name}」范围内",
|
||||
)
|
||||
link = _gen_one_link(db, group, activity)
|
||||
items.append(CpsReferralLinkItem(
|
||||
activity_id=activity.id, activity_name=activity.name, platform=activity.platform,
|
||||
redirect_url=f"{base}/c/{link.code}" if base else f"/c/{link.code}", code=link.code,
|
||||
))
|
||||
|
||||
write_audit(
|
||||
db, admin, action="cps.referral_link.generate", target_type="cps_group", target_id=group.id,
|
||||
detail={"group": group.name, "activity_ids": body.activity_ids, "count": len(items)},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return CpsReferralLinksOut(group_name=group.name, results=items)
|
||||
|
||||
|
||||
# ───────────── 订单对账 ─────────────
|
||||
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
|
||||
def reconcile_orders(
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
days: Annotated[int, Query(ge=1, le=90)] = 7,
|
||||
sid: Annotated[str | None, Query(max_length=64)] = None,
|
||||
) -> CpsReconcileResult:
|
||||
now = int(time.time())
|
||||
try:
|
||||
result = cps_repo.reconcile_orders(
|
||||
db, start_time=now - days * 86400, end_time=now, sid=sid,
|
||||
)
|
||||
except MeituanCpsError as e:
|
||||
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
|
||||
write_audit(
|
||||
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
|
||||
detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return CpsReconcileResult(**result)
|
||||
|
||||
|
||||
@router.get("/orders", response_model=CursorPage[CpsOrderOut], summary="订单明细")
|
||||
def list_orders(
|
||||
db: AdminDb,
|
||||
sid: Annotated[str | None, Query(max_length=64)] = None,
|
||||
mt_status: Annotated[str | None, Query(max_length=8)] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[CpsOrderOut]:
|
||||
items, next_cursor, total = cps_repo.list_orders(
|
||||
db, sid=sid, mt_status=mt_status, limit=limit, cursor=cursor
|
||||
)
|
||||
return CursorPage(
|
||||
items=[CpsOrderOut.model_validate(o) for o in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
# ───────────── 统计 ─────────────
|
||||
@router.get("/stats", response_model=CpsStatsOut, summary="按群对账统计")
|
||||
def get_stats(
|
||||
db: AdminDb,
|
||||
days: Annotated[int, Query(ge=1, le=90)] = 30,
|
||||
) -> CpsStatsOut:
|
||||
date_to = datetime.now(timezone.utc)
|
||||
date_from = date_to - timedelta(days=days)
|
||||
rows = cps_repo.group_stats(db, date_from=date_from, date_to=date_to)
|
||||
stats = [CpsGroupStat(**r) for r in rows]
|
||||
return CpsStatsOut(
|
||||
groups=stats,
|
||||
total_order_count=sum(s.order_count or 0 for s in stats),
|
||||
total_est_commission_cents=sum(s.est_commission_cents or 0 for s in stats),
|
||||
total_settled_commission_cents=sum(s.settled_commission_cents or 0 for s in stats),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/timeseries", summary="群点击时序(折线图:天/小时级 PV/UV/复制)")
|
||||
def group_timeseries(
|
||||
group_id: int,
|
||||
db: AdminDb,
|
||||
granularity: Annotated[str, Query(pattern="^(day|hour)$")] = "day",
|
||||
days: Annotated[int, Query(ge=1, le=30)] = 7,
|
||||
date: Annotated[str | None, Query()] = None, # hour 粒度看哪天(YYYY-MM-DD 北京),默认今天
|
||||
) -> dict:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
bj = timezone(timedelta(hours=8))
|
||||
now_bj = datetime.now(bj)
|
||||
if granularity == "hour":
|
||||
if date:
|
||||
try:
|
||||
day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e
|
||||
else:
|
||||
day0 = now_bj
|
||||
start = day0.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = start + timedelta(days=1) - timedelta(microseconds=1)
|
||||
else:
|
||||
start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = now_bj
|
||||
points = cps_repo.group_click_timeseries(
|
||||
db, group_id=group_id, granularity=granularity, start=start, end=end
|
||||
)
|
||||
return {
|
||||
"group_id": group.id,
|
||||
"group_name": group.name,
|
||||
"granularity": granularity,
|
||||
"points": points,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/daily", summary="群每天明细大表格(点击+订单按天合并)")
|
||||
def group_daily(
|
||||
group_id: int,
|
||||
db: AdminDb,
|
||||
days: Annotated[int, Query(ge=1, le=90)] = 7,
|
||||
) -> dict:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
bj = timezone(timedelta(hours=8))
|
||||
now_bj = datetime.now(bj)
|
||||
start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = now_bj
|
||||
# 点击侧:连续 N 天补零,顺序 = start..end(与下面 while 同序,按 index 对齐避免跨年 MM-DD 碰撞)
|
||||
click_points = cps_repo.group_click_timeseries(
|
||||
db, group_id=group_id, granularity="day", start=start, end=end
|
||||
)
|
||||
# 订单侧:仅美团群(有 sid)有对账;淘宝/京东订单列返回 None(前端显示 "-")
|
||||
is_meituan = "meituan" in (group.platforms or []) and bool(group.sid)
|
||||
order_daily = (
|
||||
cps_repo.group_order_daily(db, sid=group.sid, start=start, end=end) if is_meituan else {}
|
||||
)
|
||||
_ORDER_KEYS = (
|
||||
"order_count", "canceled_count", "gmv_cents",
|
||||
"est_commission_cents", "refund_profit_cents", "settled_commission_cents",
|
||||
)
|
||||
rows = []
|
||||
cur = start.astimezone(bj).date()
|
||||
last = end.astimezone(bj).date()
|
||||
idx = 0
|
||||
while cur <= last:
|
||||
cp = click_points[idx] if idx < len(click_points) else None
|
||||
row = {
|
||||
"date": cur.strftime("%m-%d"),
|
||||
"click_pv": cp["click_pv"] if cp else 0,
|
||||
"click_uv": cp["click_uv"] if cp else 0,
|
||||
"copy_pv": cp["copy_pv"] if cp else 0,
|
||||
}
|
||||
if is_meituan:
|
||||
od = order_daily.get(cur.strftime("%Y-%m-%d"))
|
||||
row.update({k: (od[k] if od else 0) for k in _ORDER_KEYS})
|
||||
else:
|
||||
row.update({k: None for k in _ORDER_KEYS})
|
||||
rows.append(row)
|
||||
idx += 1
|
||||
cur += timedelta(days=1)
|
||||
return {
|
||||
"group_id": group.id,
|
||||
"group_name": group.name,
|
||||
"is_meituan": is_meituan,
|
||||
"days": days,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/wx-users", summary="群内微信用户(领券画像:头像/昵称/领券次数)")
|
||||
def group_wx_users(group_id: int, db: AdminDb) -> dict:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
|
||||
@@ -1,6 +1,7 @@
|
||||
"""admin 反馈工单:列表(读)+ 标记已处理(写,带审计)。"""
|
||||
"""admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
@@ -25,14 +26,30 @@ def list_feedbacks(
|
||||
db: AdminDb,
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
content: Annotated[str | None, Query(max_length=100)] = None,
|
||||
created_from: Annotated[datetime | None, Query()] = None,
|
||||
created_to: Annotated[datetime | None, Query()] = None,
|
||||
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
|
||||
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[FeedbackOut]:
|
||||
items, next_cursor = queries.list_feedbacks(
|
||||
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
|
||||
items, next_cursor, total = queries.list_feedbacks(
|
||||
db,
|
||||
status=status,
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
created_from=created_from,
|
||||
created_to=created_to,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor,
|
||||
items=[FeedbackOut.model_validate(f) for f in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""admin 设备维度新手引导管理:列设备 / 重置单设备 / 全部重置。
|
||||
|
||||
背景:原 force_onboarding(运营「按用户」强制引导)客户端不认(被 onboarding_completed 压过),
|
||||
已废弃。改为直接操作 onboarding_completion(设备+账号 维度):删记录 → 该(设备,账号)的
|
||||
onboarding_completed 变 false → 客户端任何版本下次登录都重走引导。可靠、不依赖客户端实现。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.repositories import mutations, queries
|
||||
from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.onboarding import DeviceOnboardingItem, ResetAllRequest
|
||||
from app.models.admin import AdminUser
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/onboarding",
|
||||
tags=["admin-onboarding"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/devices",
|
||||
response_model=CursorPage[DeviceOnboardingItem],
|
||||
summary="设备列表(按设备聚合走过引导的账号)",
|
||||
)
|
||||
def list_devices(db: AdminDb) -> CursorPage[DeviceOnboardingItem]:
|
||||
"""列出走过新手引导的设备(ANDROID_ID),按最近完成时间倒序。没走过引导的设备不在表里、
|
||||
本就会引导,无需管理,故不列。当前全量返回(上限 500;设备量大再加分页/搜索),
|
||||
next_cursor 恒 None。"""
|
||||
rows = queries.list_onboarding_devices(db)
|
||||
return CursorPage(
|
||||
items=[DeviceOnboardingItem(**r) for r in rows],
|
||||
next_cursor=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/devices/{device_id}/reset",
|
||||
response_model=OkResponse,
|
||||
summary="重置单个设备(该设备所有账号下次重走引导)",
|
||||
)
|
||||
def reset_device(
|
||||
device_id: str,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
"""删该设备的全部 onboarding 完成记录 → 该设备上所有账号下次登录重走引导。"""
|
||||
# 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 users 各写接口)
|
||||
deleted = mutations.delete_device_onboarding(db, device_id, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="device_onboarding.reset", target_type="device", target_id=device_id,
|
||||
detail={"deleted_rows": deleted}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reset-all",
|
||||
response_model=OkResponse,
|
||||
summary="全部重设(清空所有设备引导记录)",
|
||||
)
|
||||
def reset_all(
|
||||
body: ResetAllRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
"""清空 onboarding_completion 表 → 所有用户在所有设备下次登录都重走引导。
|
||||
需 confirm=true 防误触(前端另有二次确认弹窗)。"""
|
||||
if not body.confirm:
|
||||
raise HTTPException(status_code=400, detail="需 confirm=true 确认")
|
||||
deleted = mutations.delete_all_onboarding(db, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="device_onboarding.reset_all", target_type="onboarding", target_id=None,
|
||||
detail={"deleted_rows": deleted}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""admin 首页轮播种子管理:列出 / 新增 / 改 / 删 / 批量生成 / 预览(带审计)。
|
||||
|
||||
种子用于首页轮播「真实+种子混播」的兜底(见 app/repositories/ops_marquee.py)。
|
||||
权限:operator 可改,super 恒可;预览为只读(任意已登录 admin)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.schemas.ops_marquee_seed import (
|
||||
OpsMarqueeSeedBatchDelete,
|
||||
OpsMarqueeSeedBatchEnable,
|
||||
OpsMarqueeSeedBulkCreate,
|
||||
OpsMarqueeSeedCreate,
|
||||
OpsMarqueeSeedOut,
|
||||
OpsMarqueeSeedUpdate,
|
||||
OpsSavingsFeedPreviewOut,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed
|
||||
from app.repositories import ops_marquee
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/marquee-seeds",
|
||||
tags=["admin-marquee-seeds"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
def _out(seed: OpsMarqueeSeed) -> OpsMarqueeSeedOut:
|
||||
return OpsMarqueeSeedOut(
|
||||
id=seed.id,
|
||||
masked_user=seed.masked_user,
|
||||
min_cents=seed.min_cents,
|
||||
max_cents=seed.max_cents,
|
||||
enabled=seed.enabled,
|
||||
sort_order=seed.sort_order,
|
||||
created_at=seed.created_at.isoformat() if seed.created_at else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[OpsMarqueeSeedOut], summary="轮播种子列表")
|
||||
def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]:
|
||||
return [_out(s) for s in ops_marquee.list_seeds(db)]
|
||||
|
||||
|
||||
@router.get("/preview", response_model=OpsSavingsFeedPreviewOut, summary="预览实际混播 feed")
|
||||
def preview_feed(
|
||||
db: AdminDb,
|
||||
limit: Annotated[int, Query(ge=1, le=30)] = 8,
|
||||
) -> OpsSavingsFeedPreviewOut:
|
||||
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。"""
|
||||
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit))
|
||||
|
||||
|
||||
@router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)")
|
||||
def create_seed(
|
||||
body: OpsMarqueeSeedCreate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OpsMarqueeSeedOut:
|
||||
try:
|
||||
seed = ops_marquee.create_seed(
|
||||
db,
|
||||
masked_user=body.masked_user,
|
||||
min_cents=body.min_cents,
|
||||
max_cents=body.max_cents,
|
||||
enabled=body.enabled,
|
||||
sort_order=body.sort_order,
|
||||
commit=False,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
write_audit(
|
||||
db, admin, action="ops_marquee_seed.create", target_type="ops_marquee_seed",
|
||||
target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(seed)
|
||||
return _out(seed)
|
||||
|
||||
|
||||
@router.post("/bulk", response_model=list[OpsMarqueeSeedOut], summary="批量生成种子(带审计)")
|
||||
def bulk_generate(
|
||||
body: OpsMarqueeSeedBulkCreate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> list[OpsMarqueeSeedOut]:
|
||||
try:
|
||||
seeds = ops_marquee.bulk_generate(
|
||||
db,
|
||||
count=body.count,
|
||||
min_cents=body.min_cents,
|
||||
max_cents=body.max_cents,
|
||||
enabled=body.enabled,
|
||||
commit=False,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
write_audit(
|
||||
db, admin, action="ops_marquee_seed.bulk_generate", target_type="ops_marquee_seed",
|
||||
target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
for s in seeds:
|
||||
db.refresh(s)
|
||||
return [_out(s) for s in seeds]
|
||||
|
||||
|
||||
@router.post("/batch-delete", summary="批量删除种子(带审计)")
|
||||
def batch_delete_seeds(
|
||||
body: OpsMarqueeSeedBatchDelete,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> dict:
|
||||
n = ops_marquee.batch_delete(db, body.ids, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="ops_marquee_seed.batch_delete", target_type="ops_marquee_seed",
|
||||
target_id=None, detail={"ids": body.ids, "deleted": n},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return {"deleted": n}
|
||||
|
||||
|
||||
@router.post("/batch-enable", summary="批量启用 / 停用种子(带审计)")
|
||||
def batch_enable_seeds(
|
||||
body: OpsMarqueeSeedBatchEnable,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> dict:
|
||||
n = ops_marquee.batch_set_enabled(db, body.ids, body.enabled, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="ops_marquee_seed.batch_enable", target_type="ops_marquee_seed",
|
||||
target_id=None, detail={"ids": body.ids, "enabled": body.enabled, "updated": n},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return {"updated": n}
|
||||
|
||||
|
||||
@router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)")
|
||||
def update_seed(
|
||||
seed_id: int,
|
||||
body: OpsMarqueeSeedUpdate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OpsMarqueeSeedOut:
|
||||
try:
|
||||
seed = ops_marquee.update_seed(
|
||||
db,
|
||||
seed_id,
|
||||
masked_user=body.masked_user,
|
||||
min_cents=body.min_cents,
|
||||
max_cents=body.max_cents,
|
||||
enabled=body.enabled,
|
||||
sort_order=body.sort_order,
|
||||
commit=False,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404 if "不存在" in str(e) else 400, detail=str(e)) from e
|
||||
write_audit(
|
||||
db, admin, action="ops_marquee_seed.update", target_type="ops_marquee_seed",
|
||||
target_id=seed_id, detail=body.model_dump(exclude_none=True),
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(seed)
|
||||
return _out(seed)
|
||||
|
||||
|
||||
@router.delete("/{seed_id}", summary="删轮播种子(带审计)")
|
||||
def delete_seed(
|
||||
seed_id: int,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> dict:
|
||||
ok = ops_marquee.delete_seed(db, seed_id, commit=False)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="种子不存在")
|
||||
write_audit(
|
||||
db, admin, action="ops_marquee_seed.delete", target_type="ops_marquee_seed",
|
||||
target_id=seed_id, detail=None, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return {"deleted": True}
|
||||
@@ -0,0 +1,74 @@
|
||||
"""admin 首页三统计展示配置:列出三指标配置(+ 真实值预览)+ 改某指标(带审计 + 校验)。
|
||||
|
||||
每指标可独立选 real/manual/random。计算逻辑见 app/repositories/ops_stat.py。
|
||||
权限:operator 可改(运营调门面数字),super 恒可。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.schemas.ops_stat_config import (
|
||||
OpsStatItemOut,
|
||||
OpsStatUpdateRequest,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import ops_stat
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/dashboard-display",
|
||||
tags=["admin-dashboard-display"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[OpsStatItemOut], summary="首页三统计配置 + 真实值预览")
|
||||
def list_display(db: AdminDb) -> list[OpsStatItemOut]:
|
||||
return [OpsStatItemOut(**item) for item in ops_stat.get_config(db)]
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{metric}", response_model=OpsStatItemOut, summary="改某指标展示配置(带审计)"
|
||||
)
|
||||
def update_display(
|
||||
metric: str,
|
||||
body: OpsStatUpdateRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OpsStatItemOut:
|
||||
try:
|
||||
before, after = ops_stat.update_config(
|
||||
db,
|
||||
metric,
|
||||
mode=body.mode,
|
||||
manual_value=body.manual_value,
|
||||
random_mult_min=body.random_mult_min,
|
||||
random_mult_max=body.random_mult_max,
|
||||
random_tick_seconds=body.random_tick_seconds,
|
||||
random_anchor_minutes=body.random_anchor_minutes,
|
||||
random_kind=body.random_kind,
|
||||
random_step_min=body.random_step_min,
|
||||
random_step_max=body.random_step_max,
|
||||
real_offset=body.real_offset,
|
||||
allow_decrease=body.allow_decrease,
|
||||
random_initial=body.random_initial,
|
||||
apply_now=body.apply_now,
|
||||
admin_id=admin.id,
|
||||
commit=False,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
write_audit(
|
||||
db, admin, action="ops_stat_config.set", target_type="ops_stat_config",
|
||||
target_id=metric, detail={"before": before, "after": after},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
item = next(it for it in ops_stat.get_config(db) if it["metric"] == metric)
|
||||
return OpsStatItemOut(**item)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""admin 上报更低价审核:列表 + 统计(读)+ 通过(发金币)/拒绝(写,带审计)。
|
||||
|
||||
数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台
|
||||
对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS):
|
||||
改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins),
|
||||
绝不只改状态不发钱或反之。客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.repositories import mutations, queries
|
||||
from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.price_report import (
|
||||
PriceReportOut,
|
||||
PriceReportRejectRequest,
|
||||
PriceReportSummary,
|
||||
)
|
||||
from app.core.rewards import PRICE_REPORT_REWARD_COINS
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.price_report import PriceReport
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/price-reports",
|
||||
tags=["admin-price-report"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)")
|
||||
def list_price_reports(
|
||||
db: AdminDb,
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[PriceReportOut]:
|
||||
items, next_cursor, total = queries.list_price_reports(
|
||||
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[PriceReportOut.model_validate(r) for r in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=PriceReportSummary, summary="上报审核统计(各状态计数)")
|
||||
def price_report_summary(db: AdminDb) -> PriceReportSummary:
|
||||
return PriceReportSummary.model_validate(queries.price_report_summary(db))
|
||||
|
||||
|
||||
@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)")
|
||||
def approve_price_report(
|
||||
report_id: int,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
# 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖,
|
||||
# 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。
|
||||
rep = db.get(PriceReport, report_id, with_for_update=True)
|
||||
if rep is None:
|
||||
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||
if rep.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
||||
coins = PRICE_REPORT_REWARD_COINS
|
||||
# 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账
|
||||
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
|
||||
wallet_repo.grant_coins(
|
||||
db, rep.user_id, coins,
|
||||
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
|
||||
detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/{report_id}/reject", response_model=OkResponse, summary="拒绝上报")
|
||||
def reject_price_report(
|
||||
report_id: int,
|
||||
body: PriceReportRejectRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核)
|
||||
if rep is None:
|
||||
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||
if rep.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
||||
reason = body.reason.strip()
|
||||
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
|
||||
detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
+122
-17
@@ -1,6 +1,7 @@
|
||||
"""admin 用户管理:列表 + 360 详情(读)+ 封禁/解封 + 手动调金币(写,带审计)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
@@ -12,7 +13,9 @@ from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.user import (
|
||||
AdminUserListItem,
|
||||
AdminUserOverview,
|
||||
GrantCashRequest,
|
||||
GrantCoinsRequest,
|
||||
SetDebugTraceRequest,
|
||||
SetUserStatusRequest,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
@@ -26,22 +29,32 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+分页)")
|
||||
@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+排序+分页)")
|
||||
def list_users(
|
||||
db: AdminDb,
|
||||
phone: Annotated[str | None, Query()] = None,
|
||||
register_channel: Annotated[str | None, Query()] = None,
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
nickname: Annotated[str | None, Query(max_length=100)] = None,
|
||||
created_from: Annotated[datetime | None, Query()] = None,
|
||||
created_to: Annotated[datetime | None, Query()] = None,
|
||||
last_login_from: Annotated[datetime | None, Query()] = None,
|
||||
last_login_to: Annotated[datetime | None, Query()] = None,
|
||||
sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id",
|
||||
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminUserListItem]:
|
||||
items, next_cursor = queries.list_users(
|
||||
items, next_cursor, total = queries.list_users(
|
||||
db, phone=phone, register_channel=register_channel, status=status,
|
||||
limit=limit, cursor=cursor,
|
||||
nickname=nickname, created_from=created_from, created_to=created_to,
|
||||
last_login_from=last_login_from, last_login_to=last_login_to,
|
||||
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AdminUserListItem.model_validate(u) for u in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@@ -77,7 +90,29 @@ def set_user_status(
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)")
|
||||
@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限")
|
||||
def set_user_debug_trace(
|
||||
user_id: int,
|
||||
body: SetDebugTraceRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
user = user_repo.get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
before = user.debug_trace_enabled
|
||||
# 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 set_user_status)
|
||||
mutations.set_user_debug_trace(db, user, enabled=body.enabled, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="user.debug_trace.set", target_type="user", target_id=user_id,
|
||||
detail={"before": before, "after": body.enabled}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减/设值金币(带审计)")
|
||||
def grant_user_coins(
|
||||
user_id: int,
|
||||
body: GrantCoinsRequest,
|
||||
@@ -85,27 +120,97 @@ def grant_user_coins(
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
if body.amount == 0:
|
||||
raise HTTPException(status_code=400, detail="amount 不能为 0")
|
||||
user = user_repo.get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
# 负数扣减时不允许扣成负余额(运营误操作保护)
|
||||
if body.amount < 0:
|
||||
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False)
|
||||
if acc_now.coin_balance + body.amount < 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})"
|
||||
)
|
||||
biz_type = "admin_grant" if body.amount > 0 else "admin_deduct"
|
||||
# set=设为目标值:读当前余额算出要写的差值,仍复用 grant_coins 写一笔流水(沿用原子/审计/扣负保护)
|
||||
if body.mode == "set":
|
||||
if body.amount < 0:
|
||||
raise HTTPException(status_code=400, detail="目标金币值不能为负")
|
||||
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
|
||||
before = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True).coin_balance
|
||||
delta = body.amount - before
|
||||
if delta == 0:
|
||||
raise HTTPException(status_code=400, detail=f"当前金币已为 {body.amount},无需调整")
|
||||
else:
|
||||
if body.amount == 0:
|
||||
raise HTTPException(status_code=400, detail="amount 不能为 0")
|
||||
delta = body.amount
|
||||
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
|
||||
if delta < 0:
|
||||
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
|
||||
if acc_now.coin_balance + delta < 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})"
|
||||
)
|
||||
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
|
||||
# grant_coins 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
|
||||
acc, _ = wallet_repo.grant_coins(
|
||||
db, user_id, body.amount, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
|
||||
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
|
||||
)
|
||||
detail = {"amount": delta, "balance_after": acc.coin_balance, "reason": body.reason}
|
||||
if body.mode == "set":
|
||||
detail.update({"mode": "set", "target": body.amount, "before": before})
|
||||
write_audit(
|
||||
db, admin, action="user.coins.grant", target_type="user", target_id=user_id,
|
||||
detail={"amount": body.amount, "balance_after": acc.coin_balance, "reason": body.reason},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
detail=detail, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减/设值现金(带审计)")
|
||||
def grant_user_cash(
|
||||
user_id: int,
|
||||
body: GrantCashRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
"""给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。
|
||||
主要用于让无现金用户直接测试提现。"""
|
||||
user = user_repo.get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
# set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护)
|
||||
if body.mode == "set":
|
||||
if body.amount_cents < 0:
|
||||
raise HTTPException(status_code=400, detail="目标现金值不能为负")
|
||||
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
|
||||
before = wallet_repo.get_or_create_account(
|
||||
db, user_id, commit=False, lock=True
|
||||
).cash_balance_cents
|
||||
delta = body.amount_cents - before
|
||||
if delta == 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整"
|
||||
)
|
||||
else:
|
||||
if body.amount_cents == 0:
|
||||
raise HTTPException(status_code=400, detail="amount_cents 不能为 0")
|
||||
delta = body.amount_cents
|
||||
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
|
||||
if delta < 0:
|
||||
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
|
||||
if acc_now.cash_balance_cents + delta < 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
|
||||
)
|
||||
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
|
||||
# grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
|
||||
acc, _ = wallet_repo.grant_cash(
|
||||
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
|
||||
)
|
||||
detail = {
|
||||
"amount_cents": delta,
|
||||
"balance_after_cents": acc.cash_balance_cents,
|
||||
"reason": body.reason,
|
||||
}
|
||||
if body.mode == "set":
|
||||
detail.update({"mode": "set", "target_cents": body.amount_cents, "before_cents": before})
|
||||
write_audit(
|
||||
db, admin, action="user.cash.grant", target_type="user", target_id=user_id,
|
||||
detail=detail, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
@@ -14,9 +15,26 @@ 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 queries
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.admin.schemas.wallet import ReconcileResult, WithdrawOrderOut, WithdrawRejectRequest
|
||||
from app.admin.schemas.admin import AdminAuditLogOut
|
||||
from app.admin.schemas.wallet import (
|
||||
CashTxnOut,
|
||||
ReconcileResult,
|
||||
WithdrawBulkRejectRequest,
|
||||
WithdrawBulkRequest,
|
||||
WithdrawBulkResult,
|
||||
WithdrawBulkItemResult,
|
||||
WithdrawDetailOut,
|
||||
WithdrawLedgerCheckOut,
|
||||
WithdrawOrderOut,
|
||||
WithdrawRejectRequest,
|
||||
WithdrawSummaryOut,
|
||||
WithdrawUserSnapshot,
|
||||
WxpayHealthCheckOut,
|
||||
)
|
||||
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(
|
||||
@@ -31,14 +49,154 @@ def list_withdraws(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
keyword: Annotated[str | None, Query(max_length=100)] = None,
|
||||
date_from: Annotated[datetime | None, Query()] = None,
|
||||
date_to: Annotated[datetime | None, Query()] = None,
|
||||
date_field: Annotated[str, Query(pattern="^(created_at|updated_at)$")] = "created_at",
|
||||
sort_by: Annotated[
|
||||
str, Query(pattern="^(id|created_at|updated_at|amount_cents)$")
|
||||
] = "created_at",
|
||||
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
|
||||
quick_filter: Annotated[
|
||||
str | None,
|
||||
Query(pattern="^(abnormal|failed|overdue_reviewing|pending_overdue|today|high_risk)$"),
|
||||
] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[WithdrawOrderOut]:
|
||||
items, next_cursor = queries.list_all_withdraw_orders(
|
||||
db, user_id=user_id, status=status, limit=limit, cursor=cursor,
|
||||
items, next_cursor, total = queries.list_all_withdraw_orders(
|
||||
db,
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
keyword=keyword,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
date_field=date_field,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
quick_filter=quick_filter,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[WithdrawOrderOut.model_validate(o) for o in items], next_cursor=next_cursor,
|
||||
items=[WithdrawOrderOut.model_validate(o) for o in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
|
||||
def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut:
|
||||
return WithdrawSummaryOut(**queries.withdraw_summary(db))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health-check",
|
||||
response_model=WxpayHealthCheckOut,
|
||||
summary="提现配置健康检查",
|
||||
dependencies=[Depends(require_role("finance"))], # 暴露密钥路径/配置,限财务+super
|
||||
)
|
||||
def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut:
|
||||
private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001
|
||||
public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001
|
||||
issues: list[str] = []
|
||||
|
||||
private_loadable = False
|
||||
public_loadable = False
|
||||
try:
|
||||
wxpay._load_private_key() # noqa: SLF001
|
||||
private_loadable = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
issues.append(f"商户私钥不可用:{e}")
|
||||
try:
|
||||
wxpay._load_public_key() # noqa: SLF001
|
||||
public_loadable = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
issues.append(f"微信支付平台公钥不可用:{e}")
|
||||
|
||||
if not settings.wxpay_configured:
|
||||
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("自动对账运营开关已关闭(系统配置页可开)")
|
||||
|
||||
return WxpayHealthCheckOut(
|
||||
ok=not issues,
|
||||
wxpay_configured=settings.wxpay_configured,
|
||||
wxpay_auth_configured=settings.wxpay_auth_configured,
|
||||
private_key_path=str(private_path),
|
||||
private_key_exists=private_path.exists(),
|
||||
private_key_loadable=private_loadable,
|
||||
public_key_path=str(public_path),
|
||||
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_interval_sec=settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC,
|
||||
auto_reconcile_older_than_minutes=settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES,
|
||||
issues=issues,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ledger-check", response_model=WithdrawLedgerCheckOut, summary="提现资金账本校验")
|
||||
def withdraw_ledger_check(db: AdminDb) -> WithdrawLedgerCheckOut:
|
||||
return WithdrawLedgerCheckOut(**queries.withdraw_ledger_check(db))
|
||||
|
||||
|
||||
@router.get("/{out_bill_no}", response_model=WithdrawDetailOut, summary="提现单详情")
|
||||
def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
|
||||
order = queries.get_withdraw_by_out_bill_no(db, out_bill_no)
|
||||
if order is None:
|
||||
raise HTTPException(status_code=404, detail="提现单不存在")
|
||||
|
||||
overview = queries.get_user_overview(db, order.user_id)
|
||||
user_snapshot = None
|
||||
if overview is not None:
|
||||
user = overview["user"]
|
||||
user_snapshot = WithdrawUserSnapshot(
|
||||
id=user.id,
|
||||
phone=user.phone,
|
||||
nickname=user.nickname,
|
||||
status=user.status,
|
||||
wechat_nickname=user.wechat_nickname,
|
||||
wechat_avatar_url=user.wechat_avatar_url,
|
||||
created_at=user.created_at,
|
||||
last_login_at=user.last_login_at,
|
||||
cash_balance_cents=overview["cash_balance_cents"],
|
||||
withdraw_total=overview["withdraw_total"],
|
||||
withdraw_success_cents=overview["withdraw_success_cents"],
|
||||
)
|
||||
|
||||
recent_withdraws, _, _ = queries.list_all_withdraw_orders(
|
||||
db, user_id=order.user_id, limit=5, cursor=None,
|
||||
)
|
||||
recent_cash_transactions, _ = queries.list_all_cash_transactions(
|
||||
db, user_id=order.user_id, limit=8, cursor=None,
|
||||
)
|
||||
audit_logs = queries.list_withdraw_audit_logs(db, out_bill_no, limit=10)
|
||||
risk_flags, risk_score = queries.withdraw_risk_flags(
|
||||
order,
|
||||
overview["user"] if overview else None,
|
||||
recent_withdraws,
|
||||
overview["cash_balance_cents"] if overview else 0,
|
||||
)
|
||||
|
||||
return WithdrawDetailOut(
|
||||
order=WithdrawOrderOut.model_validate(order),
|
||||
user=user_snapshot,
|
||||
risk_flags=risk_flags,
|
||||
risk_score=risk_score,
|
||||
recent_withdraws=[WithdrawOrderOut.model_validate(o) for o in recent_withdraws],
|
||||
recent_cash_transactions=[CashTxnOut.model_validate(t) for t in recent_cash_transactions],
|
||||
audit_logs=[AdminAuditLogOut.model_validate(log) for log in audit_logs],
|
||||
)
|
||||
|
||||
|
||||
@@ -61,6 +219,159 @@ def reconcile(
|
||||
return ReconcileResult(**result)
|
||||
|
||||
|
||||
def _bulk_result(items: list[WithdrawBulkItemResult]) -> WithdrawBulkResult:
|
||||
success = sum(1 for item in items if item.ok)
|
||||
return WithdrawBulkResult(
|
||||
total=len(items),
|
||||
success=success,
|
||||
failed=len(items) - success,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk/refresh", response_model=WithdrawBulkResult, summary="批量刷新查单")
|
||||
def bulk_refresh_withdraws(
|
||||
body: WithdrawBulkRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
) -> WithdrawBulkResult:
|
||||
results: list[WithdrawBulkItemResult] = []
|
||||
ip = get_client_ip(request)
|
||||
for out_bill_no in body.out_bill_nos:
|
||||
try:
|
||||
order = queries.get_withdraw_by_out_bill_no(db, out_bill_no)
|
||||
if order is None:
|
||||
raise wallet_repo.WithdrawOrderNotFound
|
||||
refreshed = wallet_repo.refresh_withdraw_status(
|
||||
db, order.user_id, out_bill_no, cancel_if_unconfirmed=True,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="withdraw.refresh", target_type="withdraw",
|
||||
target_id=out_bill_no,
|
||||
detail={
|
||||
"status": refreshed.status,
|
||||
"wechat_state": refreshed.wechat_state,
|
||||
"fail_reason": refreshed.fail_reason,
|
||||
"bulk": True,
|
||||
},
|
||||
ip=ip, commit=True,
|
||||
)
|
||||
results.append(
|
||||
WithdrawBulkItemResult(
|
||||
out_bill_no=out_bill_no, ok=True, status=refreshed.status
|
||||
)
|
||||
)
|
||||
except wallet_repo.WithdrawOrderNotFound:
|
||||
db.rollback()
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="提现单不存在")
|
||||
)
|
||||
except wxpay.WxPayNotConfiguredError:
|
||||
db.rollback()
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="微信支付未配置")
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - 批量操作单笔失败不打断整批
|
||||
db.rollback()
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=f"系统异常: {e}")
|
||||
)
|
||||
return _bulk_result(results)
|
||||
|
||||
|
||||
@router.post("/bulk/approve", response_model=WithdrawBulkResult, summary="批量审核通过并打款")
|
||||
def bulk_approve_withdraws(
|
||||
body: WithdrawBulkRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
) -> WithdrawBulkResult:
|
||||
results: list[WithdrawBulkItemResult] = []
|
||||
ip = get_client_ip(request)
|
||||
for out_bill_no in body.out_bill_nos:
|
||||
try:
|
||||
order = wallet_repo.approve_withdraw(db, out_bill_no)
|
||||
write_audit(
|
||||
db, admin, action="withdraw.approve", target_type="withdraw",
|
||||
target_id=out_bill_no,
|
||||
detail={
|
||||
"status": order.status,
|
||||
"wechat_state": order.wechat_state,
|
||||
"amount_cents": order.amount_cents,
|
||||
"bulk": True,
|
||||
},
|
||||
ip=ip, commit=True,
|
||||
)
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=True, status=order.status)
|
||||
)
|
||||
except wallet_repo.WithdrawOrderNotFound:
|
||||
db.rollback()
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="提现单不存在")
|
||||
)
|
||||
except wallet_repo.WithdrawNotReviewable as e:
|
||||
db.rollback()
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=str(e))
|
||||
)
|
||||
except wxpay.WxPayNotConfiguredError:
|
||||
db.rollback()
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="微信支付未配置")
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - 批量操作单笔失败不打断整批
|
||||
db.rollback()
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=f"系统异常: {e}")
|
||||
)
|
||||
return _bulk_result(results)
|
||||
|
||||
|
||||
@router.post("/bulk/reject", response_model=WithdrawBulkResult, summary="批量审核拒绝并退款")
|
||||
def bulk_reject_withdraws(
|
||||
body: WithdrawBulkRejectRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
) -> WithdrawBulkResult:
|
||||
results: list[WithdrawBulkItemResult] = []
|
||||
ip = get_client_ip(request)
|
||||
for out_bill_no in body.out_bill_nos:
|
||||
try:
|
||||
order = wallet_repo.reject_withdraw(db, out_bill_no, body.reason)
|
||||
write_audit(
|
||||
db, admin, action="withdraw.reject", target_type="withdraw",
|
||||
target_id=out_bill_no,
|
||||
detail={
|
||||
"reason": body.reason,
|
||||
"amount_cents": order.amount_cents,
|
||||
"bulk": True,
|
||||
},
|
||||
ip=ip, commit=True,
|
||||
)
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=True, status=order.status)
|
||||
)
|
||||
except wallet_repo.WithdrawOrderNotFound:
|
||||
db.rollback()
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="提现单不存在")
|
||||
)
|
||||
except wallet_repo.WithdrawNotReviewable as e:
|
||||
db.rollback()
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=str(e))
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - 批量操作单笔失败不打断整批
|
||||
db.rollback()
|
||||
results.append(
|
||||
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=f"系统异常: {e}")
|
||||
)
|
||||
return _bulk_result(results)
|
||||
|
||||
|
||||
@router.post("/{out_bill_no}/refresh", response_model=WithdrawOrderOut, summary="单笔重试查单")
|
||||
def refresh_withdraw(
|
||||
out_bill_no: str,
|
||||
@@ -79,7 +390,11 @@ def refresh_withdraw(
|
||||
raise HTTPException(status_code=503, detail="微信支付未配置") from e
|
||||
write_audit(
|
||||
db, admin, action="withdraw.refresh", target_type="withdraw", target_id=out_bill_no,
|
||||
detail={"status": refreshed.status, "wechat_state": refreshed.wechat_state},
|
||||
detail={
|
||||
"status": refreshed.status,
|
||||
"wechat_state": refreshed.wechat_state,
|
||||
"fail_reason": refreshed.fail_reason,
|
||||
},
|
||||
ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return WithdrawOrderOut.model_validate(refreshed)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""看广告金币审计 schemas。
|
||||
|
||||
只读对账视图:把"看视频"(ad_reward_record)和"信息流"(ad_feed_reward_record)两类发奖记录,
|
||||
用与正式发奖相同的公式 [app.core.rewards.calculate_ad_reward_coin] 复算一遍 expected_coin,
|
||||
和实际入账的 actual_coin 对比,核对金币公式是否生效。字段 snake_case、金额按金币整数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AdCoinAuditRow(BaseModel):
|
||||
"""单条发奖记录的复算明细。"""
|
||||
|
||||
scene: str = Field(..., description="reward_video(看视频) / feed(信息流)")
|
||||
record_id: int = Field(..., description="对应记录表主键")
|
||||
user_id: int
|
||||
created_at: datetime
|
||||
status: str = Field(..., description="granted / capped / ecpm_missing")
|
||||
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示,SDK getEcpm 原值)")
|
||||
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档)")
|
||||
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="复算与实发是否一致(capped/ecpm_missing 校验是否确为 0)")
|
||||
|
||||
|
||||
class AdCoinFormulaOut(BaseModel):
|
||||
"""当前金币公式参数(给前端展示规则参照)。"""
|
||||
|
||||
description: str = Field(
|
||||
"eCPM元 = getEcpm分 ÷ 100;单份金币 = round(eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan);"
|
||||
"因子1 按 eCPM元 判档(阈值 100/200/400 元)",
|
||||
description="公式说明",
|
||||
)
|
||||
coin_per_yuan: int = Field(..., description="金币:元 汇率")
|
||||
ecpm_unit: str = Field("分/千次展示(SDK getEcpm 原值)", description="eCPM 口径")
|
||||
feed_unit_seconds: int = Field(..., description="信息流每多少秒折 1 份")
|
||||
# [(因子值, 区间下限, 区间上限或 null)]
|
||||
ecpm_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子1 档位表")
|
||||
lt_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子2 LT 档位表")
|
||||
|
||||
|
||||
class AdCoinAuditOut(BaseModel):
|
||||
"""审计响应:公式参照 + 全量统计 + 明细。"""
|
||||
|
||||
date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)")
|
||||
formula: AdCoinFormulaOut
|
||||
total: int = Field(..., description="该筛选下复算总条数(全量,不受 limit/only_mismatch 影响)")
|
||||
mismatch_count: int = Field(
|
||||
..., description="全量不一致条数(=0 说明公式全部生效;在截断前统计,可信)"
|
||||
)
|
||||
truncated: bool = Field(
|
||||
..., description="展示集是否被 limit 截断(true=还有未返回的明细,请缩小范围或调大 limit)"
|
||||
)
|
||||
items: list[AdCoinAuditRow] = Field(..., description="展示明细;only_mismatch=true 时只含 ✗ 行")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""admin 广告配置 schemas(穿山甲 app_id/各位ID/验签密钥/各场景开关)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AdConfigOut(BaseModel):
|
||||
"""admin 读到的完整广告配置(含 reward_mkey;admin 有权见,但绝不经 /platform 下发客户端)。"""
|
||||
|
||||
app_id: str
|
||||
reward_code_id: str
|
||||
compare_feed_code_id: str
|
||||
coupon_feed_code_id: str
|
||||
reward_mkey: str
|
||||
reward_enabled: bool
|
||||
compare_ad_enabled: bool
|
||||
coupon_ad_enabled: bool
|
||||
|
||||
|
||||
class AdConfigUpdate(BaseModel):
|
||||
"""部分更新:只改传入(非 None)字段。空串是合法值(如清空 mkey 回退 .env)。"""
|
||||
|
||||
app_id: str | None = None
|
||||
reward_code_id: str | None = None
|
||||
compare_feed_code_id: str | None = None
|
||||
coupon_feed_code_id: str | None = None
|
||||
reward_mkey: str | None = None
|
||||
reward_enabled: bool | None = None
|
||||
compare_ad_enabled: bool | None = None
|
||||
coupon_ad_enabled: bool | None = None
|
||||
@@ -0,0 +1,90 @@
|
||||
"""广告收益报表 schemas。
|
||||
|
||||
按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合的只读报表:展示条数、收益(元)、金币、来源。
|
||||
字段 snake_case;收益按元(float),金币按整数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AdRevenueImpression(BaseModel):
|
||||
"""聚合行下钻的单条**展示**明细(每次广告展示一条,展开该组时展示)。"""
|
||||
|
||||
id: int = Field(..., description="ad_ecpm_record 主键")
|
||||
created_at: datetime
|
||||
ecpm: str = Field(..., description="本次展示 eCPM 原始值(分/千次展示)")
|
||||
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000")
|
||||
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…)")
|
||||
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID)")
|
||||
|
||||
|
||||
class AdRevenueRecord(BaseModel):
|
||||
"""聚合行下钻的单条发奖复算明细(与金币审计同源,展开该组时展示)。"""
|
||||
|
||||
record_id: int
|
||||
created_at: datetime
|
||||
status: str = Field(..., description="granted / capped / ecpm_missing")
|
||||
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示)")
|
||||
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空")
|
||||
units: int = Field(..., description="折算份数:激励视频恒 1;信息流 = 满 10 秒份数")
|
||||
lt_index_start: int | None = Field(None, description="本条占用「账号累计第几份」的起")
|
||||
lt_index_end: int | None = Field(None, description="本条占用「账号累计第几份」的止;激励视频 = 起")
|
||||
lt_factor_start: float | None = Field(None, description="因子2(LT)起值")
|
||||
lt_factor_end: float | None = Field(None, description="因子2(LT)止值;激励视频 = 起")
|
||||
expected_coin: int = Field(..., description="按公式复算应发金币")
|
||||
actual_coin: int = Field(..., description="实际入账金币")
|
||||
matched: bool = Field(..., description="复算与实发是否一致")
|
||||
|
||||
|
||||
class AdRevenueDaily(BaseModel):
|
||||
"""按日期汇总的一天(供前端按天趋势图;全量,不受 limit 影响)。"""
|
||||
|
||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||
impressions: int = Field(..., description="当天展示条数合计")
|
||||
revenue_yuan: float = Field(..., description="当天预估收益合计(元)")
|
||||
expected_coin: int = Field(..., description="当天应发金币合计")
|
||||
actual_coin: int = Field(..., description="当天实发金币合计")
|
||||
|
||||
|
||||
class AdRevenueRow(BaseModel):
|
||||
"""一个聚合组(report_date × user × ad_type × app_env × our_code_id)的汇总。"""
|
||||
|
||||
report_date: str = Field(..., description="该组所属日期(北京时间 YYYY-MM-DD)")
|
||||
user_id: int
|
||||
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)")
|
||||
app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空")
|
||||
our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空")
|
||||
hour: int | None = Field(None, description="北京时间小时 0–23(granularity=hour 时有值;按天为 null)")
|
||||
impressions: int = Field(..., description="展示条数(每条广告展示一条;轮播每条各计一次)")
|
||||
revenue_yuan: float = Field(..., description="收益(元)= Σ(eCPM元 ÷ 1000);测试应用多为 0")
|
||||
expected_coin: int = Field(..., description="应发金币(按公式复算,与金币审计同源)")
|
||||
actual_coin: int = Field(..., description="实发金币(实际入账,按现发奖算法)")
|
||||
matched: bool = Field(..., description="该组应发==实发(组内任一条不符则 false)")
|
||||
adns: list[str] = Field(default_factory=list, description="实际填充的底层 ADN 子渠道集合(如 pangle/gdt)")
|
||||
impression_records: list[AdRevenueImpression] = Field(
|
||||
default_factory=list,
|
||||
description="该组逐条展示明细(时间/eCPM/收益/adn);展开下钻用,无发奖也有(只要有展示)",
|
||||
)
|
||||
records: list[AdRevenueRecord] = Field(
|
||||
default_factory=list,
|
||||
description="该组逐条发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);展开下钻用,纯展示无发奖记录的组为空",
|
||||
)
|
||||
|
||||
|
||||
class AdRevenueReportOut(BaseModel):
|
||||
"""报表响应:全量统计 + 按天趋势 + 聚合明细。"""
|
||||
|
||||
date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)")
|
||||
date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)")
|
||||
daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)")
|
||||
total: int = Field(..., description="聚合组总数(全量,不受 limit 影响)")
|
||||
truncated: bool = Field(..., description="明细是否被 limit 截断")
|
||||
total_impressions: int = Field(..., description="全量展示条数合计")
|
||||
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
|
||||
total_expected_coin: int = Field(..., description="全量应发金币合计")
|
||||
total_actual_coin: int = Field(..., description="全量实发金币合计")
|
||||
mismatch_count: int = Field(..., description="应发≠实发的组数(=0 说明全部按公式发放)")
|
||||
items: list[AdRevenueRow] = Field(..., description="聚合明细(按 用户→类型→代码位 排序)")
|
||||
@@ -9,10 +9,15 @@ T = TypeVar("T")
|
||||
|
||||
|
||||
class CursorPage(BaseModel, Generic[T]):
|
||||
"""游标分页响应:items + 下一页游标(next_cursor=None 表示末页)。"""
|
||||
"""分页响应:items + 下一页游标(next_cursor=None 表示末页)+ 可选 total。
|
||||
|
||||
next_cursor:offset 分页时即下一页 offset,「加载更多」用;末页为 None。
|
||||
total:符合筛选条件的总条数,页码分页(antd pagination)用;不需要总数的接口可不传(None)。
|
||||
"""
|
||||
|
||||
items: list[T]
|
||||
next_cursor: int | None = None
|
||||
total: int | None = None
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""admin 比价记录 debug 页 schema(独立于 C 端 app/schemas/compare_record:admin 看任意用户、
|
||||
无 debug_trace 权限闸、展示全量 debug 字段)。phone/nickname 由 queries 瞬态挂在 ORM 实例上。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AdminComparisonListItem(BaseModel):
|
||||
"""列表项:概要 + debug 维度概览(不含大字段 raw_payload/llm_calls/明细数组)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
phone: str | None = None # join User 瞬态(非 DB 列)
|
||||
nickname: str | None = None # join User 瞬态
|
||||
business_type: str
|
||||
trace_id: str
|
||||
# admin 是 debug 工具,无条件下发 trace_url(不看 user.debug_trace_enabled)
|
||||
trace_url: str | None = None
|
||||
status: str
|
||||
information: str | None = None
|
||||
store_name: str | None = None
|
||||
source_platform_name: str | None = None
|
||||
best_platform_name: str | None = None
|
||||
source_price_cents: int | None = None
|
||||
best_price_cents: int | None = None
|
||||
saved_amount_cents: int | None = None
|
||||
# debug 概览
|
||||
total_ms: int | None = None
|
||||
step_count: int | None = None
|
||||
llm_call_count: int | None = None
|
||||
retry_count: int | None = None
|
||||
device_model: str | None = None
|
||||
rom_vendor: str | None = None
|
||||
rom_name: str | None = None
|
||||
android_version: str | None = None
|
||||
app_version: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminComparisonDetail(AdminComparisonListItem):
|
||||
"""详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。"""
|
||||
|
||||
source_platform_id: str | None = None
|
||||
source_package: str | None = None
|
||||
best_platform_id: str | None = None
|
||||
best_deeplink: str | None = None
|
||||
is_source_best: bool | None = None
|
||||
total_dish_count: int | None = None
|
||||
skipped_dish_count: int | None = None
|
||||
device_id: str | None = None
|
||||
items: list = []
|
||||
comparison_results: list = [] # 逐平台对比(价格/rank/coupon/打烊...)
|
||||
skipped_dish_names: list = []
|
||||
# 全量环境
|
||||
device_manufacturer: str | None = None
|
||||
rom_version: int | None = None
|
||||
android_sdk: int | None = None
|
||||
app_version_code: int | None = None
|
||||
source_app_version: str | None = None
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
# 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}]
|
||||
llm_calls: list | None = None
|
||||
# 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读
|
||||
# (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。
|
||||
raw_payload: dict | None = None
|
||||
@@ -10,7 +10,7 @@ class ConfigItemOut(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
group: str
|
||||
type: str # int / int_list / dict_str_int
|
||||
type: str # int / int_list / dict_str_int / bool
|
||||
help: str | None = None
|
||||
default: Any
|
||||
value: Any
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
|
||||
|
||||
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。
|
||||
对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ───────────── 群 ─────────────
|
||||
class CpsGroupOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
sid: str | None = None # 仅美团群有
|
||||
name: str
|
||||
platforms: list[str] = Field(default_factory=list)
|
||||
member_count: int | None = None
|
||||
status: str
|
||||
remark: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CpsGroupCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
# 该群发哪些平台,至少选一个
|
||||
platforms: list[str] = Field(min_length=1)
|
||||
# 仅当含 meituan 时需要(留空自动生成);纯淘宝/京东不填,后端置 None。
|
||||
sid: str | None = Field(default=None, max_length=64, pattern=r"^[A-Za-z0-9]+$")
|
||||
member_count: int | None = Field(default=None, ge=0)
|
||||
remark: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class CpsGroupUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
platforms: list[str] | None = None
|
||||
member_count: int | None = Field(default=None, ge=0)
|
||||
status: str | None = Field(default=None, pattern=r"^(active|archived)$")
|
||||
remark: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
# ───────────── 活动 ─────────────
|
||||
class CpsActivityOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
platform: str
|
||||
name: str
|
||||
act_id: str | None = None
|
||||
product_view_sign: str | None = None
|
||||
payload: str | None = None # 淘宝淘口令 / 京东链接
|
||||
image_url: str | None = None # 淘宝落地页图(绝对 URL)
|
||||
status: str
|
||||
remark: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CpsActivityCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
platform: str = Field(pattern=r"^(meituan|taobao|jd)$")
|
||||
# 美团:act_id 或 product_view_sign 二选一
|
||||
act_id: str | None = Field(default=None, max_length=64)
|
||||
product_view_sign: str | None = Field(default=None, max_length=128)
|
||||
# 淘宝:整段淘口令文本 / 京东:推广链接
|
||||
payload: str | None = Field(default=None, max_length=4096)
|
||||
# 淘宝落地页图(绝对/相对 URL;淘宝必填,由 router 校验)
|
||||
image_url: str | None = Field(default=None, max_length=512)
|
||||
remark: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class CpsActivityUpdate(BaseModel):
|
||||
"""编辑活动(PATCH):全字段可选,只更新传入的非 None 项;最终平台对应字段由 router 校验。"""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
platform: str | None = Field(default=None, pattern=r"^(meituan|taobao|jd)$")
|
||||
act_id: str | None = Field(default=None, max_length=64)
|
||||
product_view_sign: str | None = Field(default=None, max_length=128)
|
||||
payload: str | None = Field(default=None, max_length=4096)
|
||||
image_url: str | None = Field(default=None, max_length=512)
|
||||
remark: str | None = Field(default=None, max_length=256)
|
||||
status: str | None = Field(default=None, pattern=r"^(active|archived)$")
|
||||
|
||||
|
||||
# ───────────── 生成链接(批量) ─────────────
|
||||
class CpsReferralLinksRequest(BaseModel):
|
||||
group_id: int
|
||||
activity_ids: list[int] = Field(min_length=1)
|
||||
|
||||
|
||||
class CpsReferralLinkItem(BaseModel):
|
||||
activity_id: int
|
||||
activity_name: str
|
||||
platform: str
|
||||
redirect_url: str # 我们的落地页 /c/{code}(发群用)
|
||||
code: str
|
||||
|
||||
|
||||
class CpsReferralLinksOut(BaseModel):
|
||||
group_name: str
|
||||
results: list[CpsReferralLinkItem]
|
||||
|
||||
|
||||
# ───────────── 对账拉单 ─────────────
|
||||
class CpsReconcileResult(BaseModel):
|
||||
fetched: int
|
||||
inserted: int
|
||||
updated: int
|
||||
pages: int
|
||||
|
||||
|
||||
# ───────────── 订单明细 ─────────────
|
||||
class CpsOrderOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
order_id: str
|
||||
sid: str | None = None
|
||||
act_id: str | None = None
|
||||
pay_price_cents: int | None = None
|
||||
commission_cents: int | None = None
|
||||
commission_rate: str | None = None
|
||||
mt_status: str | None = None
|
||||
invalid_reason: str | None = None
|
||||
product_name: str | None = None
|
||||
pay_time: datetime | None = None
|
||||
|
||||
|
||||
# ───────────── 统计 ─────────────
|
||||
class CpsGroupStat(BaseModel):
|
||||
group_id: int | None = None
|
||||
sid: str | None = None
|
||||
name: str
|
||||
platforms: list[str] = Field(default_factory=list)
|
||||
member_count: int | None = None
|
||||
click_pv: int = 0
|
||||
click_uv: int = 0
|
||||
copy_pv: int = 0 # 淘宝"复制口令"次数
|
||||
copy_uv: int = 0
|
||||
# 对账类:淘宝/京东无法对账 → None(前端显示 "-");美团群为数值
|
||||
order_count: int | None = None
|
||||
settled_count: int | None = None
|
||||
canceled_count: int | None = None
|
||||
gmv_cents: int | None = None
|
||||
est_commission_cents: int | None = None
|
||||
settled_commission_cents: int | None = None
|
||||
|
||||
|
||||
class CpsStatsOut(BaseModel):
|
||||
groups: list[CpsGroupStat]
|
||||
total_order_count: int
|
||||
total_est_commission_cents: int
|
||||
total_settled_commission_cents: int
|
||||
@@ -14,6 +14,14 @@ class DashboardUsers(BaseModel):
|
||||
|
||||
|
||||
class DashboardCoins(BaseModel):
|
||||
reward_video_coin_total: int = 0
|
||||
reward_video_watch_count: int = 0
|
||||
feed_ad_coin_total: int = 0
|
||||
feed_ad_watch_count: int = 0
|
||||
signin_coin_total: int = 0
|
||||
signin_count: int = 0
|
||||
signin_boost_coin_total: int = 0
|
||||
signin_boost_watch_count: int = 0
|
||||
granted_total: int
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""admin 设备维度新手引导管理 schemas。
|
||||
|
||||
force_onboarding(按用户)废弃后改用「按设备」:直接操作 onboarding_completion(设备+账号 维度),
|
||||
客户端任何版本都按它判断是否走引导,故可靠。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DeviceOnboardingItem(BaseModel):
|
||||
"""一台设备(ANDROID_ID)的引导聚合:在该设备走过引导的账号数 + 最近完成时间。"""
|
||||
|
||||
device_id: str
|
||||
account_count: int
|
||||
last_completed_at: datetime
|
||||
|
||||
|
||||
class ResetAllRequest(BaseModel):
|
||||
confirm: bool = Field(
|
||||
False, description="必须 true 才清空全部设备的引导记录(防误调接口;前端另有二次确认弹窗)"
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""admin 首页轮播种子 schemas。金额单位:分(前端展示 ÷100 取元)。
|
||||
|
||||
种子 = 生成规则:masked_user 可空(空→feed 随机合成名)、金额是 [min_cents, max_cents] 区间。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OpsMarqueeSeedOut(BaseModel):
|
||||
id: int
|
||||
masked_user: str | None = None
|
||||
min_cents: int
|
||||
max_cents: int
|
||||
enabled: bool
|
||||
sort_order: int
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class OpsMarqueeSeedCreate(BaseModel):
|
||||
masked_user: str | None = None # 空 / 不传 → feed 随机合成脱敏名
|
||||
min_cents: int
|
||||
max_cents: int
|
||||
enabled: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class OpsMarqueeSeedUpdate(BaseModel):
|
||||
# masked_user:不传=不改;传空串=清空(改回随机合成);传值=固定该名
|
||||
masked_user: str | None = None
|
||||
min_cents: int | None = None
|
||||
max_cents: int | None = None
|
||||
enabled: bool | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class OpsMarqueeSeedBulkCreate(BaseModel):
|
||||
"""批量生成 count 条自动合成名种子(冷启动铺量)。"""
|
||||
count: int
|
||||
min_cents: int
|
||||
max_cents: int
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class OpsMarqueeSeedBatchDelete(BaseModel):
|
||||
"""批量删除选中的种子。"""
|
||||
ids: list[int] = Field(..., max_length=500) # 上限防超大列表(审计行/IN 子句过大)
|
||||
|
||||
|
||||
class OpsMarqueeSeedBatchEnable(BaseModel):
|
||||
"""批量启用 / 停用选中的种子。"""
|
||||
ids: list[int] = Field(..., max_length=500)
|
||||
enabled: bool
|
||||
|
||||
|
||||
class OpsSavingsFeedPreviewItem(BaseModel):
|
||||
masked_user: str
|
||||
saved_amount_cents: int
|
||||
time: str
|
||||
|
||||
|
||||
class OpsSavingsFeedPreviewOut(BaseModel):
|
||||
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
|
||||
items: list[OpsSavingsFeedPreviewItem]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""admin 首页三统计展示配置 schemas。
|
||||
|
||||
倍率用千分比整数(1.000→1000);total_saved 的 manual_value / random_current 单位是分。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OpsStatItemOut(BaseModel):
|
||||
metric: str # help_users / total_compares / total_saved
|
||||
label: str # 帮助用户 / 完成比价 / 累计节省
|
||||
unit: str # 人 / 次 / 分
|
||||
mode: str # real / manual / random
|
||||
manual_value: int | None = None
|
||||
random_mult_min: int
|
||||
random_mult_max: int
|
||||
random_tick_seconds: int
|
||||
random_anchor_minutes: int
|
||||
random_kind: str # mult(×倍率) / add(+绝对增量)
|
||||
random_step_min: int # 绝对增量区间(基础单位;total_saved 为分)
|
||||
random_step_max: int
|
||||
real_offset: int # 真实值模式保底值(基础单位):展示=max(真实,保底)。列名沿用 real_offset
|
||||
allow_decrease: bool # 是否允许展示值下降(默认 false = 只增不减)
|
||||
random_current: int | None = None
|
||||
random_last_tick_at: str | None = None
|
||||
real_value: int # 当前真实值(纯真实、不含偏移;给运营对比参考)
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class OpsStatUpdateRequest(BaseModel):
|
||||
"""改某指标配置(均可选,只改传了的字段)。"""
|
||||
|
||||
mode: str | None = None
|
||||
manual_value: int | None = None
|
||||
random_mult_min: int | None = None
|
||||
random_mult_max: int | None = None
|
||||
random_tick_seconds: int | None = None
|
||||
random_anchor_minutes: int | None = None
|
||||
random_kind: str | None = None # mult / add
|
||||
random_step_min: int | None = None
|
||||
random_step_max: int | None = None
|
||||
real_offset: int | None = None
|
||||
allow_decrease: bool | None = None
|
||||
# 切 random 时的初始基数;不传则用当前真实值播种
|
||||
random_initial: int | None = None
|
||||
# 立即更新:不等更新钟点,保存后马上把展示值刷新一次(real 快照/manual 生效/random 走一档)
|
||||
apply_now: bool = False
|
||||
@@ -0,0 +1,54 @@
|
||||
"""admin 上报更低价审核 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class PriceReportOut(BaseModel):
|
||||
"""admin 列表项:price_report 全字段(审核要看的快照 + 截图 + 状态 + 奖励)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
comparison_record_id: int | None = None
|
||||
# 选中比价记录的快照
|
||||
store_name: str | None = None
|
||||
dish_summary: str | None = None
|
||||
original_platform_id: str | None = None
|
||||
original_platform_name: str | None = None
|
||||
original_price_cents: int | None = None
|
||||
# 用户上报的更低价
|
||||
reported_platform_id: str
|
||||
reported_platform_name: str
|
||||
reported_price_cents: int
|
||||
images: list[str] = []
|
||||
# 审核
|
||||
status: str
|
||||
reject_reason: str | None = None
|
||||
reward_coins: int | None = None
|
||||
reviewed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PriceReportRejectRequest(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到")
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def _reason_not_blank(cls, v: str) -> str:
|
||||
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计/用户端记录到空理由
|
||||
if not v.strip():
|
||||
raise ValueError("拒绝理由不能为空")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class PriceReportSummary(BaseModel):
|
||||
"""审核台顶部各状态计数。"""
|
||||
|
||||
pending: int
|
||||
approved: int
|
||||
rejected: int
|
||||
total: int
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class AdminUserListItem(BaseModel):
|
||||
@@ -15,6 +15,7 @@ class AdminUserListItem(BaseModel):
|
||||
nickname: str | None = None
|
||||
register_channel: str
|
||||
status: str
|
||||
debug_trace_enabled: bool = False
|
||||
wechat_openid: str | None = None
|
||||
created_at: datetime
|
||||
last_login_at: datetime
|
||||
@@ -36,12 +37,44 @@ class AdminUserOverview(BaseModel):
|
||||
feedback_total: int
|
||||
|
||||
|
||||
def _strip_reason(v: str) -> str:
|
||||
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因
|
||||
if not v.strip():
|
||||
raise ValueError("操作原因不能为空")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class GrantCoinsRequest(BaseModel):
|
||||
amount: int = Field(..., description="金币变动:正=增加,负=扣减(不可为 0)")
|
||||
mode: Literal["delta", "set"] = Field(
|
||||
"delta", description="delta=增减(amount 为变动量) / set=设为(amount 为目标值,须≥0)"
|
||||
)
|
||||
amount: int = Field(
|
||||
...,
|
||||
description="delta 模式:金币变动(正=增加,负=扣减,不可为 0);set 模式:目标金币值(须≥0)",
|
||||
)
|
||||
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
|
||||
|
||||
_v_reason = field_validator("reason")(_strip_reason)
|
||||
|
||||
|
||||
class GrantCashRequest(BaseModel):
|
||||
mode: Literal["delta", "set"] = Field(
|
||||
"delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)"
|
||||
)
|
||||
amount_cents: int = Field(
|
||||
...,
|
||||
description="delta 模式:现金变动(分,正=增加,负=扣减,不可为 0);set 模式:目标现金值(分,须≥0)",
|
||||
)
|
||||
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
|
||||
|
||||
_v_reason = field_validator("reason")(_strip_reason)
|
||||
|
||||
|
||||
class SetUserStatusRequest(BaseModel):
|
||||
status: Literal["active", "disabled"] = Field(
|
||||
..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)"
|
||||
)
|
||||
|
||||
|
||||
class SetDebugTraceRequest(BaseModel):
|
||||
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
|
||||
|
||||
@@ -5,6 +5,8 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.admin.schemas.admin import AdminAuditLogOut
|
||||
|
||||
|
||||
class CoinTxnOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -48,11 +50,99 @@ class WithdrawOrderOut(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WithdrawSummaryOut(BaseModel):
|
||||
reviewing_count: int
|
||||
reviewing_amount_cents: int
|
||||
pending_count: int
|
||||
failed_count: int
|
||||
today_success_count: int
|
||||
today_success_amount_cents: int
|
||||
today_rejected_count: int
|
||||
|
||||
|
||||
class WithdrawUserSnapshot(BaseModel):
|
||||
id: int
|
||||
phone: str
|
||||
nickname: str | None = None
|
||||
status: str
|
||||
wechat_nickname: str | None = None
|
||||
wechat_avatar_url: str | None = None
|
||||
created_at: datetime
|
||||
last_login_at: datetime
|
||||
cash_balance_cents: int
|
||||
withdraw_total: int
|
||||
withdraw_success_cents: int
|
||||
|
||||
|
||||
class WithdrawDetailOut(BaseModel):
|
||||
order: WithdrawOrderOut
|
||||
user: WithdrawUserSnapshot | None = None
|
||||
risk_flags: list[str]
|
||||
risk_score: int
|
||||
recent_withdraws: list[WithdrawOrderOut]
|
||||
recent_cash_transactions: list[CashTxnOut]
|
||||
audit_logs: list[AdminAuditLogOut]
|
||||
|
||||
|
||||
class ReconcileResult(BaseModel):
|
||||
checked: int
|
||||
resolved: int
|
||||
|
||||
|
||||
class WithdrawBulkRequest(BaseModel):
|
||||
out_bill_nos: list[str] = Field(
|
||||
..., min_length=1, max_length=50, description="提现商户单号列表"
|
||||
)
|
||||
|
||||
|
||||
class WithdrawBulkRejectRequest(WithdrawBulkRequest):
|
||||
reason: str = Field(
|
||||
..., min_length=1, max_length=200, description="批量拒绝理由(用户可见)"
|
||||
)
|
||||
|
||||
|
||||
class WithdrawBulkItemResult(BaseModel):
|
||||
out_bill_no: str
|
||||
ok: bool
|
||||
status: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class WithdrawBulkResult(BaseModel):
|
||||
total: int
|
||||
success: int
|
||||
failed: int
|
||||
items: list[WithdrawBulkItemResult]
|
||||
|
||||
|
||||
class WithdrawLedgerCheckOut(BaseModel):
|
||||
ok: bool
|
||||
cash_balance_total_cents: int
|
||||
cash_transaction_total_cents: int
|
||||
balance_diff_cents: int
|
||||
missing_withdraw_txn_count: int
|
||||
missing_refund_txn_count: int
|
||||
duplicate_refund_txn_count: int
|
||||
refund_txn_on_non_terminal_count: int
|
||||
|
||||
|
||||
class WxpayHealthCheckOut(BaseModel):
|
||||
ok: bool
|
||||
wxpay_configured: bool
|
||||
wxpay_auth_configured: bool
|
||||
private_key_path: str
|
||||
private_key_exists: bool
|
||||
private_key_loadable: bool
|
||||
public_key_path: str
|
||||
public_key_exists: bool
|
||||
public_key_loadable: bool
|
||||
auth_notify_url_configured: bool
|
||||
auto_reconcile_enabled: bool
|
||||
auto_reconcile_interval_sec: int
|
||||
auto_reconcile_older_than_minutes: int
|
||||
issues: list[str]
|
||||
|
||||
|
||||
class WithdrawRejectRequest(BaseModel):
|
||||
reason: str = Field(
|
||||
..., min_length=1, max_length=200, description="拒绝理由(写入 fail_reason,用户可见)"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""内部(server→server)端点。不对客户端开放,靠共享密钥头校验,不走用户 JWT。"""
|
||||
@@ -0,0 +1,58 @@
|
||||
"""最新 App 版本写入端点(发布流程 → app-server)。
|
||||
|
||||
发车流程出 APK 后,把版本号 / 下载链接 / sha256 等 POST 到这里,落 app_config(key=latest_app_version)。
|
||||
客户端再 GET /api/v1/platform/app-version 读取做 OTA 检查更新。**不是给客户端的接口**:
|
||||
不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。
|
||||
密钥未配置(默认空)时直接 503,避免裸奔的写端点。也是应急改版本信息(紧急下线/改 apk_url)的入口。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.config import settings
|
||||
from app.repositories import app_config
|
||||
from app.schemas.platform import AppVersionOut, AppVersionWriteIn
|
||||
|
||||
logger = logging.getLogger("shagua.internal.app_version")
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
def _check_secret(x_internal_secret: str | None) -> None:
|
||||
"""共享密钥校验。未配置 → 503(挡住裸奔写端点);不匹配 → 401(常量时间比较)。"""
|
||||
configured = settings.INTERNAL_API_SECRET
|
||||
if not configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal api not configured",
|
||||
)
|
||||
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid internal secret",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/app-version",
|
||||
response_model=AppVersionOut,
|
||||
summary="写最新 App 版本(发布流程→app-server,落 app_config)",
|
||||
)
|
||||
def write_app_version(
|
||||
payload: AppVersionWriteIn,
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> AppVersionOut:
|
||||
_check_secret(x_internal_secret)
|
||||
data = payload.model_dump()
|
||||
app_config.set_app_version(db, data)
|
||||
logger.info(
|
||||
"app_version set code=%d name=%s url=%s",
|
||||
payload.latest_version_code, payload.latest_version_name, payload.apk_url,
|
||||
)
|
||||
return AppVersionOut(**data)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""启动确认窗兜底样本内部上报端点(pricebot → app-server)。
|
||||
|
||||
pricebot 的 launch_confirm_agent 每次靠 LLM 兜底放行一个"静态 PROFILES 没认出"的跨 App
|
||||
启动确认窗时,把完整样本 POST 到这里落库。**不是给客户端的接口**:不走用户 JWT,靠
|
||||
server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。密钥未配置
|
||||
(默认空)时直接 503,避免裸奔写端点。
|
||||
|
||||
研发定期人工把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.config import settings
|
||||
from app.repositories import launch_confirm_sample as repo
|
||||
from app.schemas.launch_confirm_sample import (
|
||||
LaunchConfirmSampleIn,
|
||||
LaunchConfirmSampleOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.internal.launch_confirm")
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
def _check_secret(x_internal_secret: str | None) -> None:
|
||||
"""共享密钥校验。未配置 → 503(挡裸奔写端点);不匹配 → 401(常量时间比较)。"""
|
||||
configured = settings.INTERNAL_API_SECRET
|
||||
if not configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal api not configured",
|
||||
)
|
||||
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid internal secret",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/launch-confirm-sample",
|
||||
response_model=LaunchConfirmSampleOut,
|
||||
summary="启动确认窗兜底样本上报(pricebot→app-server,落 launch_confirm_sample)",
|
||||
)
|
||||
def report_launch_confirm_sample(
|
||||
payload: LaunchConfirmSampleIn,
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> LaunchConfirmSampleOut:
|
||||
_check_secret(x_internal_secret)
|
||||
sid = repo.insert_sample(db, payload)
|
||||
logger.info(
|
||||
"launch_confirm_sample id=%d host=%s locale=%s success=%s trace=%s",
|
||||
sid, payload.host_package, payload.system_locale,
|
||||
payload.exec_success, payload.trace_id,
|
||||
)
|
||||
return LaunchConfirmSampleOut(id=sid)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""价格观测内部上报端点(pricebot → app-server)。
|
||||
|
||||
pricebot 在比价 done 帧把整批价格事实 POST 到这里落库。**不是给客户端的接口**:
|
||||
不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。
|
||||
密钥未配置(默认空)时直接 503,避免裸奔的写端点。
|
||||
|
||||
与不鉴权的比价透传(compare.py / coupon.py)无关:那两个是客户端→server 的透传壳,
|
||||
这个是 server→server 的内部写库。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.config import settings
|
||||
from app.repositories import price_observation as repo
|
||||
from app.schemas.price_observation import (
|
||||
PriceObservationBatchIn,
|
||||
PriceObservationBatchOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.internal.price")
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
def _check_secret(x_internal_secret: str | None) -> None:
|
||||
"""共享密钥校验。未配置 → 503(挡住裸奔写端点);不匹配 → 401(常量时间比较)。"""
|
||||
configured = settings.INTERNAL_API_SECRET
|
||||
if not configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal api not configured",
|
||||
)
|
||||
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid internal secret",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/price-observation",
|
||||
response_model=PriceObservationBatchOut,
|
||||
summary="价格观测内部上报(pricebot→app-server,落 price_observation)",
|
||||
)
|
||||
def report_price_observation(
|
||||
payload: PriceObservationBatchIn,
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> PriceObservationBatchOut:
|
||||
_check_secret(x_internal_secret)
|
||||
inserted, skipped = repo.insert_batch(db, payload)
|
||||
logger.info(
|
||||
"price_observation trace=%s inserted=%d skipped=%d device=%s user=%s",
|
||||
payload.trace_id, inserted, skipped,
|
||||
payload.source_device_id, payload.source_user_id,
|
||||
)
|
||||
return PriceObservationBatchOut(inserted=inserted, skipped=skipped)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""平台店铺映射内部上报端点(pricebot → app-server)。
|
||||
|
||||
pricebot 在淘宝比价拿到 shopId 后,把这一行跨平台店铺映射 POST 到这里落库。
|
||||
**不是给客户端的接口**:不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验
|
||||
(复用 price.py 的 _check_secret,与 price-observation 同一密钥)。
|
||||
|
||||
与 price.py 的 /internal/price-observation 平行:那个落价格事实,这个落店铺身份映射。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.api.internal.price import _check_secret
|
||||
from app.repositories import store_mapping as repo
|
||||
from app.schemas.store_mapping import (
|
||||
StoreMappingIn,
|
||||
StoreMappingInvalidateIn,
|
||||
StoreMappingInvalidateOut,
|
||||
StoreMappingOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.internal.store")
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/store-mapping/lookup",
|
||||
summary="比价前按源平台店名反查各目标平台已沉淀的店铺 id(命中→pricebot 直接 deeplink)",
|
||||
)
|
||||
def lookup_store_mapping(
|
||||
source_platform: str,
|
||||
name: str,
|
||||
db: DbSession,
|
||||
lat: float | None = None,
|
||||
lng: float | None = None,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> dict:
|
||||
_check_secret(x_internal_secret)
|
||||
result = repo.lookup_nearest(db, source_platform, name, lat, lng)
|
||||
if result:
|
||||
hits = ", ".join(
|
||||
f"{t}:row{v['row_id']}"
|
||||
f"{'(' + str(v['dist_km']) + 'km)' if 'dist_km' in v else ''}"
|
||||
f"→{v.get('deeplink') or '(无deeplink)'}"
|
||||
for t, v in result.items()
|
||||
)
|
||||
logger.info(
|
||||
"store_mapping lookup source=%s name=%r geo=(%s,%s) → 命中 %s",
|
||||
source_platform, name, lat, lng, hits,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"store_mapping lookup source=%s name=%r geo=(%s,%s) → MISS",
|
||||
source_platform, name, lat, lng,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/store-mapping",
|
||||
response_model=StoreMappingOut,
|
||||
summary="平台店铺映射内部上报(pricebot→app-server,落 store_mapping)",
|
||||
)
|
||||
def report_store_mapping(
|
||||
payload: StoreMappingIn,
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> StoreMappingOut:
|
||||
_check_secret(x_internal_secret)
|
||||
created, row_id = repo.upsert(db, payload)
|
||||
logger.info(
|
||||
"store_mapping trace=%s %s row_id=%s source=%s "
|
||||
"taobao=(%s,%s) jd=(%s,%s) device=%s user=%s",
|
||||
payload.trace_id, "新建" if created else "合并", row_id, payload.source_platform,
|
||||
payload.id_taobao, payload.name_taobao, payload.id_jd, payload.name_jd,
|
||||
payload.source_device_id, payload.source_user_id,
|
||||
)
|
||||
return StoreMappingOut(inserted=created, row_id=row_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/store-mapping/invalidate",
|
||||
response_model=StoreMappingInvalidateOut,
|
||||
summary="标记某平台 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报,lookup 不再返回)",
|
||||
)
|
||||
def invalidate_store_mapping(
|
||||
payload: StoreMappingInvalidateIn,
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> StoreMappingInvalidateOut:
|
||||
_check_secret(x_internal_secret)
|
||||
if payload.platform == "taobao":
|
||||
affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id)
|
||||
elif payload.platform == "jd":
|
||||
affected = repo.mark_jd_deeplink_invalid(db, payload.shop_id)
|
||||
else:
|
||||
# 当前只接淘宝/京东; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。
|
||||
logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform)
|
||||
return StoreMappingInvalidateOut(ok=True, affected=0)
|
||||
logger.info(
|
||||
"store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d 行",
|
||||
payload.platform, payload.shop_id, affected,
|
||||
)
|
||||
return StoreMappingInvalidateOut(ok=True, affected=affected)
|
||||
+240
-24
@@ -10,6 +10,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
@@ -20,13 +21,21 @@ from app.core.config import settings
|
||||
from app.integrations import pangle
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories import ad_feed_reward as crud_feed
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
from app.repositories import ad_watch as crud_watch
|
||||
from app.repositories import app_config
|
||||
from app.repositories import signin as crud_signin
|
||||
from app.schemas.ad import (
|
||||
AdRewardStatusOut,
|
||||
EcpmReportIn,
|
||||
EcpmReportOut,
|
||||
FeedRewardIn,
|
||||
FeedRewardOut,
|
||||
PangleCallbackOut,
|
||||
RewardNoShowIn,
|
||||
RewardNoShowOut,
|
||||
TestGrantIn,
|
||||
TestGrantOut,
|
||||
WatchReportIn,
|
||||
WatchReportOut,
|
||||
@@ -41,6 +50,23 @@ REASON_OK = 0
|
||||
REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字
|
||||
REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
|
||||
|
||||
REWARD_SCENE_REWARD_VIDEO = "reward_video"
|
||||
REWARD_SCENE_SIGNIN_BOOST = "signin_boost"
|
||||
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST}
|
||||
|
||||
|
||||
def _parse_extra(raw_extra: str | None) -> dict[str, str]:
|
||||
"""解析客户端 setMediaExtra 透传的 JSON;旧格式/异常返回空 dict。"""
|
||||
if not raw_extra:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw_extra)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {str(k): str(v) for k, v in data.items() if v is not None}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pangle-callback",
|
||||
@@ -49,20 +75,28 @@ REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
|
||||
dependencies=[Depends(rate_limit(300, 60, "pangle-callback"))],
|
||||
)
|
||||
def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / reward_amount / sign 等 query 参数。
|
||||
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / ecpm / sign 等 query 参数。
|
||||
|
||||
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/reward_amount → 幂等发金币。
|
||||
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/场景/eCPM → 幂等发金币。
|
||||
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
|
||||
granted / capped → is_verify=true + reason=0。
|
||||
"""
|
||||
if not settings.pangle_callback_configured:
|
||||
if not settings.PANGLE_CALLBACK_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
|
||||
)
|
||||
|
||||
params = dict(request.query_params)
|
||||
|
||||
if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET):
|
||||
# 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。
|
||||
# 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。
|
||||
ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or ""
|
||||
secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets
|
||||
if not secrets:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
|
||||
)
|
||||
if not pangle.verify_callback_sign_any(params, secrets):
|
||||
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
|
||||
|
||||
@@ -74,19 +108,86 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
|
||||
|
||||
user_id = int(raw_user_id)
|
||||
coin = rewards.resolve_ad_reward_coin(db, params.get("reward_amount"))
|
||||
raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign")
|
||||
try:
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
db, user_id, trans_id, coin=coin,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
extra = {}
|
||||
for extra_key in ("extra", "gromoreExtra", "gromore_extra"):
|
||||
extra.update(_parse_extra(params.get(extra_key)))
|
||||
reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO
|
||||
ad_session_id = extra.get("ad_session_id")
|
||||
ecpm = params.get("ecpm")
|
||||
|
||||
existing = crud_ad.find_by_trans(db, trans_id)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"pangle callback idempotent user_id=%d trans_id=%s status=%s scene=%s",
|
||||
user_id, trans_id, existing.status, existing.reward_scene,
|
||||
)
|
||||
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
|
||||
|
||||
try:
|
||||
if reward_scene not in SUPPORTED_REWARD_SCENES:
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=0, reward_scene=reward_scene[:32],
|
||||
ad_session_id=ad_session_id, ecpm=ecpm,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
status="unknown_scene",
|
||||
)
|
||||
logger.warning(
|
||||
"pangle callback unknown scene user_id=%d trans_id=%s scene=%s",
|
||||
user_id, trans_id, reward_scene,
|
||||
)
|
||||
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
|
||||
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
|
||||
try:
|
||||
boost, _balance = crud_signin.boost_today_signin(
|
||||
db, user_id, ad_ref_id=trans_id, commit=False
|
||||
)
|
||||
except crud_signin.NotSignedTodayError:
|
||||
db.rollback()
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
|
||||
ad_session_id=ad_session_id, ecpm=ecpm,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
status="not_signed",
|
||||
)
|
||||
except crud_signin.AlreadyBoostedError:
|
||||
db.rollback()
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
|
||||
ad_session_id=ad_session_id, ecpm=ecpm,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
status="already_boosted",
|
||||
)
|
||||
except crud_signin.LastCycleDayBoostBlockedError:
|
||||
db.rollback()
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
|
||||
ad_session_id=ad_session_id, ecpm=ecpm,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
status="last_day",
|
||||
)
|
||||
else:
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=boost.coin_awarded,
|
||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm=ecpm,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
else:
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
|
||||
reward_scene=REWARD_SCENE_REWARD_VIDEO,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
)
|
||||
except crud_ad.UnknownUserError:
|
||||
logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
|
||||
return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)
|
||||
|
||||
logger.info(
|
||||
"ad reward user_id=%d trans_id=%s status=%s coin=%d", user_id, trans_id, rec.status, rec.coin
|
||||
"ad reward user_id=%d trans_id=%s scene=%s status=%s coin=%d",
|
||||
user_id, trans_id, rec.reward_scene, rec.status, rec.coin,
|
||||
)
|
||||
# granted / capped 均算"已处理":is_verify=true 不让穿山甲重试(capped 只是没加币)
|
||||
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
|
||||
@@ -112,15 +213,15 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
||||
@router.post(
|
||||
"/watch-report",
|
||||
response_model=WatchReportOut,
|
||||
summary="上报激励视频观看时长(每日 50 分钟防刷计时)",
|
||||
summary="上报激励视频观看时长(旧客户端兼容字段)",
|
||||
dependencies=[Depends(rate_limit(120, 60, "ad-watch-report"))],
|
||||
)
|
||||
def watch_report(payload: WatchReportIn, user: CurrentUser, db: DbSession) -> WatchReportOut:
|
||||
"""客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。
|
||||
|
||||
Bearer 鉴权,user_id 取自 JWT(不信 body)。seconds 服务端夹 [0, MAX_SINGLE_WATCH_SECONDS]。
|
||||
当日累计达 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟)后:① 本接口 / reward-status 返回 remaining=0,
|
||||
客户端不再展示广告;② 后端发奖(S2S 回调)也会因时长闸记 capped 不发金币(双闸,前端绕不过)。
|
||||
当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 表示时长闸不启用;该接口
|
||||
仍保留用于旧客户端兼容和排查观看时长。
|
||||
"""
|
||||
total = crud_watch.add_watch_seconds(db, user.id, payload.seconds)
|
||||
limit = rewards.DAILY_AD_WATCH_SECONDS_LIMIT
|
||||
@@ -150,11 +251,14 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
|
||||
crud_ecpm.create_ecpm_record(
|
||||
db, user.id,
|
||||
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 ecpm=%s adn=%s slot=%s",
|
||||
user.id, payload.ad_type, payload.ecpm, payload.adn, payload.slot_id,
|
||||
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
|
||||
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
|
||||
payload.app_env, payload.our_code_id,
|
||||
)
|
||||
return EcpmReportOut(ok=True)
|
||||
|
||||
@@ -165,7 +269,7 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
|
||||
summary="[仅本地联调]模拟穿山甲回调发奖",
|
||||
dependencies=[Depends(rate_limit(60, 60, "ad-test-grant"))],
|
||||
)
|
||||
def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
|
||||
def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = None) -> TestGrantOut:
|
||||
"""⚠️ 仅本地联调用:没部署公网、穿山甲 S2S 回调打不到本地时,客户端(debug 包)看完广告后
|
||||
调这个接口,直接走与回调相同的发奖逻辑(幂等 + 每日上限),验证"看广告→金币到账"全链路。
|
||||
|
||||
@@ -175,18 +279,66 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
|
||||
if not settings.AD_REWARD_TEST_GRANT_ENABLED:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
|
||||
|
||||
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限)
|
||||
reward_scene = (payload.reward_scene if payload is not None else REWARD_SCENE_REWARD_VIDEO)
|
||||
if reward_scene not in SUPPORTED_REWARD_SCENES:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene")
|
||||
|
||||
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限/今日膨胀一次)
|
||||
trans_id = f"test-{user.id}-{uuid.uuid4().hex}"
|
||||
try:
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
db, user.id, trans_id, reward_name="测试发奖", raw="client debug test-grant"
|
||||
)
|
||||
except crud_ad.UnknownUserError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
||||
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
|
||||
try:
|
||||
boost, _balance = crud_signin.boost_today_signin(
|
||||
db, user.id, ad_ref_id=trans_id, commit=False
|
||||
)
|
||||
except crud_signin.NotSignedTodayError:
|
||||
db.rollback()
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
|
||||
raw="client debug test-grant signin_boost", status="not_signed",
|
||||
)
|
||||
except crud_signin.AlreadyBoostedError:
|
||||
db.rollback()
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
|
||||
raw="client debug test-grant signin_boost", status="already_boosted",
|
||||
)
|
||||
except crud_signin.LastCycleDayBoostBlockedError:
|
||||
db.rollback()
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
|
||||
raw="client debug test-grant signin_boost", status="last_day",
|
||||
)
|
||||
else:
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user.id, trans_id, coin=boost.coin_awarded,
|
||||
reward_scene=reward_scene, reward_name="测试签到膨胀",
|
||||
raw="client debug test-grant signin_boost", commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
else:
|
||||
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
|
||||
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
|
||||
ad_session_id = payload.ad_session_id if payload is not None else None
|
||||
ecpm_val = "200"
|
||||
if ad_session_id:
|
||||
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
|
||||
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
|
||||
ecpm_val = ecpm_rec.ecpm_raw
|
||||
try:
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
|
||||
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
|
||||
)
|
||||
except crud_ad.UnknownUserError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
||||
|
||||
(used, limit, coin_per, round_count, cooldown_until,
|
||||
_watched, _watch_limit) = crud_ad.today_status(db, user.id)
|
||||
logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin)
|
||||
logger.info(
|
||||
"ad TEST grant user_id=%d scene=%s status=%s coin=%d",
|
||||
user.id, reward_scene, rec.status, rec.coin,
|
||||
)
|
||||
return TestGrantOut(
|
||||
granted=(rec.status == "granted"),
|
||||
status=rec.status,
|
||||
@@ -198,3 +350,67 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
|
||||
round_count=round_count,
|
||||
cooldown_until=cooldown_until,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/feed-reward",
|
||||
response_model=FeedRewardOut,
|
||||
summary="信息流广告完成后结算金币",
|
||||
dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))],
|
||||
)
|
||||
def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut:
|
||||
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。
|
||||
|
||||
当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发。
|
||||
"""
|
||||
rec = crud_feed.grant_feed_reward(
|
||||
db,
|
||||
user.id,
|
||||
client_event_id=payload.client_event_id,
|
||||
ecpm=payload.ecpm,
|
||||
duration_seconds=payload.duration_seconds,
|
||||
ad_session_id=payload.ad_session_id,
|
||||
adn=payload.adn,
|
||||
slot_id=payload.slot_id,
|
||||
app_env=payload.app_env,
|
||||
our_code_id=payload.our_code_id,
|
||||
aborted=payload.aborted,
|
||||
)
|
||||
logger.info(
|
||||
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
|
||||
user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin,
|
||||
)
|
||||
return FeedRewardOut(
|
||||
granted=(rec.status == "granted"),
|
||||
status=rec.status,
|
||||
coin=rec.coin,
|
||||
unit_count=rec.unit_count,
|
||||
daily_limit=rewards.get_ad_daily_limit(db),
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
+10
-6
@@ -19,6 +19,7 @@ from app.core.ratelimit import rate_limit
|
||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
||||
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
||||
from app.integrations.sms import SmsError, send_code, verify_code
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.schemas.auth import (
|
||||
JverifyLoginRequest,
|
||||
@@ -37,12 +38,13 @@ logger = logging.getLogger("shagua.auth")
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _login_response(db, user) -> TokenWithUser:
|
||||
"""登录类接口共用的响应组装。"""
|
||||
def _login_response(user, *, onboarding_completed: bool) -> TokenWithUser:
|
||||
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。"""
|
||||
tokens = issue_token_pair(user.id)
|
||||
return TokenWithUser(
|
||||
**tokens,
|
||||
user=UserOut.model_validate(user),
|
||||
onboarding_completed=onboarding_completed,
|
||||
)
|
||||
|
||||
|
||||
@@ -66,8 +68,9 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
|
||||
logger.info("jverify_login ok user_id=%d phone=%s", user.id, mask_phone(phone))
|
||||
return _login_response(db, user)
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("jverify_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(phone), completed)
|
||||
return _login_response(user, onboarding_completed=completed)
|
||||
|
||||
|
||||
# ===================== 短信登录 =====================
|
||||
@@ -103,8 +106,9 @@ def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
|
||||
logger.info("sms_login ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
|
||||
return _login_response(db, user)
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("sms_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(req.phone), completed)
|
||||
return _login_response(user, onboarding_completed=completed)
|
||||
|
||||
|
||||
# ===================== Refresh =====================
|
||||
|
||||
+37
-6
@@ -18,6 +18,7 @@ pricebot 协议文档:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -25,6 +26,7 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
|
||||
logger = logging.getLogger("shagua.compare")
|
||||
|
||||
@@ -38,25 +40,35 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
|
||||
打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s,
|
||||
比领券的 30s 长)。
|
||||
"""
|
||||
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server
|
||||
# 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时
|
||||
# 直接发原始 bytes(content=raw),不重新 dumps。
|
||||
raw = await request.body()
|
||||
try:
|
||||
body = await request.json()
|
||||
meta = json.loads(raw)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
|
||||
url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}{upstream_path}"
|
||||
# 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state)
|
||||
base = pick_pricebot(meta.get("trace_id"))
|
||||
url = f"{base.rstrip('/')}{upstream_path}"
|
||||
timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC
|
||||
|
||||
logger.info(
|
||||
"compare %s device_id=%s trace_id=%s step=%s",
|
||||
upstream_path,
|
||||
body.get("device_id"),
|
||||
body.get("trace_id"),
|
||||
body.get("step"),
|
||||
meta.get("device_id"),
|
||||
meta.get("trace_id"),
|
||||
meta.get("step"),
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(url, json=body)
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error("[pricebot] request failed: %s", e)
|
||||
raise HTTPException(
|
||||
@@ -90,6 +102,25 @@ async def intent_step(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/intent/step")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/intent/precoupon/step",
|
||||
summary="外卖比价 Phase 0 意图识别前先用券 (透传到 pricebot, 仅美团源)",
|
||||
)
|
||||
async def intent_precoupon_step(request: Request) -> dict[str, Any]:
|
||||
# 美团源平台『意图识别前先用券』多帧循环: 客户端在调 /intent/recognize 之前先循环
|
||||
# 调本端点到 done(continue=false)。订单页底部有『点击使用X红包』就自动选最大免费券
|
||||
# 用上, 已用券/无券则首帧秒过。与 /intent/step 同属 intent 域, 复用同一透传壳。
|
||||
return await _passthrough(request, "/api/intent/precoupon/step")
|
||||
|
||||
|
||||
@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,11 +15,15 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.repositories import comparison as crud_compare
|
||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||
from app.schemas.compare_record import (
|
||||
CompareStatsOut,
|
||||
ComparisonRecordCreatedOut,
|
||||
ComparisonRecordDetailOut,
|
||||
ComparisonRecordIn,
|
||||
@@ -38,11 +42,18 @@ router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
|
||||
summary="上报一次比价结果(幂等)",
|
||||
)
|
||||
def report_record(
|
||||
payload: ComparisonRecordIn, user: CurrentUser, db: DbSession
|
||||
payload: ComparisonRecordIn,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> ComparisonRecordCreatedOut:
|
||||
rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload)
|
||||
# LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台
|
||||
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
|
||||
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
|
||||
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
|
||||
logger.info(
|
||||
"compare record user=%s trace=%s biz=%s status=%s saved=%s",
|
||||
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
|
||||
user.id,
|
||||
rec.trace_id,
|
||||
rec.business_type,
|
||||
@@ -52,6 +63,38 @@ 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,
|
||||
summary="比价口径战绩(完成比价数 + 累计发现可省)",
|
||||
)
|
||||
def stats(user: CurrentUser, db: DbSession) -> CompareStatsOut:
|
||||
count, saved = crud_compare.get_stats(db, user.id)
|
||||
return CompareStatsOut(compare_count=count, discovered_saved_cents=saved)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/records",
|
||||
response_model=ComparisonRecordPage,
|
||||
@@ -62,14 +105,23 @@ 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
|
||||
)
|
||||
return ComparisonRecordPage(
|
||||
items=[ComparisonRecordOut.model_validate(it) for it in items],
|
||||
next_cursor=next_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):
|
||||
for o in outs:
|
||||
o.trace_url = None
|
||||
return ComparisonRecordPage(items=outs, next_cursor=next_cursor)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -83,4 +135,12 @@ def get_record(
|
||||
rec = crud_compare.get_record(db, user.id, record_id)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found")
|
||||
return ComparisonRecordDetailOut.model_validate(rec)
|
||||
out = ComparisonRecordDetailOut.model_validate(rec)
|
||||
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url。
|
||||
# ⚠️ raw_payload 是上报体全量(model_dump),里面也藏着一份 trace_url,必须一并抹掉——
|
||||
# 否则无权限用户从详情接口的 raw_payload 绕过权限闸拿到 trace_url。
|
||||
if not user.debug_trace_enabled:
|
||||
out.trace_url = None
|
||||
if isinstance(out.raw_payload, dict):
|
||||
out.raw_payload.pop("trace_url", None)
|
||||
return out
|
||||
|
||||
+218
-7
@@ -10,19 +10,86 @@ pricebot 协议文档:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
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.core.config import settings
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import coupon_state as coupon_repo
|
||||
from app.schemas.coupon_state import (
|
||||
CouponCompletedTodayOut,
|
||||
CouponPromptDismissIn,
|
||||
CouponPromptShouldShowOut,
|
||||
CouponPromptShownIn,
|
||||
CouponStatsOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.coupon")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/coupon", tags=["coupon"])
|
||||
|
||||
|
||||
def _to_int(v: object) -> int | None:
|
||||
"""user_id 协议是字符串(登录态才带),转 int 存库;缺失/非法 → None。"""
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return int(v) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_coupon_results(resp_json: dict) -> list[dict]:
|
||||
"""从 pricebot 响应抽本帧券结果,并**按 coupon_id 去重**。
|
||||
|
||||
⚠️ done/单券帧同时带 last_coupon_result(最后一张)+ action.params.coupon_results
|
||||
(全量含最后一张)→ 最后一张出现两次。必须去重:同批里同 coupon_id 两次,
|
||||
record_claims 在 autoflush=False 下两次 select 都查不到刚 add 的行 → 重复 add →
|
||||
commit 撞唯一约束 IntegrityError 回滚整批(done/单券记录全丢)。
|
||||
coupon_results(全量权威)覆盖 last_coupon_result。"""
|
||||
by_id: dict[str, dict] = {}
|
||||
last = resp_json.get("last_coupon_result")
|
||||
if isinstance(last, dict) and last.get("coupon_id"):
|
||||
by_id[last["coupon_id"]] = last
|
||||
params = (resp_json.get("action") or {}).get("params") or {}
|
||||
cr = params.get("coupon_results")
|
||||
if isinstance(cr, list):
|
||||
for x in cr:
|
||||
if isinstance(x, dict) and x.get("coupon_id"):
|
||||
by_id[x["coupon_id"]] = x
|
||||
return list(by_id.values())
|
||||
|
||||
|
||||
def _mark_engagement_blocking(
|
||||
device_id: str, package: str, user_id: int | None, engage_type: str
|
||||
) -> None:
|
||||
"""独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。"""
|
||||
with SessionLocal() as db:
|
||||
coupon_repo.mark_engagement(db, device_id, package, user_id, engage_type)
|
||||
|
||||
|
||||
def _record_claims_blocking(
|
||||
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
|
||||
) -> None:
|
||||
with SessionLocal() as db:
|
||||
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
|
||||
|
||||
|
||||
def _mark_completed_blocking(
|
||||
device_id: str, user_id: int | None, trace_id: str | None
|
||||
) -> None:
|
||||
"""独立 session 写"今日已跑完整轮"(到 done 帧那刻调,best-effort 不阻塞领券)。"""
|
||||
with SessionLocal() as db:
|
||||
coupon_repo.mark_completed_today(db, device_id, user_id, trace_id)
|
||||
|
||||
|
||||
@router.post("/step", summary="领券任务步进 (透传到 pricebot)")
|
||||
async def coupon_step(
|
||||
request: Request,
|
||||
@@ -33,24 +100,51 @@ async def coupon_step(
|
||||
- 透传: 不做 schema 校验,pricebot 自己校验
|
||||
- 失败: 网络不可达 / pricebot 5xx → 502 + 友好 message
|
||||
"""
|
||||
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省透传 CPU)。只 loads 一次拿
|
||||
# trace_id 做亲和 + 打日志,转发时直接发原始 bytes,不重新 dumps。
|
||||
raw = await request.body()
|
||||
try:
|
||||
body = await request.json()
|
||||
meta = json.loads(raw)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
|
||||
url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}/api/coupon/step"
|
||||
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")。写库失败绝不能
|
||||
# 连累领券主流程,整段吞掉。
|
||||
if device_id and meta.get("step") == 0:
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_mark_engagement_blocking, device_id, pkg, user_id, "claim_started"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon engagement write failed: %s", e)
|
||||
|
||||
# 按 trace_id 一致性 hash 选 pricebot 实例(同一领券任务的所有帧落同一进程)
|
||||
base = pick_pricebot(meta.get("trace_id"))
|
||||
url = f"{base.rstrip('/')}/api/coupon/step"
|
||||
timeout = settings.PRICEBOT_REQUEST_TIMEOUT_SEC
|
||||
|
||||
logger.info(
|
||||
"coupon_step device_id=%s trace_id=%s step=%s",
|
||||
body.get("device_id"),
|
||||
body.get("trace_id"),
|
||||
body.get("step"),
|
||||
meta.get("device_id"),
|
||||
meta.get("trace_id"),
|
||||
meta.get("step"),
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(url, json=body)
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error("[pricebot] request failed: %s", e)
|
||||
raise HTTPException(
|
||||
@@ -69,4 +163,121 @@ async def coupon_step(
|
||||
detail=f"pricebot upstream returned {resp.status_code}",
|
||||
)
|
||||
|
||||
return resp.json()
|
||||
resp_json = resp.json()
|
||||
|
||||
# 领券结果沉淀(资产):中间帧 last_coupon_result + done 帧 coupon_results 幂等写库。
|
||||
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
|
||||
if device_id:
|
||||
results = _extract_coupon_results(resp_json)
|
||||
if results:
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_record_claims_blocking, device_id, user_id, trace_id, results
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon claim write failed: %s", e)
|
||||
|
||||
# 整轮跑完(done 帧)→ 记一条"今日已完成",首页「去领取」卡据此置灰。
|
||||
# pricebot 把中途单券 done 改写成 wait+continue=true,只有整套全跑完那帧才保留
|
||||
# command=="done"(见 pricebot main.py),故 done 已等价"整轮完成"(用户决策 A 方案:
|
||||
# 到 done 即算,不管单券成败),无需再判 continue。写库失败绝不连累领券返回。
|
||||
action = resp_json.get("action") or {}
|
||||
if device_id and action.get("command") == "done":
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_mark_completed_blocking, device_id, user_id, trace_id
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon completion write failed: %s", e)
|
||||
|
||||
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** 不再弹。
|
||||
|
||||
server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。
|
||||
频控按 (device, package, 日),各 App 独立。MVP 不鉴权,按 device_id 记。
|
||||
"""
|
||||
coupon_repo.mark_engagement(
|
||||
db, payload.device_id, payload.package, payload.user_id, "dismissed"
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/prompt/should-show",
|
||||
response_model=CouponPromptShouldShowOut,
|
||||
summary="切到外卖 App 时是否还应弹领券引导窗",
|
||||
)
|
||||
def coupon_prompt_should_show(
|
||||
device_id: str, db: DbSession, package: str = ""
|
||||
) -> CouponPromptShouldShowOut:
|
||||
"""今天这台设备**这个 App** 已 engage(弹/领/拒)过 → should_show=false。各 App 独立:
|
||||
美团弹过不压淘宝/京东。客户端切到目标 App 时带 package 查(老客户端不带 → "" 全局态)。"""
|
||||
return CouponPromptShouldShowOut(
|
||||
should_show=not coupon_repo.has_engaged_today(db, device_id, package)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/prompt/reset", summary="重置今日领券引导窗 engagement(开发测频控用)")
|
||||
def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
|
||||
"""删这台设备今天的 engagement → has_engaged_today 变 false,今天又能弹。
|
||||
开发设置「重置今日领券弹窗状态」按钮调。MVP 不鉴权,按 device_id。"""
|
||||
coupon_repo.reset_today_engagement(db, payload.device_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/completed-today",
|
||||
response_model=CouponCompletedTodayOut,
|
||||
summary="这台设备今天是否已跑完整轮领券(到 done 帧)",
|
||||
)
|
||||
def coupon_completed_today(
|
||||
device_id: str, db: DbSession
|
||||
) -> CouponCompletedTodayOut:
|
||||
"""今天这台设备已跑完整轮(到 done)→ completed=true。客户端据此把首页「去领取」
|
||||
卡置灰、不可点(用户决策 A 方案:到 done 即算,不管单券成败)。判断维度 device_id
|
||||
必须与领券循环上报的一致(客户端两端都用 ANDROID_ID)。"""
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""CPS 群发短链落地:用户点 /c/{code} → 记点击 → 按平台 302 跳 或 返回淘宝落地页。
|
||||
|
||||
公网无鉴权(群里任何人点都要能跳/能领)。记点击失败绝不影响用户。
|
||||
- 美团/京东:记 visit + 302 跳 target(美团短链 / 京东链接)
|
||||
- 淘宝:记 visit + 返回 H5 落地页(整段淘口令复制按钮);点"复制口令"→ POST /c/{code}/copy 记 copy
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import media
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_db
|
||||
from app.integrations import wx_oauth
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_link import CpsLink
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
from app.repositories import cps_wx_user as cps_wx_user_repo
|
||||
|
||||
logger = logging.getLogger("shagua.cps_redirect")
|
||||
|
||||
router = APIRouter(tags=["cps-redirect"])
|
||||
|
||||
# code 不存在/失效时的兜底落地
|
||||
_FALLBACK_URL = "https://www.meituan.com/"
|
||||
|
||||
# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发)
|
||||
_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg"
|
||||
|
||||
# 微信网页授权拿到的用户标识 cookie(种在 coupon 域,30 天免重复授权)
|
||||
_WX_OPENID_COOKIE = "wx_openid"
|
||||
_WX_UINFO_COOKIE = "wx_uinfo" # "1" = 已拿过昵称头像(userinfo),点领券不再跳授权
|
||||
_COOKIE_MAX_AGE = 30 * 86400
|
||||
|
||||
|
||||
def _is_wechat(request: Request) -> bool:
|
||||
"""是否微信内置浏览器(网页授权只在微信内有意义,外部浏览器不跳授权)。"""
|
||||
return "micromessenger" in (request.headers.get("user-agent", "").lower())
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str | None:
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def _record(
|
||||
db: Session, link: CpsLink, request: Request, event_type: str, openid: str | None = None
|
||||
) -> None:
|
||||
try:
|
||||
cps_link_repo.record_click(
|
||||
db, link=link, ip=_client_ip(request),
|
||||
ua=request.headers.get("user-agent"), event_type=event_type, openid=openid,
|
||||
)
|
||||
except Exception:
|
||||
pass # 记点击失败不阻断
|
||||
|
||||
|
||||
@router.get("/MP_verify_F7wnRQ7xPbhVOWC8.txt", include_in_schema=False)
|
||||
def wx_mp_domain_verify() -> PlainTextResponse:
|
||||
"""微信「网页授权域名」归属校验文件。coupon.shaguabijia.com 全反代 app-server,
|
||||
微信请求 coupon.shaguabijia.com/MP_verify_xxx.txt 验证域名归属;内容=文件名核心串。
|
||||
放域名根目录(本端点即根路径),为微信网页授权(拿 openid)接入做准备。"""
|
||||
return PlainTextResponse("F7wnRQ7xPbhVOWC8")
|
||||
|
||||
|
||||
@router.get("/c/{code}", summary="群发短链落地(微信授权拿 openid + 记点击 + 跳转/淘宝落地页)")
|
||||
def cps_landing(code: str, request: Request, db: Session = Depends(get_db)):
|
||||
link = cps_link_repo.get_by_code(db, code)
|
||||
if link is None:
|
||||
return RedirectResponse(_FALLBACK_URL, status_code=302)
|
||||
openid = request.cookies.get(_WX_OPENID_COOKIE)
|
||||
# 微信内 + 还没拿到 openid + 配了服务号 → 先 base 静默授权拿 openid,再 302 回本页。
|
||||
# 非微信 / 已有 cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,绝不阻断领券)。
|
||||
if openid is None and settings.wx_oauth_active and _is_wechat(request):
|
||||
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
|
||||
auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_base", f"base:{code}")
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
_record(db, link, request, "visit", openid=openid)
|
||||
if link.platform == "taobao":
|
||||
activity = db.get(CpsActivity, link.activity_id)
|
||||
image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE
|
||||
has_uinfo = request.cookies.get(_WX_UINFO_COOKIE) == "1"
|
||||
return HTMLResponse(
|
||||
_taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo)
|
||||
)
|
||||
# 美团短链 / 京东链接:直接 302 跳
|
||||
return RedirectResponse(link.target_url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/wx/oauth/cb", include_in_schema=False)
|
||||
def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)):
|
||||
"""微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。
|
||||
换 openid(+userinfo)→ upsert 用户 → 种 cookie → 302 回落地页。任何失败兜底回落地页,
|
||||
绝不阻断用户领券。"""
|
||||
kind, _, orig_code = state.partition(":")
|
||||
try:
|
||||
token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...}
|
||||
openid = token["openid"]
|
||||
link = cps_link_repo.get_by_code(db, orig_code)
|
||||
group_id = link.group_id if link else None
|
||||
nickname = headimgurl = unionid = None
|
||||
if kind == "uinfo" and "userinfo" in (token.get("scope") or ""):
|
||||
info = wx_oauth.get_userinfo(token["access_token"], openid)
|
||||
nickname, headimgurl, unionid = (
|
||||
info.get("nickname"), info.get("headimgurl"), info.get("unionid"),
|
||||
)
|
||||
cps_wx_user_repo.upsert(
|
||||
db, openid=openid, code=orig_code, group_id=group_id,
|
||||
nickname=nickname, headimgurl=headimgurl, unionid=unionid,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[wx_oauth] callback failed state=%s", state)
|
||||
return RedirectResponse(f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302)
|
||||
# userinfo 授权回来带 ?authed=1,落地页据此自动触发复制(把"点领券→授权→复制"衔接为一步)
|
||||
target = f"/c/{orig_code}" + ("?authed=1" if kind == "uinfo" else "")
|
||||
resp = RedirectResponse(target, status_code=302)
|
||||
resp.set_cookie(_WX_OPENID_COOKIE, openid, max_age=_COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
||||
if nickname:
|
||||
resp.set_cookie(_WX_UINFO_COOKIE, "1", max_age=_COOKIE_MAX_AGE, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件,带 openid)")
|
||||
def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
link = cps_link_repo.get_by_code(db, code)
|
||||
if link is not None:
|
||||
_record(db, link, request, "copy", openid=request.cookies.get(_WX_OPENID_COOKIE))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _taobao_landing_html(
|
||||
token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
|
||||
) -> str:
|
||||
"""淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。
|
||||
|
||||
image_url 经 html.escape 嵌入 <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>"""
|
||||
@@ -1,7 +1,7 @@
|
||||
"""帮助与反馈 endpoint。
|
||||
|
||||
路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。
|
||||
POST / 提交反馈(multipart:content / contact 必填,images 可选 ≤4 张)
|
||||
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 ≤6 张)
|
||||
|
||||
截图复用 [app.core.media] 落盘到 /media/feedback/。
|
||||
"""
|
||||
@@ -20,8 +20,8 @@ logger = logging.getLogger("shagua.feedback")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
|
||||
|
||||
_MAX_IMAGES = 4
|
||||
_CONTENT_MAX = 2000
|
||||
_MAX_IMAGES = 6
|
||||
_CONTENT_MAX = 200
|
||||
_CONTACT_MAX = 128
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ async def submit_feedback(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
content: str = Form(...),
|
||||
contact: str = Form(...),
|
||||
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
|
||||
contact: str = Form(default=""),
|
||||
images: list[UploadFile] = File(default=[]),
|
||||
) -> FeedbackOut:
|
||||
content = content.strip()
|
||||
@@ -39,8 +40,6 @@ async def submit_feedback(
|
||||
raise HTTPException(status_code=400, detail="反馈内容不能为空")
|
||||
if len(content) > _CONTENT_MAX:
|
||||
raise HTTPException(status_code=400, detail="反馈内容过长")
|
||||
if not contact:
|
||||
raise HTTPException(status_code=400, detail="联系方式不能为空")
|
||||
if len(contact) > _CONTACT_MAX:
|
||||
raise HTTPException(status_code=400, detail="联系方式过长")
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""好友邀请 endpoint。
|
||||
|
||||
路由前缀 /api/v1/invite,需 Bearer 鉴权(用户级数据);唯一例外是 /landing-track
|
||||
(落地页 dl.html 浏览器访问、无 token,详见任务 3 [[invite-three-tasks]]):
|
||||
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
|
||||
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码;支持三种归因:
|
||||
① clipboard:首启读剪贴板拿邀请码 → 上报码
|
||||
② manual:用户在邀请页输入邀请码 → 上报码
|
||||
③ fingerprint:剪贴板没拿到时上报指纹,后端反查
|
||||
POST /landing-track 落地页 dl.html 访问时上报指纹(剪贴板兜底的服务端一端,无需鉴权)
|
||||
|
||||
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、发金币、指纹反查)在
|
||||
repositories/invite.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.repositories import invite as invite_repo
|
||||
from app.schemas.invite import (
|
||||
BindInviteIn,
|
||||
BindInviteOut,
|
||||
InviteeItem,
|
||||
InviteeListOut,
|
||||
InviteInfoOut,
|
||||
LandingTrackIn,
|
||||
LandingTrackOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.invite")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
|
||||
|
||||
_BIND_MESSAGES = {
|
||||
"success": "邀请绑定成功,金币已到账",
|
||||
"already_bound": "你已绑定过邀请人",
|
||||
"invalid_code": "邀请码无效",
|
||||
"self_invite": "不能填写自己的邀请码",
|
||||
"not_eligible": "邀请仅对新注册用户生效",
|
||||
"fp_not_found": "未匹配到邀请关系",
|
||||
}
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""从 HTTP 头拿真实 IP。nginx 反代时走 X-Forwarded-For;裸跑 uvicorn 走 request.client。"""
|
||||
xff = request.headers.get("x-forwarded-for", "")
|
||||
if xff:
|
||||
# X-Forwarded-For 可能是 "client, proxy1, proxy2" 链;取第一个 = 真实客户端
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else ""
|
||||
|
||||
|
||||
# Android UA 形如 '... ; <Model> Build/<id>) AppleWebKit/...';抓 ';' 后到 ' Build/' 前
|
||||
# 的 token = Build.MODEL(跨端跟客户端 android.os.Build.MODEL 对齐)。user-agents 库
|
||||
# 把 Android 设备归为 'Smartphone' 通用名,抓不到真实型号,只能正则。
|
||||
_ANDROID_BUILD_MODEL_RE = re.compile(r";\s*([^;]+?)\s+Build/", re.IGNORECASE)
|
||||
|
||||
|
||||
def _parse_device_model(ua: str) -> str:
|
||||
"""解析浏览器 UA 拿手机型号(如 '24115RA8EC'、'PJF110')。
|
||||
|
||||
Android:正则抓 UA 里 ';...Build/' 前的 token(== Build.MODEL),跨端可严格匹配。
|
||||
iOS / 解析不到:退到 user-agents 库的通用解析,失败/UA 空 → 返空字符串。
|
||||
"""
|
||||
if not ua:
|
||||
return ""
|
||||
m = _ANDROID_BUILD_MODEL_RE.search(ua)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
try:
|
||||
from user_agents import parse
|
||||
return (parse(ua).device.model or "").strip()
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
|
||||
@router.get("/me", response_model=InviteInfoOut, summary="我的邀请码 + 分享链接 + 战绩")
|
||||
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
|
||||
code = invite_repo.ensure_code(db, user)
|
||||
invited, coins = invite_repo.get_stats(db, user.id)
|
||||
sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
|
||||
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
|
||||
return InviteInfoOut(
|
||||
invite_code=code,
|
||||
share_url=share_url,
|
||||
invited_count=invited,
|
||||
coins_earned=coins,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/invitees", response_model=InviteeListOut, summary="我邀请的人列表(分页)")
|
||||
def my_invitees(
|
||||
user: CurrentUser, db: DbSession, limit: int = 20, offset: int = 0
|
||||
) -> InviteeListOut:
|
||||
"""邀请页小窗(取前几条) + 完整列表页(分页加载)共用。
|
||||
|
||||
名字/头像的降级兜底在 repositories/invite.get_invitees 算好,这里只组装响应。
|
||||
limit 夹到 [1,50] 防滥用;offset 不小于 0。
|
||||
"""
|
||||
limit = max(1, min(limit, 50))
|
||||
offset = max(0, offset)
|
||||
items, total, has_more = invite_repo.get_invitees(
|
||||
db, user.id, limit=limit, offset=offset,
|
||||
)
|
||||
return InviteeListOut(
|
||||
items=[InviteeItem(**it) for it in items],
|
||||
total=total,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/landing-track",
|
||||
response_model=LandingTrackOut,
|
||||
summary="落地页指纹采集(无需鉴权)",
|
||||
)
|
||||
def landing_track(
|
||||
req: LandingTrackIn, request: Request, db: DbSession
|
||||
) -> LandingTrackOut:
|
||||
"""B 浏览器打开 dl.html?ref=xxx 时上报指纹。
|
||||
|
||||
服务端从 HTTP 头拿 IP/UA、解析 UA 拿 device_model,跟 req.screen 一起入库。
|
||||
无需鉴权(浏览器没 token)。invalid_code / no_ip 走 silent 返回(不抛 5xx 影响下载流程)。
|
||||
"""
|
||||
inviter = invite_repo.resolve_inviter(db, req.ref)
|
||||
if inviter is None or inviter.status != "active":
|
||||
return LandingTrackOut(status="invalid_code")
|
||||
|
||||
ip = _client_ip(request)
|
||||
if not ip:
|
||||
return LandingTrackOut(status="no_ip")
|
||||
|
||||
ua_str = request.headers.get("user-agent", "")
|
||||
device_model = _parse_device_model(ua_str)
|
||||
|
||||
invite_repo.record_fingerprint(
|
||||
db,
|
||||
inviter_user_id=inviter.id,
|
||||
ip=ip,
|
||||
screen=req.screen,
|
||||
device_model=device_model,
|
||||
user_agent=ua_str,
|
||||
)
|
||||
logger.info(
|
||||
"invite landing-track inviter=%d ip=%s screen=%s model=%s",
|
||||
inviter.id, ip, req.screen, device_model,
|
||||
)
|
||||
return LandingTrackOut(status="ok")
|
||||
|
||||
|
||||
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
|
||||
def bind_invite(
|
||||
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
|
||||
) -> BindInviteOut:
|
||||
code = (req.invite_code or "").strip()
|
||||
|
||||
# 路径 1:有邀请码 → 走原路径(clipboard / manual)
|
||||
if code:
|
||||
result = invite_repo.bind(
|
||||
db, invitee=user, invite_code=code, channel=req.channel,
|
||||
)
|
||||
logger.info(
|
||||
"invite bind invitee=%d code=%s channel=%s -> %s",
|
||||
user.id, code, req.channel, result.status,
|
||||
)
|
||||
return BindInviteOut(
|
||||
status=result.status,
|
||||
coins_awarded=result.invitee_coin,
|
||||
message=_BIND_MESSAGES.get(result.status, result.status),
|
||||
)
|
||||
|
||||
# 路径 2:无邀请码 + 有指纹 → 指纹兜底反查
|
||||
if req.fingerprint is not None:
|
||||
ip = _client_ip(request)
|
||||
inviter = invite_repo.resolve_inviter_by_fingerprint(
|
||||
db,
|
||||
ip=ip,
|
||||
screen=req.fingerprint.screen,
|
||||
device_model=req.fingerprint.device_model,
|
||||
window_days=rewards.INVITE_FP_WINDOW_DAYS,
|
||||
)
|
||||
if inviter is None:
|
||||
logger.info(
|
||||
"invite bind invitee=%d fingerprint not_found ip=%s screen=%s model=%s",
|
||||
user.id, ip, req.fingerprint.screen, req.fingerprint.device_model,
|
||||
)
|
||||
return BindInviteOut(
|
||||
status="fp_not_found",
|
||||
coins_awarded=0,
|
||||
message=_BIND_MESSAGES["fp_not_found"],
|
||||
)
|
||||
# 用反查到的 inviter.invite_code 走原 bind 流程(走原幂等/自邀/新人闸/发币逻辑)
|
||||
result = invite_repo.bind(
|
||||
db, invitee=user, invite_code=inviter.invite_code, channel="fingerprint",
|
||||
)
|
||||
logger.info(
|
||||
"invite bind invitee=%d fingerprint -> inviter=%d code=%s -> %s",
|
||||
user.id, inviter.id, inviter.invite_code, result.status,
|
||||
)
|
||||
return BindInviteOut(
|
||||
status=result.status,
|
||||
coins_awarded=result.invitee_coin,
|
||||
message=_BIND_MESSAGES.get(result.status, result.status),
|
||||
)
|
||||
|
||||
# 路径 3:啥都没传 → 无效请求
|
||||
return BindInviteOut(
|
||||
status="invalid_code",
|
||||
coins_awarded=0,
|
||||
message=_BIND_MESSAGES["invalid_code"],
|
||||
)
|
||||
+184
-19
@@ -7,10 +7,14 @@ from __future__ import annotations
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import nullslast, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.core.config import settings
|
||||
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
|
||||
from app.db.session import get_db
|
||||
from app.integrations.meituan import MeituanCpsError, _call, get_referral_link, query_coupon
|
||||
from app.models.meituan_coupon import MeituanCoupon
|
||||
from app.schemas.meituan import (
|
||||
CouponCard,
|
||||
CouponListRequest,
|
||||
@@ -19,6 +23,7 @@ from app.schemas.meituan import (
|
||||
FeedResponse,
|
||||
ReferralLinkRequest,
|
||||
ReferralLinkResponse,
|
||||
TopSalesRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.meituan")
|
||||
@@ -29,7 +34,7 @@ router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
|
||||
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)")
|
||||
def list_coupons(req: CouponListRequest) -> CouponListResponse:
|
||||
if not settings.mt_cps_configured:
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None)
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id)
|
||||
try:
|
||||
raw = query_coupon(
|
||||
@@ -45,14 +50,21 @@ def list_coupons(req: CouponListRequest) -> CouponListResponse:
|
||||
page_size=req.page_size,
|
||||
)
|
||||
except MeituanCpsError as e:
|
||||
logger.error("query_coupon failed: %s", e)
|
||||
raise HTTPException(status_code=502, detail=f"meituan: {e}") from e
|
||||
# 软降级:不再抛 502(避免前端弹报错 toast),返空 + degraded,前端显示「服务繁忙」。
|
||||
logger.warning("[coupons] query_coupon 失败,降级返空: %s", e)
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
|
||||
items = [CouponCard.from_raw(it) for it in (raw.get("data") or [])]
|
||||
items: list[CouponCard] = []
|
||||
for it in (raw.get("data") or []):
|
||||
try:
|
||||
items.append(CouponCard.from_raw(it))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return CouponListResponse(
|
||||
items=items,
|
||||
has_next=raw.get("hasNext", False),
|
||||
search_id=raw.get("searchId"),
|
||||
status="ok" if items else "empty",
|
||||
)
|
||||
|
||||
|
||||
@@ -79,36 +91,145 @@ def _interleave(waimai: list[dict], daodian: list[dict]) -> list[CouponCard]:
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉, 无限流)")
|
||||
def feed(req: FeedRequest) -> FeedResponse:
|
||||
if not settings.mt_cps_configured:
|
||||
return FeedResponse(items=[], has_next=False, page=req.page)
|
||||
page_idx = req.page - 1
|
||||
lon, lat = req.longitude, req.latitude
|
||||
logger.info("[feed] page=%s lon=%.6f lat=%.6f", req.page, lon, lat)
|
||||
def _commission_pct(card: CouponCard) -> float:
|
||||
"""'1.4%' → 1.4;解析失败按 0(会被智能推荐过滤掉)。"""
|
||||
try:
|
||||
return float(card.commission_rate.rstrip("%"))
|
||||
except (ValueError, AttributeError):
|
||||
return 0.0
|
||||
|
||||
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]:
|
||||
|
||||
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近")
|
||||
def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||
lon, lat = req.longitude, req.latitude
|
||||
tab = (req.tab or "").strip()
|
||||
logger.info("[feed] tab=%s page=%s lon=%.6f lat=%.6f", tab or "(default)", req.page, lon, lat)
|
||||
|
||||
# rec 是纯离线库查询,不依赖 MT 凭证、不实时打美团;其余 tab(distance / 默认)要实时打美团,
|
||||
# 未配凭证直接降级返空(degraded),让前端区分「服务暂不可用」而非「暂无」。
|
||||
if tab != "rec" and not settings.mt_cps_configured:
|
||||
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
|
||||
|
||||
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> tuple[list[dict], bool]:
|
||||
"""返回(items, 是否调用失败)。失败标志用于把默认 feed 整页标 degraded。"""
|
||||
try:
|
||||
return (query_coupon(
|
||||
data = (query_coupon(
|
||||
longitude=lon, latitude=lat,
|
||||
platform=platform, biz_line=biz_line,
|
||||
list_topic_id=topic, page_size=20,
|
||||
).get("data") or [])
|
||||
return data, False
|
||||
except MeituanCpsError:
|
||||
return []
|
||||
return [], True
|
||||
|
||||
# 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。
|
||||
# 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。
|
||||
# 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)。
|
||||
if tab == "distance":
|
||||
lon_i, lat_i = int(lon * 1_000_000), int(lat * 1_000_000)
|
||||
|
||||
def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]:
|
||||
"""顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页, 是否调用失败)。"""
|
||||
sid: str | None = None
|
||||
data: list[dict] = []
|
||||
has_next = False
|
||||
for pg in range(1, n + 1):
|
||||
body: dict = {
|
||||
"platform": platform, "searchText": keyword, "sortField": 6,
|
||||
"longitude": lon_i, "latitude": lat_i, "pageSize": 20,
|
||||
}
|
||||
if biz_line is not None:
|
||||
body["bizLine"] = biz_line
|
||||
if sid:
|
||||
body["searchId"] = sid
|
||||
else:
|
||||
body["pageNo"] = 1
|
||||
try:
|
||||
r = _call("/cps_open/common/api/v1/query_coupon", body)
|
||||
except MeituanCpsError:
|
||||
return [], False, True # 调用失败(用于标 degraded)
|
||||
data = r.get("data") or []
|
||||
sid = r.get("searchId")
|
||||
has_next = bool(r.get("hasNext")) and bool(data)
|
||||
if not data or (not has_next and pg < n):
|
||||
return [], False, False # 没那么多页了(非错误)
|
||||
return data, has_next, False
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
f_wm = pool.submit(_search_page_n, 1, None, "外卖", req.page)
|
||||
f_dd = pool.submit(_search_page_n, 2, 1, "美食", req.page)
|
||||
(wm_data, wm_hn, wm_fail), (dd_data, dd_hn, dd_fail) = f_wm.result(), f_dd.result()
|
||||
|
||||
seen: set[str] = set()
|
||||
cards: list[CouponCard] = []
|
||||
for it in wm_data + dd_data:
|
||||
try:
|
||||
card = CouponCard.from_raw(it)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if card.product_view_sign and card.product_view_sign not in seen:
|
||||
seen.add(card.product_view_sign)
|
||||
cards.append(card)
|
||||
cards.sort(key=lambda c: c.distance_meters if c.distance_meters is not None else float("inf"))
|
||||
# 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty
|
||||
status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty")
|
||||
return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status)
|
||||
|
||||
# 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。
|
||||
# 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单"
|
||||
# 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。
|
||||
if tab == "rec":
|
||||
PAGE = 20
|
||||
try:
|
||||
base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0)
|
||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
||||
MeituanCoupon.dedup_key,
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
).subquery()
|
||||
m = aliased(MeituanCoupon, deduped)
|
||||
start = (req.page - 1) * PAGE
|
||||
rows = db.execute(
|
||||
select(m)
|
||||
# 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页
|
||||
.order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id)
|
||||
.offset(start)
|
||||
.limit(PAGE + 1)
|
||||
).scalars().all()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("[feed] rec 库查询失败,降级返空")
|
||||
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
|
||||
has_next = len(rows) > PAGE
|
||||
cards: list[CouponCard] = []
|
||||
for row in rows[:PAGE]:
|
||||
try:
|
||||
card = CouponCard.from_raw(row.raw or {})
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if card.product_view_sign:
|
||||
# 智能推荐不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。
|
||||
# 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。
|
||||
card.distance_text = None
|
||||
card.distance_meters = None
|
||||
cards.append(card)
|
||||
return FeedResponse(items=cards, has_next=has_next, page=req.page,
|
||||
status="ok" if cards else "empty")
|
||||
|
||||
# 默认(老客户端不传 tab):沿用逐轮分页的混合 feed,不筛。
|
||||
page_idx = req.page - 1
|
||||
if page_idx >= len(_TOPIC_ROUNDS):
|
||||
return FeedResponse(items=[], has_next=False, page=req.page)
|
||||
return FeedResponse(items=[], has_next=False, page=req.page, status="empty")
|
||||
|
||||
wm_topic, dd_topic = _TOPIC_ROUNDS[page_idx]
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
f_wm = pool.submit(_fetch_topic, 1, None, wm_topic)
|
||||
f_dd = pool.submit(_fetch_topic, 2, 1, dd_topic)
|
||||
waimai, daodian = f_wm.result(), f_dd.result()
|
||||
(waimai, wm_fail), (daodian, dd_fail) = f_wm.result(), f_dd.result()
|
||||
|
||||
items = _interleave(waimai, daodian)
|
||||
has_next = page_idx + 1 < len(_TOPIC_ROUNDS)
|
||||
return FeedResponse(items=items, has_next=has_next, page=req.page)
|
||||
# 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty
|
||||
status = "degraded" if (not items and wm_fail and dd_fail) else ("ok" if items else "empty")
|
||||
return FeedResponse(items=items, has_next=has_next, page=req.page, status=status)
|
||||
|
||||
|
||||
@router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)")
|
||||
@@ -130,3 +251,47 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
|
||||
link_map = raw.get("referralLinkMap") or {}
|
||||
link = raw.get("data") or link_map.get("1") or link_map.get("3") or next(iter(link_map.values()), "")
|
||||
return ReferralLinkResponse(link=link, link_map=link_map)
|
||||
|
||||
|
||||
@router.post("/top-sales", response_model=CouponListResponse,
|
||||
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)")
|
||||
def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse:
|
||||
# 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。
|
||||
# (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
|
||||
# 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。
|
||||
try:
|
||||
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
|
||||
base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
|
||||
if req.platform is not None:
|
||||
base = base.where(MeituanCoupon.platform == req.platform)
|
||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
||||
MeituanCoupon.dedup_key,
|
||||
MeituanCoupon.sale_volume_num.desc(),
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
).subquery()
|
||||
|
||||
# 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw
|
||||
m = aliased(MeituanCoupon, deduped)
|
||||
start = (req.page - 1) * req.page_size
|
||||
rows = db.execute(
|
||||
select(m)
|
||||
# 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项
|
||||
.order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id)
|
||||
.offset(start)
|
||||
.limit(req.page_size + 1)
|
||||
).scalars().all()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("[top-sales] 库查询失败,降级返空")
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
|
||||
has_next = len(rows) > req.page_size
|
||||
cards: list[CouponCard] = []
|
||||
for row in rows[:req.page_size]:
|
||||
try:
|
||||
card = CouponCard.from_raw(row.raw or {})
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if card.product_view_sign:
|
||||
cards.append(card)
|
||||
return CouponListResponse(items=cards, has_next=has_next, search_id=None,
|
||||
status="ok" if cards else "empty")
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""首页平台级展示数据 endpoint(全平台门面数字,**不鉴权**——登录前首页也要展示)。
|
||||
|
||||
路由前缀 `/api/v1/platform`:
|
||||
GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。
|
||||
GET /flags 客户端运营 feature flag(比价/领券期广告开关等),客户端拉取后缓存。
|
||||
|
||||
展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.repositories import 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,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.platform")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
|
||||
|
||||
|
||||
@router.get("/stats", response_model=PlatformStatsOut, summary="首页三统计(全平台门面数字)")
|
||||
def stats(db: DbSession) -> PlatformStatsOut:
|
||||
v = crud.get_display_values(db)
|
||||
return PlatformStatsOut(
|
||||
help_users=v["help_users"],
|
||||
total_compares=v["total_compares"],
|
||||
total_saved_cents=v["total_saved"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/savings-feed", response_model=SavingsFeedOut, summary="首页轮播 feed(真实+种子混播)")
|
||||
def savings_feed(db: DbSession, limit: int = Query(8, ge=1, le=30)) -> SavingsFeedOut:
|
||||
items = marquee_crud.get_feed(db, limit=limit)
|
||||
return SavingsFeedOut(items=[SavingsFeedItem(**it) for it in items])
|
||||
|
||||
|
||||
@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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user