Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 251e48a9cf | |||
| 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 |
@@ -79,6 +79,12 @@ 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 回调本服务发金币(客户端不参与发奖)。
|
||||
|
||||
@@ -43,3 +43,4 @@ secrets/*
|
||||
|
||||
# 运行日志(run.sh 输出, 不入库)
|
||||
*.log
|
||||
logs/
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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,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,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,6 +13,7 @@ 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.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
|
||||
@@ -20,7 +21,9 @@ from app.admin.routers.config import router as config_router
|
||||
from app.admin.routers.dashboard import router as dashboard_router
|
||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||
from app.admin.routers.feedback import router as feedback_router
|
||||
from app.admin.routers.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
|
||||
@@ -78,9 +81,12 @@ 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(ad_audit_router)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""看广告金币审计:复算 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,
|
||||
"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,
|
||||
"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,
|
||||
"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,
|
||||
"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 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: list[dict] = []
|
||||
if scene in (None, "reward_video"):
|
||||
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
|
||||
if scene in (None, "feed"):
|
||||
rows.extend(_feed_rows(db, date=date, user_id=user_id))
|
||||
rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True)
|
||||
|
||||
total = len(rows)
|
||||
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],
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,46 @@ 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 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 +166,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 +279,41 @@ 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]:
|
||||
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
|
||||
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
|
||||
数据变动可能错位一条——admin 低频场景可接受。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)
|
||||
stmt = stmt.order_by(order_fn(sort_col), id_order)
|
||||
|
||||
offset = max(cursor or 0, 0)
|
||||
rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all())
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
next_cursor = offset + limit if has_more else None
|
||||
return items, next_cursor
|
||||
|
||||
|
||||
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
|
||||
@@ -122,6 +323,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 +493,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"]],
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,8 +35,8 @@ def _validate(key: str, value: Any) -> None:
|
||||
raise ValueError("需为非空整数列表")
|
||||
if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value):
|
||||
raise ValueError("列表元素需为非负整数")
|
||||
if key == "signin_rewards" and len(value) != 14:
|
||||
raise ValueError("签到档位必须正好 14 个(对应 14 天循环)")
|
||||
if key == "signin_rewards" and len(value) != SIGNIN_CYCLE_LEN:
|
||||
raise ValueError(f"签到档位必须正好 {SIGNIN_CYCLE_LEN} 个(对应 {SIGNIN_CYCLE_LEN} 天循环)")
|
||||
elif t == "dict_str_int":
|
||||
if not isinstance(value, dict) or not all(
|
||||
isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool)
|
||||
|
||||
@@ -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,11 +26,25 @@ 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,
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
@@ -12,6 +12,8 @@ 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,
|
||||
@@ -111,6 +113,40 @@ def bulk_generate(
|
||||
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,
|
||||
|
||||
@@ -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,7 +15,23 @@ 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 wallet as wallet_repo
|
||||
@@ -31,14 +48,147 @@ 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() -> 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("免确认授权回调地址未配置")
|
||||
if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED:
|
||||
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=settings.WITHDRAW_AUTO_RECONCILE_ENABLED,
|
||||
auto_reconcile_interval_sec=settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC,
|
||||
auto_reconcile_older_than_minutes=settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES,
|
||||
issues=issues,
|
||||
)
|
||||
|
||||
|
||||
@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 +211,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 +382,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 时只含 ✗ 行")
|
||||
@@ -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):
|
||||
|
||||
@@ -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 才清空全部设备的引导记录(防误调接口;前端另有二次确认弹窗)"
|
||||
)
|
||||
@@ -4,7 +4,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OpsMarqueeSeedOut(BaseModel):
|
||||
@@ -42,6 +42,17 @@ class OpsMarqueeSeedBulkCreate(BaseModel):
|
||||
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
|
||||
|
||||
@@ -20,7 +20,7 @@ class OpsStatItemOut(BaseModel):
|
||||
random_kind: str # mult(×倍率) / add(+绝对增量)
|
||||
random_step_min: int # 绝对增量区间(基础单位;total_saved 为分)
|
||||
random_step_max: int
|
||||
real_offset: int # 真实值模式基数偏移(基础单位)
|
||||
real_offset: int # 真实值模式保底值(基础单位):展示=max(真实,保底)。列名沿用 real_offset
|
||||
allow_decrease: bool # 是否允许展示值下降(默认 false = 只增不减)
|
||||
random_current: int | None = None
|
||||
random_last_tick_at: str | None = None
|
||||
|
||||
@@ -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,107 @@
|
||||
"""平台店铺映射内部上报端点(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":
|
||||
# 当前只接淘宝; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。
|
||||
logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform)
|
||||
return StoreMappingInvalidateOut(ok=True, affected=0)
|
||||
affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id)
|
||||
logger.info(
|
||||
"store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d 行",
|
||||
payload.platform, payload.shop_id, affected,
|
||||
)
|
||||
return StoreMappingInvalidateOut(ok=True, affected=affected)
|
||||
+156
-19
@@ -10,6 +10,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
@@ -23,6 +24,7 @@ from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories import ad_feed_reward as crud_feed
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
from app.repositories import ad_watch as crud_watch
|
||||
from app.repositories import signin as crud_signin
|
||||
from app.schemas.ad import (
|
||||
AdRewardStatusOut,
|
||||
EcpmReportIn,
|
||||
@@ -30,6 +32,7 @@ from app.schemas.ad import (
|
||||
FeedRewardIn,
|
||||
FeedRewardOut,
|
||||
PangleCallbackOut,
|
||||
TestGrantIn,
|
||||
TestGrantOut,
|
||||
WatchReportIn,
|
||||
WatchReportOut,
|
||||
@@ -44,6 +47,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",
|
||||
@@ -52,9 +72,9 @@ 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。
|
||||
"""
|
||||
@@ -77,19 +97,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)
|
||||
@@ -153,11 +240,12 @@ 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,
|
||||
)
|
||||
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",
|
||||
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
|
||||
)
|
||||
return EcpmReportOut(ok=True)
|
||||
|
||||
@@ -168,7 +256,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 包)看完广告后
|
||||
调这个接口,直接走与回调相同的发奖逻辑(幂等 + 每日上限),验证"看广告→金币到账"全链路。
|
||||
|
||||
@@ -178,18 +266,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,
|
||||
@@ -220,6 +356,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
|
||||
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,
|
||||
)
|
||||
|
||||
+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 =====================
|
||||
|
||||
@@ -102,6 +102,17 @@ 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")
|
||||
|
||||
@@ -77,10 +77,12 @@ def list_records(
|
||||
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(列表页「复制调试链接」靠它)
|
||||
if not user.debug_trace_enabled:
|
||||
for o in outs:
|
||||
o.trace_url = None
|
||||
return ComparisonRecordPage(items=outs, next_cursor=next_cursor)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -94,4 +96,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
|
||||
|
||||
+93
-12
@@ -23,7 +23,12 @@ 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 CouponPromptDismissIn, CouponPromptShouldShowOut
|
||||
from app.schemas.coupon_state import (
|
||||
CouponCompletedTodayOut,
|
||||
CouponPromptDismissIn,
|
||||
CouponPromptShouldShowOut,
|
||||
CouponPromptShownIn,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.coupon")
|
||||
|
||||
@@ -62,11 +67,11 @@ def _extract_coupon_results(resp_json: dict) -> list[dict]:
|
||||
|
||||
|
||||
def _mark_engagement_blocking(
|
||||
device_id: str, user_id: int | None, engage_type: str
|
||||
device_id: str, package: str, user_id: int | None, engage_type: str
|
||||
) -> None:
|
||||
"""独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。"""
|
||||
with SessionLocal() as db:
|
||||
coupon_repo.mark_engagement(db, device_id, user_id, engage_type)
|
||||
coupon_repo.mark_engagement(db, device_id, package, user_id, engage_type)
|
||||
|
||||
|
||||
def _record_claims_blocking(
|
||||
@@ -76,6 +81,14 @@ def _record_claims_blocking(
|
||||
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,
|
||||
@@ -99,14 +112,17 @@ async def coupon_step(
|
||||
device_id = meta.get("device_id")
|
||||
user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用
|
||||
trace_id = meta.get("trace_id")
|
||||
# 发起领券时前台 App 包名(step body 带 "package")。频控按 App,这条 engagement 要记到
|
||||
# 对应 App 上。App 内「去领取」发起时 package 可能缺/为空 → 退化为 "" 占位(全局态)。
|
||||
pkg = meta.get("package") or ""
|
||||
|
||||
# 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started),
|
||||
# 今天这台设备不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
|
||||
# 今天**这个 App** 不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
|
||||
# 连累领券主流程,整段吞掉。
|
||||
if device_id and meta.get("step") == 0:
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_mark_engagement_blocking, device_id, user_id, "claim_started"
|
||||
_mark_engagement_blocking, device_id, pkg, user_id, "claim_started"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon engagement write failed: %s", e)
|
||||
@@ -160,17 +176,45 @@ async def coupon_step(
|
||||
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),今天不再弹。
|
||||
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天**这个 App** 不再弹。
|
||||
|
||||
server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。
|
||||
MVP 不鉴权,按 device_id 记。
|
||||
频控按 (device, package, 日),各 App 独立。MVP 不鉴权,按 device_id 记。
|
||||
"""
|
||||
coupon_repo.mark_engagement(db, payload.device_id, payload.user_id, "dismissed")
|
||||
coupon_repo.mark_engagement(
|
||||
db, payload.device_id, payload.package, payload.user_id, "dismissed"
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -180,10 +224,47 @@ def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict
|
||||
summary="切到外卖 App 时是否还应弹领券引导窗",
|
||||
)
|
||||
def coupon_prompt_should_show(
|
||||
device_id: str, db: DbSession
|
||||
device_id: str, db: DbSession, package: str = ""
|
||||
) -> CouponPromptShouldShowOut:
|
||||
"""今天这台设备已 engage(领或拒)过 → should_show=false。客户端据此决定弹不弹
|
||||
(前台 SP 缓存做快速路径,这里是权威)。"""
|
||||
"""今天这台设备**这个 App** 已 engage(弹/领/拒)过 → should_show=false。各 App 独立:
|
||||
美团弹过不压淘宝/京东。客户端切到目标 App 时带 package 查(老客户端不带 → "" 全局态)。"""
|
||||
return CouponPromptShouldShowOut(
|
||||
should_show=not coupon_repo.has_engaged_today(db, device_id)
|
||||
should_show=not coupon_repo.has_engaged_today(db, device_id, package)
|
||||
)
|
||||
|
||||
|
||||
@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}
|
||||
|
||||
@@ -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,7 +20,7 @@ logger = logging.getLogger("shagua.feedback")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
|
||||
|
||||
_MAX_IMAGES = 4
|
||||
_MAX_IMAGES = 6
|
||||
_CONTENT_MAX = 2000
|
||||
_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="联系方式过长")
|
||||
|
||||
|
||||
+172
-19
@@ -1,25 +1,37 @@
|
||||
"""好友邀请 endpoint。
|
||||
|
||||
路由前缀 /api/v1/invite,需 Bearer 鉴权(用户级数据):
|
||||
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
|
||||
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码,注册即生效,双方各发金币
|
||||
路由前缀 /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 访问时上报指纹(剪贴板兜底的服务端一端,无需鉴权)
|
||||
|
||||
客户端两种调用 /bind:
|
||||
- 自动:首启读剪贴板拿到邀请码 → 登录后调本接口(channel=clipboard)
|
||||
- 手动:用户在邀请页输入好友邀请码(channel=manual)
|
||||
|
||||
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、发金币)在 repositories/invite.py。
|
||||
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、发金币、指纹反查)在
|
||||
repositories/invite.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter
|
||||
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, InviteInfoOut
|
||||
from app.schemas.invite import (
|
||||
BindInviteIn,
|
||||
BindInviteOut,
|
||||
InviteeItem,
|
||||
InviteeListOut,
|
||||
InviteInfoOut,
|
||||
LandingTrackIn,
|
||||
LandingTrackOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.invite")
|
||||
|
||||
@@ -31,9 +43,43 @@ _BIND_MESSAGES = {
|
||||
"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)
|
||||
@@ -48,17 +94,124 @@ def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
|
||||
def bind_invite(req: BindInviteIn, user: CurrentUser, db: DbSession) -> BindInviteOut:
|
||||
result = invite_repo.bind(
|
||||
db, invitee=user, invite_code=req.invite_code, channel=req.channel,
|
||||
@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 bind invitee=%d code=%s channel=%s -> %s",
|
||||
user.id, req.invite_code, req.channel, result.status,
|
||||
"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=result.status,
|
||||
coins_awarded=result.invitee_coin,
|
||||
message=_BIND_MESSAGES.get(result.status, result.status),
|
||||
status="invalid_code",
|
||||
coins_awarded=0,
|
||||
message=_BIND_MESSAGES["invalid_code"],
|
||||
)
|
||||
|
||||
+148
-54
@@ -8,12 +8,12 @@ import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import nullslast, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_db
|
||||
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
|
||||
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,
|
||||
@@ -34,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(
|
||||
@@ -50,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",
|
||||
)
|
||||
|
||||
|
||||
@@ -93,59 +100,136 @@ def _commission_pct(card: CouponCard) -> float:
|
||||
|
||||
|
||||
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近")
|
||||
def feed(req: FeedRequest) -> FeedResponse:
|
||||
if not settings.mt_cps_configured:
|
||||
return FeedResponse(items=[], has_next=False, page=req.page)
|
||||
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)
|
||||
|
||||
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]:
|
||||
# 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":
|
||||
with ThreadPoolExecutor(max_workers=len(_TOPIC_ROUNDS) * 2) as pool:
|
||||
futs = []
|
||||
for wm_topic, dd_topic in _TOPIC_ROUNDS:
|
||||
futs.append(pool.submit(_fetch_topic, 1, None, wm_topic))
|
||||
futs.append(pool.submit(_fetch_topic, 2, 1, dd_topic))
|
||||
raws = [f.result() for f in futs]
|
||||
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 raw_list in raws:
|
||||
for it in raw_list:
|
||||
for it in wm_data + dd_data:
|
||||
try:
|
||||
card = CouponCard.from_raw(it)
|
||||
if card.product_view_sign and card.product_view_sign not in seen:
|
||||
seen.add(card.product_view_sign)
|
||||
cards.append(card)
|
||||
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"))
|
||||
return FeedResponse(items=cards, has_next=False, page=1)
|
||||
# 两路都失败且无数据 → 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,默认):沿用逐轮分页的混合 feed,后端过滤掉佣金率 < 3%。
|
||||
# 智能推荐(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)
|
||||
if tab == "rec":
|
||||
items = [c for c in items if _commission_pct(c) >= 3.0]
|
||||
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="换取推广链接(点抢时调)")
|
||||
@@ -172,32 +256,42 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
|
||||
@router.post("/top-sales", response_model=CouponListResponse,
|
||||
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)")
|
||||
def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse:
|
||||
# 只取有销量档的(美团很多券没销量,排不了);按销量降序、同销量再按佣金降序
|
||||
stmt = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
|
||||
if req.platform is not None:
|
||||
stmt = stmt.where(MeituanCoupon.platform == req.platform)
|
||||
stmt = stmt.order_by(
|
||||
MeituanCoupon.sale_volume_num.desc(),
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
)
|
||||
rows = db.execute(stmt).scalars().all()
|
||||
# 去重 + 排序 + 分页全在 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()
|
||||
|
||||
# 跨源去重(dedup_key = 品牌|名|价):按销量降序遍历,每个 dedup_key 只留第一条(=销量最高那条)。
|
||||
# 用 raw(整条原始返回)重建 CouponCard,字段与实时接口完全一致,前端无需改渲染。
|
||||
seen: set[str] = set()
|
||||
# 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:
|
||||
if row.dedup_key in seen:
|
||||
continue
|
||||
seen.add(row.dedup_key)
|
||||
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)
|
||||
|
||||
start = (req.page - 1) * req.page_size
|
||||
page_items = cards[start:start + req.page_size]
|
||||
has_next = start + req.page_size < len(cards)
|
||||
return CouponListResponse(items=page_items, has_next=has_next, search_id=None)
|
||||
return CouponListResponse(items=cards, has_next=has_next, search_id=None,
|
||||
status="ok" if cards else "empty")
|
||||
|
||||
+17
-8
@@ -12,7 +12,9 @@ import logging
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
from app.repositories import signin as crud_signin
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.schemas.welfare import (
|
||||
SigninBoostRequest,
|
||||
SigninBoostResultOut,
|
||||
@@ -54,14 +56,21 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut:
|
||||
def boost_signin(
|
||||
payload: SigninBoostRequest, user: CurrentUser, db: DbSession
|
||||
) -> SigninBoostResultOut:
|
||||
try:
|
||||
record, balance = crud_signin.boost_today_signin(
|
||||
db, user.id, ad_ref_id=payload.ad_ref_id
|
||||
)
|
||||
except crud_signin.NotSignedTodayError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="not signed today") from e
|
||||
except crud_signin.AlreadyBoostedError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="already boosted today") from e
|
||||
if not payload.ad_ref_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward required")
|
||||
ad_rec = crud_ad.find_by_trans(db, payload.ad_ref_id)
|
||||
if (
|
||||
ad_rec is None
|
||||
or ad_rec.user_id != user.id
|
||||
or ad_rec.reward_scene != "signin_boost"
|
||||
or ad_rec.status != "granted"
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward not verified")
|
||||
record = crud_signin.boost_by_ad_ref(db, user.id, payload.ad_ref_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="signin boost not granted")
|
||||
acc = crud_wallet.get_or_create_account(db, user.id)
|
||||
balance = acc.coin_balance
|
||||
|
||||
logger.info(
|
||||
"signin boost ok user_id=%d date=%s coin=%d",
|
||||
|
||||
+36
-2
@@ -12,13 +12,19 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import media
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.schemas.auth import UserOut
|
||||
from app.schemas.user import OkResponse, ProfileUpdateRequest
|
||||
from app.schemas.user import (
|
||||
OkResponse,
|
||||
OnboardingCompleteRequest,
|
||||
OnboardingStatusResponse,
|
||||
ProfileUpdateRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.user")
|
||||
|
||||
@@ -51,6 +57,34 @@ async def upload_avatar(
|
||||
return UserOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.post("/onboarding/complete", response_model=OkResponse, summary="标记新手引导完成(按 设备+账号)")
|
||||
def complete_onboarding(
|
||||
req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession
|
||||
) -> OkResponse:
|
||||
"""走完新手引导时调一次。按 (当前账号, device_id) 落一条完成标记,跨卸载重装持久。
|
||||
幂等:重复调用不报错。device_id 取客户端硬件级 ANDROID_ID,与登录请求一致。"""
|
||||
onboarding_repo.mark_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("onboarding complete user_id=%d device_len=%d", user.id, len(req.device_id))
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/onboarding/status",
|
||||
response_model=OnboardingStatusResponse,
|
||||
summary="查新手引导是否已完成(按 设备+账号)",
|
||||
)
|
||||
def onboarding_status(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
device_id: str = Query("", max_length=64, description="硬件级 ANDROID_ID;空=按未完成处理"),
|
||||
) -> OnboardingStatusResponse:
|
||||
"""已登录用户启动时查:该 (账号, 设备) 走过引导没。false → 客户端重走(运营在 admin 删了该设备
|
||||
记录即触发)。替代原 force_onboarding(按用户)——改设备维度后这是"运营触发重走"的查询入口。
|
||||
device_id 为空一律按未完成(同登录:不误跳过)。"""
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id)
|
||||
return OnboardingStatusResponse(completed=completed)
|
||||
|
||||
|
||||
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
|
||||
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
|
||||
media.delete_avatar(user.avatar_url)
|
||||
|
||||
+27
-6
@@ -35,6 +35,7 @@ from app.schemas.welfare import (
|
||||
ExchangeResultOut,
|
||||
TransferAuthResultOut,
|
||||
TransferAuthStatusOut,
|
||||
UnbindWechatRequest,
|
||||
UnbindWechatResultOut,
|
||||
WithdrawInfoOut,
|
||||
WithdrawOrderOut,
|
||||
@@ -151,9 +152,24 @@ def bind_wechat(req: BindWechatRequest, user: CurrentUser, db: DbSession) -> Bin
|
||||
|
||||
|
||||
@router.post("/unbind-wechat", response_model=UnbindWechatResultOut, summary="解绑微信(清空 openid)")
|
||||
def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut:
|
||||
def unbind_wechat(
|
||||
user: CurrentUser, db: DbSession, req: UnbindWechatRequest | None = None
|
||||
) -> UnbindWechatResultOut:
|
||||
# 有「待审核(reviewing)」提现单时,首次解绑拦下要求确认:这类单还没打款,确认解绑(force)
|
||||
# 时会被立即退回现金余额(refund_reviewing_withdraws_on_unbind),后台不再挂单,用户需先知情。
|
||||
# (pending 单转账已在途、解绑影响不到,不拦;见 has_reviewing_withdraw。)
|
||||
force = req.force if req is not None else False
|
||||
if not force and crud_wallet.has_reviewing_withdraw(db, user.id):
|
||||
logger.info("unbind wechat needs_confirm(有待审核提现) user_id=%d", user.id)
|
||||
return UnbindWechatResultOut(
|
||||
bound=bool(user.wechat_openid),
|
||||
needs_confirm=True,
|
||||
message="你有提现正在审核中,解绑微信后这笔会自动退回到现金余额。确定解绑吗?",
|
||||
)
|
||||
# 先把待审核提现立即退回现金(无则 no-op),再清 openid —— 让"解绑即退"名副其实
|
||||
refunded = crud_wallet.refund_reviewing_withdraws_on_unbind(db, user.id)
|
||||
crud_wallet.unbind_wechat_openid(db, user.id)
|
||||
logger.info("unbind wechat ok user_id=%d", user.id)
|
||||
logger.info("unbind wechat ok user_id=%d force=%s refunded_withdraws=%d", user.id, force, refunded)
|
||||
return UnbindWechatResultOut(bound=False)
|
||||
|
||||
|
||||
@@ -176,7 +192,7 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
"/withdraw",
|
||||
response_model=WithdrawResultOut,
|
||||
summary="发起提现(扣款建单,待人工审核;审核通过后才打款)",
|
||||
dependencies=[Depends(rate_limit(20, 60, "withdraw"))], # #6 同 IP 每分钟≤20 次
|
||||
dependencies=[Depends(rate_limit(5, 60, "withdraw"))], # IP 级粗限流;用户级未完成单限制在仓库层
|
||||
)
|
||||
def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut:
|
||||
# 提现发起本身不调微信(打款在审核通过后),但仍要求微信支付已配置——否则审核通过也打不了款,提前拦
|
||||
@@ -189,12 +205,17 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
||||
except crud_wallet.InvalidWithdrawAmountError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"amount_cents must be within [{WITHDRAW_MIN_CENTS}, {WITHDRAW_MAX_CENTS}]",
|
||||
detail="提现金额不符合要求",
|
||||
) from e
|
||||
except crud_wallet.WechatNotBoundError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先绑定微信") from e
|
||||
except crud_wallet.WithdrawTooFrequentError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="已有提现申请正在审核或打款中,请处理完成后再申请",
|
||||
) from e
|
||||
except crud_wallet.InsufficientCashError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient cash balance") from e
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e
|
||||
|
||||
# 调试直发(非生产):skip_review=true 时跳过人工审核,立即发起微信转账(等价 admin approve)。
|
||||
# 双闸保护——客户端仅 debug 包下发此 flag,服务端仅非 prod 才认;任一道闸拦住即恢复正常审核,
|
||||
|
||||
+36
-1
@@ -9,11 +9,16 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# 生产环境 JWT secret 的最小可接受长度(字节)。HS256 推荐高熵随机串;<16 视为弱密钥。
|
||||
_MIN_PROD_SECRET_LEN = 16
|
||||
# 已知的占位默认值(代码里写死的 default),prod 下绝不能沿用。
|
||||
_INSECURE_SECRET_DEFAULTS = frozenset({"change-me", "change-me-admin", ""})
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
@@ -102,6 +107,13 @@ class Settings(BaseSettings):
|
||||
WXPAY_PUBLIC_KEY_PATH: str = "./secrets/pub_key.pem" # 微信支付平台公钥
|
||||
WXPAY_TRANSFER_SCENE_ID: str = "1000" # 转账场景 ID(1000=现金营销)
|
||||
WXPAY_REQUEST_TIMEOUT_SEC: int = 10
|
||||
WITHDRAW_AUTO_RECONCILE_ENABLED: bool = False
|
||||
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC: int = 300
|
||||
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES: int = 15
|
||||
# 0 点自动兑金币(由 deploy/daily-exchange.timer 触发 scripts.daily_auto_exchange)。
|
||||
# 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停可在 .env 置 false,
|
||||
# 无需动 systemd timer——脚本读此开关,false 时直接 no-op 退出。
|
||||
AUTO_EXCHANGE_ENABLED: bool = True
|
||||
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
|
||||
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
|
||||
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
||||
@@ -191,6 +203,29 @@ class Settings(BaseSettings):
|
||||
def is_prod(self) -> bool:
|
||||
return self.APP_ENV == "prod"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _enforce_prod_secrets(self) -> "Settings":
|
||||
"""prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。
|
||||
|
||||
只校验两个签发凭证:App 用户的 JWT_SECRET_KEY、后台的 ADMIN_JWT_SECRET——它们沿用默认值
|
||||
时任何人都能伪造 access/admin token → 账号与后台失陷。INTERNAL_API_SECRET 默认空 = 内部端点
|
||||
关闭(返 503),是安全的默认态,故不在此强制。dev 不触发,便于本地直接起。
|
||||
"""
|
||||
if not self.is_prod:
|
||||
return self
|
||||
weak: list[str] = []
|
||||
for name in ("JWT_SECRET_KEY", "ADMIN_JWT_SECRET"):
|
||||
value = getattr(self, name)
|
||||
if value in _INSECURE_SECRET_DEFAULTS or len(value) < _MIN_PROD_SECRET_LEN:
|
||||
weak.append(name)
|
||||
if weak:
|
||||
raise ValueError(
|
||||
f"APP_ENV=prod 但检测到弱/默认密钥: {', '.join(weak)} —— 必须改成 "
|
||||
f"≥{_MIN_PROD_SECRET_LEN} 位高熵随机串(否则 JWT 可被伪造 → 用户/后台账号失陷)。"
|
||||
f"生成示例: python -c \"import secrets; print(secrets.token_urlsafe(48))\""
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -14,9 +14,9 @@ from app.core import rewards as r
|
||||
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int
|
||||
CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"signin_rewards": {
|
||||
"default": list(r.SIGNIN_REWARDS), "label": "签到 14 天金币档位",
|
||||
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
|
||||
"group": "签到", "type": "int_list",
|
||||
"help": "第 1~14 天每天签到发的金币;断签重置回第 1 天。长度需为 14。",
|
||||
"help": "第 1~7 天每天签到发的金币;断签重置回第 1 天。长度需为 7。",
|
||||
},
|
||||
"min_exchange_coin": {
|
||||
"default": r.MIN_EXCHANGE_COIN, "label": "最低兑换金币",
|
||||
@@ -42,7 +42,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"ad_reward_coin": {
|
||||
"default": r.AD_REWARD_COIN, "label": "看广告单次金币",
|
||||
"group": "看广告", "type": "int",
|
||||
"help": "看完一个激励视频发的金币;须与穿山甲后台代码位'奖励数量'保持一致。",
|
||||
"help": "历史兼容/测试展示值;正式发放按 eCPM 公式计算。",
|
||||
},
|
||||
"ad_daily_limit": {
|
||||
"default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)",
|
||||
@@ -50,7 +50,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"ad_max_coin": {
|
||||
"default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限",
|
||||
"group": "看广告", "type": "int", "help": "夹紧穿山甲回调里异常的 reward_amount,防刷爆。",
|
||||
"group": "看广告", "type": "int", "help": "历史 reward_amount 口径保留项;正式发放按 eCPM 公式计算。",
|
||||
},
|
||||
"ad_round_count": {
|
||||
"default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数",
|
||||
@@ -60,4 +60,9 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
|
||||
"group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
|
||||
},
|
||||
"signin_boost_coin": {
|
||||
"default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币",
|
||||
"group": "签到", "type": "int",
|
||||
"help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。",
|
||||
},
|
||||
}
|
||||
|
||||
+89
-24
@@ -17,11 +17,10 @@ def cn_today() -> date:
|
||||
return datetime.now(CN_TZ).date()
|
||||
|
||||
|
||||
# ===== 签到:14 天循环,断签重置回第 1 天 =====
|
||||
# 第 1→14 天的金币奖励,签到逻辑用 cycle_day(1..14)索引。
|
||||
# ===== 签到:7 天循环,断签重置回第 1 天(原型 2026-06 由 14 天改 7 天一轮)=====
|
||||
# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引;一轮 7 天累计 2500 金币(对原型 banner)。
|
||||
SIGNIN_REWARDS: tuple[int, ...] = (
|
||||
200, 120, 150, 180, 200, 250, 500,
|
||||
200, 250, 300, 350, 400, 500, 3000,
|
||||
200, 200, 300, 200, 400, 400, 800,
|
||||
)
|
||||
SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS)
|
||||
|
||||
@@ -57,13 +56,40 @@ WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
|
||||
TASK_ENABLE_NOTIFICATION = "enable_notification"
|
||||
|
||||
# task_key -> 奖励金币
|
||||
# 打开消息提醒: 1000 金币(=¥0.1, 客户端原型展示口径; 量级与签到/里程碑相称)。
|
||||
# 打开消息提醒: 750 金币(客户端原型展示口径 2026-06 调 1000→750; 量级与签到/里程碑相称)。
|
||||
# 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。
|
||||
# ⚠️打开消息提醒是「可重复领取」任务(2026-06):每次开启通知发一次,金额逐次减半(见 notification_reward),
|
||||
# 这里的 750 是首次(基数)。客户端只在「通知权限关闭」时展示该任务,开着就隐藏。
|
||||
TASK_REWARDS: dict[str, int] = {
|
||||
TASK_ENABLE_NOTIFICATION: 1000,
|
||||
TASK_ENABLE_NOTIFICATION: 750,
|
||||
}
|
||||
|
||||
|
||||
def notification_reward(base: int, times_claimed: int) -> int:
|
||||
"""打开消息提醒第 (times_claimed+1) 次领取的金币。
|
||||
|
||||
基数 base(=750)起,每次减半、四舍五入到整数、最低 1:
|
||||
750 → 375 → 188 → 94 → 47 → 24 → 12 → 6 → 3 → 2 → 1 → 1 …
|
||||
(四舍五入用 (x+1)//2,匹配产品给的 750/375/188/94 序列。)
|
||||
"""
|
||||
r = max(1, base)
|
||||
for _ in range(max(0, times_claimed)):
|
||||
r = max(1, (r + 1) // 2)
|
||||
return r
|
||||
|
||||
|
||||
def notification_max_claims(base: int) -> int:
|
||||
"""打开消息提醒最多可领取的次数:减半到最低 1 那次为止(含),之后不再可领。
|
||||
|
||||
防止「通知一直关着」时无限刷最低档 1 金币(发放上限闸,服务端硬限,不依赖客户端只在
|
||||
通知关闭时才展示的约定)。base=750 时序列 750→…→1 在第 11 次落到 1,故上限 11 次。
|
||||
"""
|
||||
n = 0
|
||||
while notification_reward(base, n) > 1:
|
||||
n += 1
|
||||
return n + 1 # 含第一次减半到 1 的那一次
|
||||
|
||||
|
||||
# ===== 比价战绩里程碑(累计成功比价 N 次,逐档解锁领金币)=====
|
||||
# 第 1→6 次的金币奖励(1-based:第 N 次比价解锁第 N 档)。值沿用客户端原型档位。
|
||||
# 数据源是 comparison_record 里 status='success' 的条数;每档领一次,
|
||||
@@ -77,6 +103,12 @@ def record_milestone_reward(milestone: int) -> int:
|
||||
return RECORD_MILESTONES[milestone - 1]
|
||||
|
||||
|
||||
# ===== 上报更低价(人工审核通过发固定金币)=====
|
||||
# 用户上报"某平台比我们算的最低价更便宜"+ 截图,经运营后台人工审核通过后发放的固定金币奖励。
|
||||
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
|
||||
PRICE_REPORT_REWARD_COINS: int = 1000
|
||||
|
||||
|
||||
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
||||
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
|
||||
INVITE_INVITER_COINS: int = 10000
|
||||
@@ -85,9 +117,18 @@ INVITE_INVITEE_COINS: int = 10000
|
||||
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
|
||||
INVITE_NEW_USER_WINDOW_HOURS: int = 72
|
||||
|
||||
# 指纹归因兜底(剪贴板被覆盖时):落地页 POST /landing-track 存的指纹记录,在此窗口内可被
|
||||
# /bind 反查撞库。窗口取舍:太短(24h)覆盖不到"晚上看链接、第二天装"的常见场景;太宽
|
||||
# (30d)IP/设备会漂、匹配错率上升。7 天兼顾"看广告→使用"周期与匹配精度。
|
||||
INVITE_FP_WINDOW_DAYS: int = 7
|
||||
|
||||
|
||||
# ===== 看激励视频 / 信息流广告发金币 =====
|
||||
# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。
|
||||
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
|
||||
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
|
||||
# 因子1 档位阈值按【元/千次】定(100/200/400 元 = ¥100/¥200/¥400 CPM,产品口径 2026-06-09)。
|
||||
# 注:真实 eCPM 一般 <¥100 CPM,故多落最低档 0.1,高档基本不触发——这是产品有意的取舍。
|
||||
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次)。
|
||||
AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
||||
(0.1, 0, 100),
|
||||
(0.3, 101, 200),
|
||||
@@ -102,9 +143,16 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
||||
(1.0, 11, None),
|
||||
)
|
||||
|
||||
# 客户端可影响的 eCPM 可信上限(分/千次展示):信息流广告一期由客户端上报 eCPM,伪造天价 eCPM
|
||||
# 可铸出天量金币(见 calculate_ad_reward_coin)。真实 eCPM 一般 <¥100 CPM(=10000 分),档位表顶档
|
||||
# 为 >¥400(=40000 分);取 ¥500 CPM=50000 分,留足真实头部余量又封死伪造值。钳在唯一计算口
|
||||
# calculate_ad_reward_coin,故 feed 与 reward_video(回退客户端上报 eCPM 时)一并护住;阈值设在所有
|
||||
# 真实值之上,不会少发正规奖励。
|
||||
AD_ECPM_MAX_FEN: int = 50_000
|
||||
|
||||
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||||
"""解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理。"""
|
||||
|
||||
def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
|
||||
"""解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。"""
|
||||
if ecpm is None:
|
||||
return 0.0
|
||||
try:
|
||||
@@ -114,8 +162,14 @@ def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||||
return max(0.0, value)
|
||||
|
||||
|
||||
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||||
"""eCPM 转成元(getEcpm 原值是分,÷100)。因子档位判定与收益换算都用元。"""
|
||||
return parse_ecpm_fen(ecpm) / 100.0
|
||||
|
||||
|
||||
def ad_ecpm_factor(ecpm_yuan: float) -> float:
|
||||
"""eCPM 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。"""
|
||||
"""eCPM 档位因子(阈值单位=元/千次):≤100=0.1,101-200=0.3,201-400=0.4,>400=0.6。
|
||||
产品口径(2026-06-09):阈值按元判档;真实 eCPM(<¥100 CPM)多落最低档 0.1。"""
|
||||
if ecpm_yuan > 400:
|
||||
return 0.6
|
||||
if ecpm_yuan > 200:
|
||||
@@ -125,33 +179,40 @@ def ad_ecpm_factor(ecpm_yuan: float) -> float:
|
||||
return 0.1
|
||||
|
||||
|
||||
def ad_lt_factor(today_count_after_this: int) -> float:
|
||||
"""LT 因子。today_count_after_this 是当天累计第 N 条/份广告奖励。"""
|
||||
count = max(1, today_count_after_this)
|
||||
def ad_lt_factor(count_after_this: int) -> float:
|
||||
"""LT 因子。count_after_this 是该账号累计第 N 条/份看视频奖励(不按天重置)。"""
|
||||
count = max(1, count_after_this)
|
||||
for factor, lo, hi in AD_LT_FACTOR_TABLE:
|
||||
if count >= lo and (hi is None or count <= hi):
|
||||
return factor
|
||||
return 1.0
|
||||
|
||||
|
||||
def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int:
|
||||
def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: int) -> int:
|
||||
"""按金币数值体系计算单份广告奖励金币。
|
||||
|
||||
单次奖励(元)=eCPM/1000 × 因子1(eCPM 档) × 因子2(LT);再按 1 元=10000 金币取整。
|
||||
eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。
|
||||
单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT);
|
||||
再按 1 元=10000 金币取整。count_after_this 为账号累计第 N 次看视频(LT 因子用,不按天重置)。
|
||||
|
||||
eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值
|
||||
会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
|
||||
所有真实值,不影响正规发奖。
|
||||
"""
|
||||
ecpm_yuan = parse_ecpm_yuan(ecpm)
|
||||
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this)
|
||||
ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
|
||||
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
|
||||
return max(0, round(yuan * COIN_PER_YUAN))
|
||||
|
||||
|
||||
# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。
|
||||
SIGNIN_BOOST_COIN: int = 3000
|
||||
|
||||
|
||||
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
|
||||
# 看完一个激励视频发的金币(666 金币 ≈¥0.0666,汇率 10000 金币=1 元)。
|
||||
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币";
|
||||
# ③ test-grant 本地联调的发奖额。
|
||||
# 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把
|
||||
# 代码位"奖励数量"配成与本值一致(=666),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
|
||||
# 历史固定金币口径保留项。正式激励视频实发按 calculate_ad_reward_coin(eCPM, 当日第 N 次)
|
||||
# 计算;该值只用于旧接口兼容、配置页展示和本地联调兜底。
|
||||
AD_REWARD_COIN: int = 666
|
||||
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
|
||||
# 历史 reward_amount 口径保留项。正式激励视频实发按 eCPM 公式计算。
|
||||
MAX_AD_REWARD_COIN: int = 1000
|
||||
# 每用户每日发奖次数上限。产品口径:一天最多看 500 次广告。
|
||||
DAILY_AD_REWARD_LIMIT: int = 500
|
||||
@@ -169,7 +230,7 @@ VIDEO_ROUND_COOLDOWN_SECONDS: int = 3
|
||||
|
||||
|
||||
def resolve_ad_reward_coin(db, reward_amount: str | int | None) -> int: # noqa: ANN001
|
||||
"""把穿山甲回调的 reward_amount(后台代码位配的"奖励数量")解析成本次发放金币。
|
||||
"""历史 reward_amount 口径解析函数,保留给旧脚本/旧配置兼容。
|
||||
|
||||
缺失 / 非数字 / ≤0 → 回退配置单次金币;超过配置单次上限 → 夹紧(均从 app_config 读)。
|
||||
注:reward_amount 不参与穿山甲 sign(只签 trans_id),但伪造回调需先伪造合法 sign
|
||||
@@ -236,3 +297,7 @@ def get_ad_round_count(db) -> int: # noqa: ANN001
|
||||
|
||||
def get_ad_cooldown_sec(db) -> int: # noqa: ANN001
|
||||
return int(_cfg(db, "ad_cooldown_sec"))
|
||||
|
||||
|
||||
def get_signin_boost_coin(db) -> int: # noqa: ANN001
|
||||
return int(_cfg(db, "signin_boost_coin"))
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""提现 pending 单自动对账后台任务。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.integrations.wxpay import WxPayNotConfiguredError
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
logger = logging.getLogger("shagua.withdraw_reconcile")
|
||||
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "withdraw_reconcile.lock"
|
||||
|
||||
|
||||
def _touch_lock() -> None:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.utime(_LOCK_PATH, None)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
|
||||
"""同机多进程保护:同一时间只允许一个自动查单 worker 运行。"""
|
||||
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd: int | None = None
|
||||
try:
|
||||
try:
|
||||
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
try:
|
||||
age = time.time() - _LOCK_PATH.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
age = stale_after_sec + 1
|
||||
if age > stale_after_sec:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
_LOCK_PATH.unlink()
|
||||
try:
|
||||
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
fd = None
|
||||
|
||||
if fd is None:
|
||||
yield False
|
||||
return
|
||||
|
||||
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
|
||||
yield True
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
_LOCK_PATH.unlink()
|
||||
|
||||
|
||||
def _reconcile_once(older_than_minutes: int) -> dict:
|
||||
with SessionLocal() as db:
|
||||
return wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes)
|
||||
|
||||
|
||||
async def _run_loop() -> None:
|
||||
interval = max(30, int(settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC))
|
||||
older_than = max(1, int(settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES))
|
||||
lock_stale_after = max(interval * 3, 600)
|
||||
with _single_instance_lock(lock_stale_after) as lock_acquired:
|
||||
if not lock_acquired:
|
||||
logger.warning("withdraw auto reconcile skipped: another worker owns lock")
|
||||
return
|
||||
|
||||
await _run_locked_loop(interval, older_than)
|
||||
|
||||
|
||||
async def _run_locked_loop(interval: int, older_than: int) -> None:
|
||||
logger.info(
|
||||
"withdraw auto reconcile started interval=%ss older_than=%sm",
|
||||
interval,
|
||||
older_than,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
_touch_lock()
|
||||
result = await asyncio.to_thread(_reconcile_once, older_than)
|
||||
if result["checked"] or result["resolved"]:
|
||||
logger.info("withdraw auto reconcile result=%s", result)
|
||||
except WxPayNotConfiguredError:
|
||||
logger.warning("withdraw auto reconcile skipped: wxpay not configured")
|
||||
except SQLAlchemyError:
|
||||
logger.exception("withdraw auto reconcile db error")
|
||||
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
|
||||
logger.exception("withdraw auto reconcile unexpected error")
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("withdraw auto reconcile stopped")
|
||||
raise
|
||||
|
||||
|
||||
def start_withdraw_reconcile_worker() -> asyncio.Task | None:
|
||||
if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED:
|
||||
logger.info("withdraw auto reconcile disabled")
|
||||
return None
|
||||
if not settings.wxpay_configured:
|
||||
logger.warning("withdraw auto reconcile enabled but wxpay not configured")
|
||||
return asyncio.create_task(_run_loop(), name="withdraw-auto-reconcile")
|
||||
|
||||
|
||||
async def stop_withdraw_reconcile_worker(task: asyncio.Task | None) -> None:
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
@@ -8,9 +8,9 @@
|
||||
我们客户端是 useMediation(true) 融合,回调走 GroMore 广告位层级,**不是**联盟代码位层级):
|
||||
**sign = SHA256("{m-key}:{trans_id}")** 的十六进制串。注意:
|
||||
- 是**普通 SHA256**,不是 HMAC,也不是 RSA;
|
||||
- **只对 `m-key:trans_id` 这一个字符串**签名,user_id / reward_amount 等其余参数**不参与**签名
|
||||
- **只对 `m-key:trans_id` 这一个字符串**签名,user_id / ecpm / extra 等其余参数**不参与**签名
|
||||
(它们的可信度由"能算出正确 sign = 知道 m-key"间接保证——伪造者没有 m-key 就连合法 sign
|
||||
都造不出,自然也无法注入假 user_id/reward_amount)。
|
||||
都造不出,自然也无法注入假 user_id/ecpm/extra)。
|
||||
m-key(安全密钥)在穿山甲后台「GroMore 聚合管理 → 搜广告位ID → 编辑」处获取,配到
|
||||
`settings.PANGLE_REWARD_SECRET`。(联盟代码位层级用的是另一套 Security Key + isValid 响应,我们不走那条。)
|
||||
"""
|
||||
|
||||
+48
-27
@@ -14,7 +14,9 @@ import base64
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
@@ -23,6 +25,7 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPubl
|
||||
from app.core.config import settings
|
||||
|
||||
_API_HOST = "https://api.mch.weixin.qq.com"
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
_TRANSFER_PATH = "/v3/fund-app/mch-transfer/transfer-bills"
|
||||
# 免确认收款授权(用户授权免确认模式)
|
||||
_AUTH_PATH = "/v3/fund-app/mch-transfer/user-confirm-authorization"
|
||||
@@ -38,15 +41,30 @@ class WxPayNotConfiguredError(Exception):
|
||||
"""微信支付凭证 / 证书缺失,无法调用。"""
|
||||
|
||||
|
||||
def _http_client() -> httpx.Client:
|
||||
"""微信相关 HTTP 客户端。
|
||||
|
||||
显式使用当前 Python 环境的 certifi 证书,避免被外部 SSL_CERT_FILE 指到不存在文件。
|
||||
代理仍允许从环境变量读取,方便本地开发。
|
||||
"""
|
||||
return httpx.Client(verify=certifi.where())
|
||||
|
||||
|
||||
def _resolve_config_path(value: str) -> Path:
|
||||
path = Path(value)
|
||||
return path if path.is_absolute() else _PROJECT_ROOT / path
|
||||
|
||||
|
||||
def _load_private_key() -> RSAPrivateKey:
|
||||
global _private_key
|
||||
if _private_key is None:
|
||||
key_path = _resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH)
|
||||
try:
|
||||
with open(settings.WXPAY_MCH_PRIVATE_KEY_PATH, "rb") as f:
|
||||
with key_path.open("rb") as f:
|
||||
_private_key = serialization.load_pem_private_key(f.read(), password=None)
|
||||
except FileNotFoundError as e:
|
||||
raise WxPayNotConfiguredError(
|
||||
f"商户私钥不存在: {settings.WXPAY_MCH_PRIVATE_KEY_PATH}"
|
||||
f"商户私钥不存在: {key_path}"
|
||||
) from e
|
||||
return _private_key
|
||||
|
||||
@@ -54,12 +72,13 @@ def _load_private_key() -> RSAPrivateKey:
|
||||
def _load_public_key() -> RSAPublicKey:
|
||||
global _public_key
|
||||
if _public_key is None:
|
||||
key_path = _resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH)
|
||||
try:
|
||||
with open(settings.WXPAY_PUBLIC_KEY_PATH, "rb") as f:
|
||||
with key_path.open("rb") as f:
|
||||
_public_key = serialization.load_pem_public_key(f.read())
|
||||
except FileNotFoundError as e:
|
||||
raise WxPayNotConfiguredError(
|
||||
f"微信支付平台公钥不存在: {settings.WXPAY_PUBLIC_KEY_PATH}"
|
||||
f"微信支付平台公钥不存在: {key_path}"
|
||||
) from e
|
||||
return _public_key
|
||||
|
||||
@@ -126,7 +145,7 @@ def create_transfer(
|
||||
"Content-Type": "application/json",
|
||||
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
with _http_client() as client:
|
||||
resp = client.post(
|
||||
f"{_API_HOST}{_TRANSFER_PATH}",
|
||||
content=body_str,
|
||||
@@ -143,7 +162,7 @@ def query_transfer(out_bill_no: str) -> dict:
|
||||
"Authorization": _build_authorization("GET", path, ""),
|
||||
"Accept": "application/json",
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
with _http_client() as client:
|
||||
resp = client.get(
|
||||
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
|
||||
)
|
||||
@@ -159,7 +178,7 @@ def cancel_transfer(out_bill_no: str) -> dict:
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
with _http_client() as client:
|
||||
resp = client.post(
|
||||
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
|
||||
)
|
||||
@@ -171,16 +190,17 @@ def code_to_userinfo(code: str) -> dict:
|
||||
返回 {openid, nickname, avatar_url, raw}。失败抛 ValueError。
|
||||
注意:微信隐私新政下,部分 app 的 sns/userinfo 可能返回脱敏值(昵称"微信用户"/灰头像);
|
||||
nickname/avatar_url 可能为空,调用方需兜底。"""
|
||||
r1 = httpx.get(
|
||||
"https://api.weixin.qq.com/sns/oauth2/access_token",
|
||||
params={
|
||||
"appid": settings.WECHAT_APP_ID,
|
||||
"secret": settings.WECHAT_APP_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
with _http_client() as client:
|
||||
r1 = client.get(
|
||||
"https://api.weixin.qq.com/sns/oauth2/access_token",
|
||||
params={
|
||||
"appid": settings.WECHAT_APP_ID,
|
||||
"secret": settings.WECHAT_APP_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
d1 = r1.json()
|
||||
if "openid" not in d1 or "access_token" not in d1:
|
||||
raise ValueError(f"微信授权失败: {d1.get('errmsg', d1)}")
|
||||
@@ -191,11 +211,12 @@ def code_to_userinfo(code: str) -> dict:
|
||||
raw: dict = {}
|
||||
# userinfo 拉取失败不应让绑定失败(openid 已拿到),吞掉异常只是没昵称头像
|
||||
try:
|
||||
r2 = httpx.get(
|
||||
"https://api.weixin.qq.com/sns/userinfo",
|
||||
params={"access_token": d1["access_token"], "openid": openid, "lang": "zh_CN"},
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
with _http_client() as client:
|
||||
r2 = client.get(
|
||||
"https://api.weixin.qq.com/sns/userinfo",
|
||||
params={"access_token": d1["access_token"], "openid": openid, "lang": "zh_CN"},
|
||||
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
||||
)
|
||||
raw = r2.json()
|
||||
nickname = raw.get("nickname") or None
|
||||
avatar_url = raw.get("headimgurl") or None
|
||||
@@ -244,7 +265,7 @@ def apply_transfer_authorization(
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
with _http_client() as client:
|
||||
resp = client.post(
|
||||
f"{_API_HOST}{_AUTH_PATH}",
|
||||
content=body_str,
|
||||
@@ -262,7 +283,7 @@ def query_transfer_authorization(out_authorization_no: str) -> dict:
|
||||
"Authorization": _build_authorization("GET", path, ""),
|
||||
"Accept": "application/json",
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
with _http_client() as client:
|
||||
resp = client.get(
|
||||
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
|
||||
)
|
||||
@@ -277,7 +298,7 @@ def close_transfer_authorization(out_authorization_no: str) -> dict:
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
with _http_client() as client:
|
||||
resp = client.post(
|
||||
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
|
||||
)
|
||||
@@ -319,7 +340,7 @@ def pre_transfer_with_authorization(
|
||||
"Content-Type": "application/json",
|
||||
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
with _http_client() as client:
|
||||
resp = client.post(
|
||||
f"{_API_HOST}{_PRE_TRANSFER_AUTH_PATH}",
|
||||
content=body_str,
|
||||
@@ -356,7 +377,7 @@ def transfer_with_authorization(
|
||||
"Content-Type": "application/json",
|
||||
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
with _http_client() as client:
|
||||
resp = client.post(
|
||||
f"{_API_HOST}{_TRANSFER_WITH_AUTH_PATH}",
|
||||
content=body_str,
|
||||
|
||||
+13
-3
@@ -21,6 +21,7 @@ from app.api.v1.compare_milestone import router as compare_milestone_router
|
||||
from app.api.v1.compare_record import router as compare_record_router
|
||||
from app.api.v1.coupon import router as coupon_router
|
||||
from app.api.internal.price import router as internal_price_router
|
||||
from app.api.internal.store import router as internal_store_router
|
||||
from app.api.v1.feedback import router as feedback_router
|
||||
from app.api.v1.invite import router as invite_router
|
||||
from app.api.v1.meituan import router as meituan_router
|
||||
@@ -35,6 +36,10 @@ from app.api.v1.wallet import router as wallet_router
|
||||
from app.api.v1.wxpay import router as wxpay_router
|
||||
from app.core.config import settings
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.withdraw_reconcile_worker import (
|
||||
start_withdraw_reconcile_worker,
|
||||
stop_withdraw_reconcile_worker,
|
||||
)
|
||||
|
||||
setup_logging(debug=settings.APP_DEBUG)
|
||||
logger = logging.getLogger("shagua.main")
|
||||
@@ -50,8 +55,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.APP_DEBUG,
|
||||
settings.DATABASE_URL.split("://", 1)[0],
|
||||
)
|
||||
yield
|
||||
logger.info("shutting down")
|
||||
reconcile_task = start_withdraw_reconcile_worker()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_withdraw_reconcile_worker(reconcile_task)
|
||||
logger.info("shutting down")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -94,8 +103,9 @@ app.include_router(savings_router)
|
||||
app.include_router(ad_router)
|
||||
app.include_router(order_router)
|
||||
app.include_router(report_router)
|
||||
# 内部(server→server)端点:pricebot 上报价格观测,靠共享密钥头校验,不对客户端开放。
|
||||
# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
|
||||
app.include_router(internal_price_router)
|
||||
app.include_router(internal_store_router)
|
||||
app.include_router(platform_router)
|
||||
|
||||
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
|
||||
|
||||
@@ -9,17 +9,21 @@ from app.models.comparison import ComparisonRecord # noqa: F401
|
||||
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
|
||||
from app.models.coupon_state import ( # noqa: F401
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
)
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.invite import InviteRelation # noqa: F401
|
||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
|
||||
from app.models.onboarding import OnboardingCompletion # noqa: F401
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
|
||||
from app.models.ops_stat_config import OpsStatConfig # noqa: F401
|
||||
from app.models.price_observation import PriceObservation # noqa: F401
|
||||
from app.models.price_report import PriceReport # noqa: F401
|
||||
from app.models.savings import SavingsRecord # noqa: F401
|
||||
from app.models.signin import SigninBoostRecord, SigninRecord # noqa: F401
|
||||
from app.models.store_mapping import StoreMapping # noqa: F401
|
||||
from app.models.task import UserTask # noqa: F401
|
||||
from app.models.user import User # noqa: F401
|
||||
from app.models.wallet import ( # noqa: F401
|
||||
|
||||
+10
-8
@@ -1,11 +1,8 @@
|
||||
"""广告展示 eCPM 上报记录(内部收益统计/对账)。
|
||||
|
||||
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM 信息。和发奖记录
|
||||
[ad_reward.AdRewardRecord] 是**两条独立的数据流**:
|
||||
- 发奖走穿山甲 S2S 回调(后端 → 有 trans_id、无 ecpm);
|
||||
- eCPM 走客户端上报(客户端 → 有 ecpm、无 trans_id)。
|
||||
两者没有公共键,无法逐条一一对应,所以本表用于**按用户/按天聚合收益**口径的对账,
|
||||
不做"这条发奖 = 这条 ecpm"的精确关联。穿山甲后台报表才是结算权威,本表是细粒度补充。
|
||||
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM 信息。`ad_session_id`
|
||||
由客户端生成,并通过激励视频 extra 透传给 S2S 回调,用于把"展示 eCPM"与
|
||||
"奖励完成"绑定。没有 session id 的旧上报仍可作为按天对账补充。
|
||||
|
||||
⚠️ `ecpm_raw` 原样存客户端上报的字符串——eCPM 单位(分 / 元)截至 2026-05-31 尚未最终确认,
|
||||
确认后再加一列解析好的数值;在此之前对账按"待定单位"处理。
|
||||
@@ -14,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -22,6 +19,9 @@ from app.db.base import Base
|
||||
|
||||
class AdEcpmRecord(Base):
|
||||
__tablename__ = "ad_ecpm_record"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ad_session_id", name="uq_ad_ecpm_record_session"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
@@ -29,11 +29,13 @@ class AdEcpmRecord(Base):
|
||||
)
|
||||
# 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报
|
||||
ad_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
|
||||
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
|
||||
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 实际展示用的代码位(底层 mediation rit,非客户端配置位)
|
||||
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 客户端上报的 eCPM 原始字符串(单位待确认,原样存)
|
||||
# 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存)
|
||||
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
|
||||
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||
|
||||
@@ -20,6 +20,7 @@ class AdFeedRewardRecord(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
client_event_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
|
||||
+14
-5
@@ -1,8 +1,8 @@
|
||||
"""看激励视频发奖记录(穿山甲 S2S 回调)。
|
||||
|
||||
每条 = 穿山甲一次发奖回调。`trans_id`(穿山甲交易号)唯一,做幂等键:穿山甲会重试回调,
|
||||
同号只发一次金币。`reward_date`(北京时间日期串)给"每日上限"计数用——按日期串等值查,
|
||||
不在 SQL 里做跨时区 date 比较(SQLite 上不可靠)。审计 / 对账时整张表可逐笔回溯。
|
||||
每条 = 穿山甲/GroMore 一次服务端激励回调。`trans_id`(交易号)唯一,做幂等键:
|
||||
穿山甲会重试回调,同号只处理一次。`reward_scene` 区分福利页激励视频、签到膨胀等
|
||||
不同奖励场景,避免统计和每日上限互相污染。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,8 +25,14 @@ class AdRewardRecord(Base):
|
||||
)
|
||||
# 实发金币(capped 时为 0)
|
||||
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# granted(已发) / capped(当日超限未发)
|
||||
# granted(已发) / capped(当日超限未发) / ecpm_missing(缺 eCPM 未发)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
|
||||
# reward_video(福利页看视频) / signin_boost(签到膨胀)
|
||||
reward_scene: Mapped[str] = mapped_column(String(32), nullable=False, default="reward_video")
|
||||
# 客户端生成并通过 extra 透传的广告会话 id
|
||||
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报)
|
||||
ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数
|
||||
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||
# 穿山甲上报的奖励名(参考,不作发奖依据)
|
||||
@@ -39,4 +45,7 @@ class AdRewardRecord(Base):
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AdRewardRecord trans_id={self.trans_id} user_id={self.user_id} {self.status} coin={self.coin}>"
|
||||
return (
|
||||
f"<AdRewardRecord trans_id={self.trans_id} user_id={self.user_id} "
|
||||
f"scene={self.reward_scene} {self.status} coin={self.coin}>"
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
@@ -39,6 +40,9 @@ class ComparisonRecord(Base):
|
||||
__table_args__ = (
|
||||
# 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。
|
||||
UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"),
|
||||
# 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序;
|
||||
# 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。
|
||||
Index("ix_comparison_status_created", "status", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
@@ -53,6 +57,10 @@ class ComparisonRecord(Base):
|
||||
)
|
||||
# pricebot 侧 trace_id:关联调试落盘 + 幂等去重键
|
||||
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# 本次比价的公网调试链接(price.shaguabijia.com/traces/{dir}/)。pricebot done 帧给、
|
||||
# 客户端上报带上——dir 名含 pricebot 落盘的时分秒,前端/server 都拼不出,必须存。
|
||||
# 查看接口按 user.debug_trace_enabled 决定返不返回。旧记录 / 未开上云为 None。
|
||||
trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
# ===== 源平台(发起比价的那家)=====
|
||||
source_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
@@ -86,7 +94,7 @@ class ComparisonRecord(Base):
|
||||
# ===== 明细(JSON,越详细越好)=====
|
||||
# 下单菜品 [{name, qty, specs?}]
|
||||
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名)
|
||||
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细)
|
||||
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
# 目标平台未找到、跳过的菜名
|
||||
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
|
||||
@@ -93,15 +93,22 @@ class CouponClaimRecord(Base):
|
||||
)
|
||||
|
||||
|
||||
class CouponPromptEngagement(Base):
|
||||
"""按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。"""
|
||||
class CouponDailyCompletion(Base):
|
||||
"""按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。
|
||||
|
||||
__tablename__ = "coupon_prompt_engagement"
|
||||
与 engagement 区别:engagement = 用户表达过意向(点了一键领取/拒绝即记,不管跑没跑完);
|
||||
completion = 这一轮真跑到了 done(整套流程走完)。首页「去领取」卡据此置灰:跑完了
|
||||
今天就不能再领。判断口径(用户决策 2026-06-10 A 方案):**到 done 即算**,不管单券
|
||||
成败(失败/跳过常是无障碍/环境问题,重复点也补不回来)。判断维度 device_id,与
|
||||
engagement/claim 一致。
|
||||
"""
|
||||
|
||||
__tablename__ = "coupon_daily_completion"
|
||||
__table_args__ = (
|
||||
# 一台设备一天一条:今天 engage 过(领或拒)就不再弹。
|
||||
# 一台设备一天一条:今天跑完过就置灰。
|
||||
UniqueConstraint(
|
||||
"device_id", "engage_date",
|
||||
name="uq_coupon_engage_device_date",
|
||||
"device_id", "complete_date",
|
||||
name="uq_coupon_completion_device_date",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -110,9 +117,55 @@ class CouponPromptEngagement(Base):
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
# Asia/Shanghai 自然日。
|
||||
complete_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
# 哪次任务跑到 done,回指 pricebot work_logs / 排查。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<CouponDailyCompletion device={self.device_id} "
|
||||
f"date={self.complete_date}>"
|
||||
)
|
||||
|
||||
|
||||
class CouponPromptEngagement(Base):
|
||||
"""按 (device, **App**, 自然日) 记"今天这个 App 是否对领券引导窗表达过意向"——弹窗频控源。
|
||||
|
||||
2026-06-14:频控维度从 (device, 日) 改为 (device, package, 日)。需求是美团/淘宝/京东
|
||||
各自独立——在美团弹过/领过,不影响淘宝、京东今天仍各弹一次。原来缺 package → 任一 App
|
||||
弹过就把整台设备当天标记 engage,其余 App 被压住不弹(bug)。
|
||||
"""
|
||||
|
||||
__tablename__ = "coupon_prompt_engagement"
|
||||
__table_args__ = (
|
||||
# 一台设备、一个 App、一天一条:今天**这个 App** engage 过(领或拒)才不再弹该 App。
|
||||
UniqueConstraint(
|
||||
"device_id", "package", "engage_date",
|
||||
name="uq_coupon_engage_device_pkg_date",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# 触发弹窗的目标 App 包名(com.sankuai.meituan / com.taobao.taobao / com.jingdong.app.mall)。
|
||||
# 频控维度,各 App 独立。旧行(改造前)无此值 → 迁移用占位 "" 填,不影响新逻辑判断。
|
||||
package: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, server_default=""
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
# Asia/Shanghai 自然日。
|
||||
engage_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
# claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)。仅记录区分,
|
||||
# 判断只看"今天有没有这条",type 不影响弹不弹。
|
||||
# claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)/ shown(自动弹出即记)。
|
||||
# 仅记录区分,判断只看"今天这个 App 有没有这条",type 不影响弹不弹。
|
||||
engage_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""用户反馈表(帮助与反馈)。
|
||||
|
||||
每条 = 用户一次提交。content 必填,contact 必填(微信/QQ/手机,便于回访),images 为可选的
|
||||
截图 URL 列表(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。
|
||||
每条 = 用户一次提交。content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集,
|
||||
新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表
|
||||
(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""邀请指纹归因表(剪贴板归因兜底)。
|
||||
|
||||
数据流(对应 [[invite-three-tasks]] 任务 3):
|
||||
1. B 浏览器打开落地页 dl.html?ref=邀请码 → JS POST /api/v1/invite/landing-track
|
||||
2. 后端从 HTTP 头拿 IP/UA,解析 UA 得 device_model,跟 JS 上报的 screen 一起入库
|
||||
3. B 装包首启 → 登录后,若剪贴板没拿到邀请码(被覆盖),客户端再算一次 screen+Build.MODEL
|
||||
上报 → 后端用 (ip, screen, device_model) 反查本表 7 天内最近匹配 → 拿出 inviter_user_id
|
||||
→ 走原 bind 路径(channel=fingerprint)
|
||||
|
||||
不要索引(IP, created_at) 单独,组合索引 (ip, screen, device_model, created_at desc) 反查更省。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class InviteFingerprint(Base):
|
||||
__tablename__ = "invite_fingerprint"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
inviter_user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 落地页访问者 IP(服务端从 HTTP 头拿,X-Forwarded-For 由部署侧 nginx 透传)
|
||||
ip: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# 客户端 Build.MODEL / 浏览器 UA 解析出来的型号(如 "PJF110"、"23046PNC9C")
|
||||
device_model: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
# 屏幕分辨率 "1080x2400"(浏览器 screen.width × height,客户端 DisplayMetrics)
|
||||
screen: Mapped[str] = mapped_column(String(32), nullable=False, default="")
|
||||
# 完整 UA 字符串(留 debug / 排查"匹配不上"用,不直接参与匹配)
|
||||
user_agent: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<InviteFingerprint inviter={self.inviter_user_id} "
|
||||
f"ip={self.ip} model={self.device_model} screen={self.screen}>"
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""新手引导完成标记(按 设备 + 账号 去重)。
|
||||
|
||||
产品规则:同一台设备 + 同一个账号,新手引导只跑一次;卸载重装(同设备同账号)
|
||||
不再触发。本地标记(SharedPreferences)卸载即丢,所以"完成"必须落后端,按
|
||||
(user_id, device_id) 唯一一条。
|
||||
|
||||
⚠️ device_id 这里用客户端的**硬件级稳定标识**(Android `Settings.Secure.ANDROID_ID`),
|
||||
同签名 app 卸载重装不变、仅恢复出厂才重置——区别于领券/比价用的 per-install device_id
|
||||
(见 coupon_state,存 SP、重装会变)。换新设备 → 无此行 → 重新走引导。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class OnboardingCompletion(Base):
|
||||
"""一台设备 + 一个账号 一条:走完新手引导即写入,登录时据此跳过引导。"""
|
||||
|
||||
__tablename__ = "onboarding_completion"
|
||||
__table_args__ = (
|
||||
# 同账号、同设备只一条:重复 mark / 并发提交靠它幂等。
|
||||
UniqueConstraint("user_id", "device_id", name="uq_onboarding_user_device"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 登录态用户。引导是登录后才走的,故必填(非空)、进唯一键。
|
||||
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
|
||||
# 硬件级稳定设备标识(ANDROID_ID),卸载重装不变。
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
completed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<OnboardingCompletion user={self.user_id} device={self.device_id}>"
|
||||
@@ -25,7 +25,8 @@ class OpsMarqueeSeed(Base):
|
||||
__tablename__ = "ops_marquee_seed"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 脱敏用户名,如「用户********a52」;留空(NULL)→ feed 展示时按脱敏格式随机合成(避开撞名)
|
||||
# 脱敏用户名(手机尾号 / 中文昵称风格,如「138****5678」「省钱**」);留空(NULL)或旧「用户****xxx」
|
||||
# 模板名 → feed 展示时随机合成混合风格名(避开撞名、自愈历史种子)
|
||||
masked_user: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 节省金额区间(分);feed 每次在 [min,max] 随机取一个值。固定金额则 min==max。客户端 ÷100 显示「x.xx 元」
|
||||
min_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
@@ -53,7 +53,8 @@ class OpsStatConfig(Base):
|
||||
random_kind: Mapped[str] = mapped_column(String(8), nullable=False, default="mult")
|
||||
random_step_min: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
random_step_max: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# real 模式基数偏移(基础单位;total_saved 为分):展示值 = 真实值 + 偏移(冷启动期既真实又体面)
|
||||
# real 模式保底值(基础单位;total_saved 为分):展示值 = max(真实值, 保底值)。冷启动显示保底撑
|
||||
# 场面,真实值涨过保底后显示纯真实、零差距。列名沿用 real_offset(语义已从"偏移"改为"保底")
|
||||
real_offset: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 门面数字默认「只增不减」;运营明确要下调时才开此开关(real/manual 刷新都受护栏约束)
|
||||
allow_decrease: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""平台店铺表(store_mapping)—— 跨平台"同一家店"的 id/名 映射资产层。
|
||||
|
||||
每完成一次淘宝比价(在目标淘宝店通过 更多操作→分享→复制链接 拿到分享短链、
|
||||
HTTP 解析出 shopId 后),pricebot server→server 内部上报落这里一行。**与登录无关、
|
||||
不依赖客户端鉴权**(比价透传链路当前不鉴权,user_id 客户端带上时一并记)。
|
||||
|
||||
与 price_observation 的区别:
|
||||
- price_observation:平台/门店视角的**价格事实**(某店这单多少钱)。
|
||||
- store_mapping:平台/门店视角的**身份映射**(同一家物理店在 淘宝/美团/京东 各自的
|
||||
店铺 id 与店名)。是未来"我见过这家店→跳过重新搜索/匹配"的源头。两表独立。
|
||||
|
||||
「先存下来、用法后说」:列尽量铺全(各平台 id/名 + 地理 + 溯源 + 淘宝/美团原料 URL),
|
||||
attrs(JSONB)兜底存灵活明细,免得每多记一个字段就迁移 schema。
|
||||
|
||||
⚠️ 数据质量:跨平台"同一家店"的连接来自 agent 的 LLM 店铺匹配,匹配错则一行里连错店。
|
||||
本表是 append-only 原始记录(每比价一行、trace_id 幂等防重试重复),清洗/归一二期再做。
|
||||
已接通**淘宝**(id_taobao=shopId)、**美团**(meituan_poi_id_str,非稳定主键、单列存)、
|
||||
**京东**(id_jd=storeId + jd_vender_id,均稳定数字主键;3.cn 短链反查)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
DateTime,
|
||||
Float,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
# PG 上用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 price_observation / comparison_record)。
|
||||
_JSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
class StoreMapping(Base):
|
||||
__tablename__ = "store_mapping"
|
||||
__table_args__ = (
|
||||
# 一次比价(trace)只记一条:pricebot 重试 / 客户端 replay 重复上报时幂等去重。
|
||||
# 一次淘宝比价 = 一个目标淘宝店 → 一行映射(源 + 各平台身份压在同一行)。
|
||||
UniqueConstraint("trace_id", name="uq_store_mapping_trace"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# ===== 跨平台店铺身份(同一家物理店在各平台的 id/名;按比价角色稀疏填充)=====
|
||||
# id_taobao = 分享短链解析出的 shopId(淘宝当目标、走完取 id 流程才有);
|
||||
# name_taobao = 店铺页 a11y content_desc "店铺标题:xxx" 剥前缀。
|
||||
id_taobao: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
name_taobao: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 美团:无同款 share→稳定id 机制(poi_id_str 每次变,见下方 meituan_poi_id_str),id_meituan
|
||||
# 留给将来 CPS API 的稳定 poi_id;name-only 时仅 name_meituan(源平台店名来自 intent/agent 匹配名)。
|
||||
id_meituan: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
name_meituan: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 京东:已接通 share→id(3.cn 短链反查)。id_jd = storeId(门店稳定数字主键,同 taobao shopId→
|
||||
# id_taobao);venderId(deeplink 还需)单列存 jd_vender_id。name_jd = 店铺页店名。
|
||||
id_jd: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
name_jd: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
# ===== 地理(同名店异地区分 / 地理分桶匹配的主要燃料)=====
|
||||
city: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
geohash: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
||||
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# 淘宝门店地址(店铺页 a11y 抓到才有;比经纬度更利于人工/LLM 匹配)
|
||||
taobao_address: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
|
||||
# ===== 溯源 / 用户画像 =====
|
||||
# 源平台(发起比价那家:meituan / taobao_flash / jd_waimai ...)
|
||||
source_platform: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
|
||||
business_type: Mapped[str] = mapped_column(String(16), nullable=False, default="food")
|
||||
# pricebot 侧 trace_id:回指原始 trace(溯源)+ 幂等去重键(uq_store_mapping_trace)
|
||||
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
source_device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# user_id 当前比价链路不鉴权拿不到,客户端带上时才有;先可空。
|
||||
source_user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
|
||||
# ===== 淘宝原料(可复跳 / 可重解析 / 调试;URL 可能很长 → Text)=====
|
||||
taobao_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # m.tb.cn 短链
|
||||
taobao_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 解析出的目标 URL(含 shopId)
|
||||
taobao_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 et-store/search deeplink
|
||||
# 淘宝 deeplink 失效标记:比价撞"页面出错了"降级页时被置(pricebot server→server invalidate),
|
||||
# NULL=有效。lookup 反查过滤掉非 NULL 的淘宝候选,不再返回坏 deeplink(重搜会写新行覆盖)。
|
||||
taobao_deeplink_invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# ===== 美团原料(同淘宝;dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink)=====
|
||||
# ⚠️ poi_id_str 每次分享重新加密、非稳定主键(调研文档 §八), 故单列存"可复跳的一次性票据",
|
||||
# 不进 id_meituan —— 后者留给将来 CPS API 拿到的稳定数字 poi_id。
|
||||
meituan_poi_id_str: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
meituan_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # dpurl.cn 短链
|
||||
meituan_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 302 落地 menu URL(含 poi_id_str)
|
||||
meituan_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 imeituan:// 店内搜索 deeplink
|
||||
|
||||
# ===== 京东原料(秒送;3.cn 短链 → 跟随重定向反查 venderId+storeId → openapp.jdmobile:// deeplink)=====
|
||||
# storeId 进 id_jd(稳定店主键);venderId 单列存(deeplink 模板 venderId+storeId 都要,且 venderId
|
||||
# 是商家维度、可跨门店,与门店 storeId 分开记)。其余三列与 taobao_*/meituan_* 平行。
|
||||
jd_vender_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
jd_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # 3.cn 短链
|
||||
jd_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 反查出的目标 openapp.jdmobile:// deeplink
|
||||
jd_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 pages/search 店内搜索 deeplink
|
||||
|
||||
# 灵活字段兜底(免得加字段就迁移)
|
||||
attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<StoreMapping id={self.id} taobao=({self.id_taobao!r},{self.name_taobao!r}) "
|
||||
f"source={self.source_platform} trace_id={self.trace_id}>"
|
||||
)
|
||||
+8
-1
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, func
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, false, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -51,6 +51,13 @@ class User(Base):
|
||||
# 账号状态:active / disabled / deleted
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
|
||||
# 调试链接权限:开了的用户在比价完成弹窗 + 比价记录页能看到「复制调试链接」按钮
|
||||
# (复制 price.shaguabijia.com 的 trace 链接发给开发排障)。运营后台按用户配置;
|
||||
# /me 与登录响应里带出给前端做条件渲染。默认 false。
|
||||
debug_trace_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default=false()
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
+34
-1
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -41,6 +41,21 @@ class CoinAccount(Base):
|
||||
|
||||
class CoinTransaction(Base):
|
||||
__tablename__ = "coin_transaction"
|
||||
__table_args__ = (
|
||||
# 可重复任务(如 task_enable_notification)按 ref_id 序号(task_key:N)去重,挡并发/连点
|
||||
# 重复发放——读-算-写无锁,两个并发 claim 会都算出同一序号双倍发钱(见 task.claim_task)。
|
||||
# 仅 task_* 且 ref_id 非空生效;一次性任务 ref_id=task_key 本就每人一条,纳入无害;
|
||||
# 不影响 signin / exchange / withdraw 等其它 biz_type。
|
||||
Index(
|
||||
"ux_coin_transaction_task_ref",
|
||||
"user_id",
|
||||
"biz_type",
|
||||
"ref_id",
|
||||
unique=True,
|
||||
sqlite_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
|
||||
postgresql_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
@@ -76,6 +91,15 @@ class WithdrawOrder(Base):
|
||||
"""
|
||||
|
||||
__tablename__ = "withdraw_order"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ux_withdraw_order_user_active",
|
||||
"user_id",
|
||||
unique=True,
|
||||
sqlite_where=text("status IN ('reviewing', 'pending')"),
|
||||
postgresql_where=text("status IN ('reviewing', 'pending')"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
@@ -148,6 +172,15 @@ class CashTransaction(Base):
|
||||
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
|
||||
|
||||
__tablename__ = "cash_transaction"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ux_cash_transaction_withdraw_refund_ref",
|
||||
"ref_id",
|
||||
unique=True,
|
||||
sqlite_where=text("biz_type = 'withdraw_refund' AND ref_id IS NOT NULL"),
|
||||
postgresql_where=text("biz_type = 'withdraw_refund' AND ref_id IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
|
||||
@@ -7,6 +7,7 @@ user 存在(JWT),故不做 UnknownUser 校验。best-effort 上报:丢一两条
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import cn_today
|
||||
@@ -19,24 +20,52 @@ def create_ecpm_record(
|
||||
*,
|
||||
ad_type: str,
|
||||
ecpm_raw: str,
|
||||
ad_session_id: str | None = None,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
) -> AdEcpmRecord:
|
||||
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。"""
|
||||
if ad_session_id:
|
||||
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
rec = AdEcpmRecord(
|
||||
user_id=user_id,
|
||||
ad_type=ad_type,
|
||||
ad_session_id=ad_session_id,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
ecpm_raw=ecpm_raw,
|
||||
report_date=cn_today().isoformat(),
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
if ad_session_id:
|
||||
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
raise
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def find_by_session(
|
||||
db: Session, *, user_id: int, ad_session_id: str | None
|
||||
) -> AdEcpmRecord | None:
|
||||
"""按广告会话找 eCPM。旧客户端无 ad_session_id 时返回 None。"""
|
||||
if not ad_session_id:
|
||||
return None
|
||||
return db.execute(
|
||||
select(AdEcpmRecord).where(
|
||||
AdEcpmRecord.user_id == user_id,
|
||||
AdEcpmRecord.ad_session_id == ad_session_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def count_today(db: Session, user_id: int) -> int:
|
||||
"""该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。"""
|
||||
return db.execute(
|
||||
|
||||
@@ -16,6 +16,10 @@ from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
FEED_REWARD_UNIT_SECONDS = 10
|
||||
# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数
|
||||
# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。
|
||||
# 与 rewards.AD_ECPM_MAX_FEN(eCPM 钳顶)合起来,把单事件可铸金币锁进有限区间。
|
||||
FEED_MAX_DURATION_SECONDS = 120
|
||||
|
||||
|
||||
def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None:
|
||||
@@ -38,15 +42,14 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int, today: str) -> int:
|
||||
"""按每个 10 秒单位逐份计算奖励,LT 使用当天累计奖励份序号。"""
|
||||
def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int) -> int:
|
||||
"""按每个 10 秒单位逐份计算奖励,LT 使用**账号累计**奖励份序号(不按天重置)。"""
|
||||
if unit_count <= 0:
|
||||
return 0
|
||||
existing_units = db.execute(
|
||||
select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0))
|
||||
.where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.reward_date == today,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
)
|
||||
).scalar_one()
|
||||
@@ -63,16 +66,23 @@ def grant_feed_reward(
|
||||
client_event_id: str,
|
||||
ecpm: str,
|
||||
duration_seconds: int,
|
||||
ad_session_id: str | None = None,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
) -> AdFeedRewardRecord:
|
||||
"""完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。"""
|
||||
"""完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。
|
||||
|
||||
一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS
|
||||
限单事件份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额;
|
||||
叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。
|
||||
"""
|
||||
existing = _find_by_event(db, client_event_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
today = cn_today().isoformat()
|
||||
safe_duration = max(0, min(duration_seconds, 24 * 60 * 60))
|
||||
# 客户端上报时长先钳到 FEED_MAX_DURATION_SECONDS,防伪造超长时长刷份数(见常量注释)。
|
||||
safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS))
|
||||
unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS
|
||||
|
||||
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
|
||||
@@ -82,6 +92,7 @@ def grant_feed_reward(
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=unit_count,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
@@ -90,7 +101,7 @@ def grant_feed_reward(
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
coin = _unit_reward_total(db, user_id, ecpm, unit_count, today)
|
||||
coin = _unit_reward_total(db, user_id, ecpm, unit_count)
|
||||
if coin > 0:
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
@@ -103,6 +114,7 @@ def grant_feed_reward(
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=unit_count,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
2. trans_id 唯一 → 同一交易号二次回调不重复发(穿山甲会重试)。
|
||||
3. 当日发奖次数 ≥ 上限 → 记一条 status='capped' 但不发金币。
|
||||
|
||||
发金币复用 `wallet.grant_coins`(biz_type='ad_reward', ref_id=trans_id),与发奖记录同事务,
|
||||
保证"记一笔 + 加金币"原子化。单次金币 / 每日上限 / 每轮冷却 都从 app_config 读(运营后台可改)。
|
||||
普通福利页激励视频按 eCPM 公式发金币;缺 eCPM 时记 status='ecpm_missing' 但不发金币。
|
||||
签到膨胀等其他激励视频场景复用本表记录 S2S 幂等,实际发币由各自业务仓储完成。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.core.ad_cooldown import compute_cooldown
|
||||
from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.user import User
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.repositories.ad_watch import watched_seconds_today
|
||||
|
||||
@@ -35,6 +36,11 @@ def _find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
|
||||
"""按 S2S 交易号查询处理记录,供路由在分场景前做幂等短路。"""
|
||||
return _find_by_trans(db, trans_id)
|
||||
|
||||
|
||||
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
||||
return db.execute(
|
||||
select(func.count())
|
||||
@@ -43,6 +49,21 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.reward_date == reward_date,
|
||||
AdRewardRecord.status == "granted",
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def _granted_cumulative(db: Session, user_id: int) -> int:
|
||||
"""账号累计已发奖的看视频次数(不按天重置)——LT 因子(因子2)用它定"第 N 次广告"。
|
||||
与 _granted_today 区别仅在去掉 reward_date 过滤;每日次数上限/冷却仍按当日统计。"""
|
||||
return db.execute(
|
||||
select(func.count())
|
||||
.select_from(AdRewardRecord)
|
||||
.where(
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.status == "granted",
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
@@ -52,16 +73,13 @@ def grant_ad_reward(
|
||||
user_id: int,
|
||||
trans_id: str,
|
||||
*,
|
||||
coin: int | None = None,
|
||||
ecpm: str | None = None,
|
||||
ad_session_id: str | None = None,
|
||||
reward_scene: str = "reward_video",
|
||||
reward_name: str | None = None,
|
||||
raw: str | None = None,
|
||||
) -> AdRewardRecord:
|
||||
"""看广告发奖(幂等 + 每日限额)。返回发奖记录(status=granted/capped)。
|
||||
|
||||
coin 为本次发放金币(由调用方按穿山甲回调 reward_amount 解析,见
|
||||
rewards.resolve_ad_reward_coin);None → 读配置 get_ad_reward_coin(test-grant / 缺省场景)。
|
||||
user_id 不存在抛 UnknownUserError(不建账户,防伪造 user_id 刷出脏数据)。
|
||||
"""
|
||||
"""福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。"""
|
||||
# #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发
|
||||
existing = _find_by_trans(db, trans_id)
|
||||
if existing is not None:
|
||||
@@ -83,24 +101,88 @@ def grant_ad_reward(
|
||||
rec = AdRewardRecord(
|
||||
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
if coin is None:
|
||||
coin = rewards.get_ad_reward_coin(db)
|
||||
ecpm_raw = ecpm
|
||||
if not ecpm_raw and ad_session_id:
|
||||
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
|
||||
ecpm_raw = ecpm_rec.ecpm_raw if ecpm_rec is not None else None
|
||||
|
||||
if not ecpm_raw:
|
||||
rec = AdRewardRecord(
|
||||
trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
# LT 因子(因子2)按"账号累计第 N 次看广告"递减(2.0→1.0),不再按天重置;
|
||||
# 每日次数上限/冷却仍按当日统计(over_count 用 _granted_today)。
|
||||
coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_cumulative(db, user_id) + 1)
|
||||
|
||||
# 发金币 + 记一笔,同事务
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="ad_reward", ref_id=trans_id, remark="看视频奖励金币",
|
||||
biz_type="reward_video", ref_id=trans_id, remark="看视频奖励金币",
|
||||
)
|
||||
rec = AdRewardRecord(
|
||||
trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
|
||||
def record_external_reward(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
trans_id: str,
|
||||
*,
|
||||
coin: int,
|
||||
reward_scene: str,
|
||||
ad_session_id: str | None = None,
|
||||
ecpm: str | None = None,
|
||||
reward_name: str | None = None,
|
||||
raw: str | None = None,
|
||||
status: str = "granted",
|
||||
commit: bool = True,
|
||||
) -> AdRewardRecord:
|
||||
"""记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。"""
|
||||
existing = _find_by_trans(db, trans_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
if db.get(User, user_id) is None:
|
||||
raise UnknownUserError
|
||||
rec = AdRewardRecord(
|
||||
trans_id=trans_id,
|
||||
user_id=user_id,
|
||||
coin=coin,
|
||||
status=status,
|
||||
reward_date=cn_today().isoformat(),
|
||||
reward_name=reward_name,
|
||||
raw=raw,
|
||||
reward_scene=reward_scene,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
)
|
||||
db.add(rec)
|
||||
if commit:
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
existing = _find_by_trans(db, trans_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
raise
|
||||
db.refresh(rec)
|
||||
else:
|
||||
db.flush()
|
||||
return rec
|
||||
|
||||
|
||||
def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardRecord:
|
||||
"""提交发奖记录;并发下同 trans_id 撞唯一约束时回滚并返回已存在的那条(幂等兜底)。"""
|
||||
db.add(rec)
|
||||
@@ -125,6 +207,7 @@ def _granted_times_today_desc(db: Session, user_id: int, reward_date: str) -> li
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.reward_date == reward_date,
|
||||
AdRewardRecord.status == "granted",
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
)
|
||||
.order_by(AdRewardRecord.created_at.desc())
|
||||
).scalars()
|
||||
@@ -153,7 +236,7 @@ def today_status(
|
||||
return (
|
||||
len(granted_desc),
|
||||
rewards.get_ad_daily_limit(db),
|
||||
rewards.get_ad_reward_coin(db),
|
||||
0,
|
||||
state.round_count,
|
||||
state.cooldown_until,
|
||||
watched_seconds_today(db, user_id, today=today),
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.savings import SavingsRecord
|
||||
from app.schemas.compare_record import ComparisonRecordIn
|
||||
@@ -81,6 +84,7 @@ def upsert_record(
|
||||
source_package=payload.source_package,
|
||||
information=payload.information,
|
||||
best_deeplink=payload.best_deeplink,
|
||||
trace_url=payload.trace_url,
|
||||
total_dish_count=payload.total_dish_count,
|
||||
skipped_dish_count=payload.skipped_dish_count,
|
||||
items=[it.model_dump(exclude_none=True) for it in payload.items],
|
||||
@@ -104,7 +108,15 @@ def upsert_record(
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
rec = ComparisonRecord(user_id=user_id, trace_id=payload.trace_id, **fields)
|
||||
# created_at 显式存 naive 北京 wall-clock(同 savings_record):客户端「比价记录」页
|
||||
# formatRecordTime 原样切片 created_at 字符串、不转时区,而默认 server_default=func.now()
|
||||
# 在 SQLite 下返回 UTC → 直接慢 8h。覆盖分支不动 created_at(保留首次创建时间)。
|
||||
rec = ComparisonRecord(
|
||||
user_id=user_id,
|
||||
trace_id=payload.trace_id,
|
||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||
**fields,
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
|
||||
@@ -9,11 +9,15 @@ import logging
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponPromptEngagement
|
||||
from app.models.coupon_state import (
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.coupon_state")
|
||||
|
||||
@@ -27,11 +31,13 @@ def today_cn() -> date:
|
||||
|
||||
# ===== 弹窗频控(coupon_prompt_engagement)=====
|
||||
|
||||
def has_engaged_today(db: Session, device_id: str) -> bool:
|
||||
"""这台设备今天是否已对领券引导窗表达过意向(领或拒)。有 = 不再弹。"""
|
||||
def has_engaged_today(db: Session, device_id: str, package: str) -> bool:
|
||||
"""这台设备今天**这个 App** 是否已对领券引导窗表达过意向(领/拒/弹出)。有 = 该 App 不再弹。
|
||||
频控按 (device, package, 日):美团弹过不影响淘宝/京东今天各自仍弹一次。"""
|
||||
row = db.execute(
|
||||
select(CouponPromptEngagement.id).where(
|
||||
CouponPromptEngagement.device_id == device_id,
|
||||
CouponPromptEngagement.package == package,
|
||||
CouponPromptEngagement.engage_date == today_cn(),
|
||||
)
|
||||
).first()
|
||||
@@ -39,13 +45,18 @@ def has_engaged_today(db: Session, device_id: str) -> bool:
|
||||
|
||||
|
||||
def mark_engagement(
|
||||
db: Session, device_id: str, user_id: int | None, engage_type: str
|
||||
db: Session, device_id: str, package: str, user_id: int | None, engage_type: str
|
||||
) -> None:
|
||||
"""记今日意向(claim_started / dismissed)。(device, 今天) 唯一,幂等 upsert。"""
|
||||
"""记今日意向(shown / claim_started / dismissed)。(device, package, 今天) 唯一,幂等 upsert。
|
||||
|
||||
engage_type 升级口径(同一 (device,package,日) 多次调,只覆盖 type,不新增行):
|
||||
shown(自动弹出)→ claim_started(点一键领取)/ dismissed(点关闭)。判断只看"有没有这条"。
|
||||
"""
|
||||
today = today_cn()
|
||||
row = db.execute(
|
||||
select(CouponPromptEngagement).where(
|
||||
CouponPromptEngagement.device_id == device_id,
|
||||
CouponPromptEngagement.package == package,
|
||||
CouponPromptEngagement.engage_date == today,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
@@ -55,16 +66,84 @@ def mark_engagement(
|
||||
row.user_id = user_id
|
||||
else:
|
||||
db.add(CouponPromptEngagement(
|
||||
device_id=device_id, user_id=user_id,
|
||||
device_id=device_id, package=package, user_id=user_id,
|
||||
engage_date=today, engage_type=engage_type,
|
||||
))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发下另一请求刚插了同 (device, package, 日) → 唯一约束撞,回滚忽略(本就幂等)。
|
||||
db.rollback()
|
||||
|
||||
|
||||
def reset_today_engagement(db: Session, device_id: str) -> int:
|
||||
"""删这台设备今天**所有 App** 的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。
|
||||
删后各 App has_engaged_today → false,今天又都能弹。返回删除行数。
|
||||
(不按 package 过滤:重置是"把今天清干净从头测",清全部 App 最符合预期。)"""
|
||||
result = db.execute(
|
||||
delete(CouponPromptEngagement).where(
|
||||
CouponPromptEngagement.device_id == device_id,
|
||||
CouponPromptEngagement.engage_date == today_cn(),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
# ===== 今日跑完整轮(coupon_daily_completion)=====
|
||||
|
||||
def has_completed_today(db: Session, device_id: str) -> bool:
|
||||
"""这台设备今天是否已跑完整轮领券(到 done 帧)。有 = 首页置灰、不能再领。"""
|
||||
row = db.execute(
|
||||
select(CouponDailyCompletion.id).where(
|
||||
CouponDailyCompletion.device_id == device_id,
|
||||
CouponDailyCompletion.complete_date == today_cn(),
|
||||
)
|
||||
).first()
|
||||
return row is not None
|
||||
|
||||
|
||||
def mark_completed_today(
|
||||
db: Session, device_id: str, user_id: int | None, trace_id: str | None = None
|
||||
) -> None:
|
||||
"""记今日已跑完整轮。(device, 今天) 唯一,幂等 upsert。到 done 即记,不管单券成败。"""
|
||||
today = today_cn()
|
||||
row = db.execute(
|
||||
select(CouponDailyCompletion).where(
|
||||
CouponDailyCompletion.device_id == device_id,
|
||||
CouponDailyCompletion.complete_date == today,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is not None:
|
||||
if user_id is not None:
|
||||
row.user_id = user_id
|
||||
if trace_id is not None:
|
||||
row.trace_id = trace_id
|
||||
else:
|
||||
db.add(CouponDailyCompletion(
|
||||
device_id=device_id, user_id=user_id,
|
||||
complete_date=today, trace_id=trace_id,
|
||||
))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。
|
||||
db.rollback()
|
||||
|
||||
|
||||
def reset_today_completion(db: Session, device_id: str) -> int:
|
||||
"""删这台设备今天的"已完成"记录(开发设置「重置今日领券弹窗状态」全重置时调)。
|
||||
删后 has_completed_today → false,首页「去领取」卡恢复可点。返回删除行数。"""
|
||||
result = db.execute(
|
||||
delete(CouponDailyCompletion).where(
|
||||
CouponDailyCompletion.device_id == device_id,
|
||||
CouponDailyCompletion.complete_date == today_cn(),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
# ===== 领券记录(coupon_claim_record)=====
|
||||
|
||||
def record_claims(
|
||||
|
||||
@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.invite import InviteRelation
|
||||
from app.models.invite_fingerprint import InviteFingerprint
|
||||
from app.models.user import User
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
@@ -164,3 +165,121 @@ def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||||
).scalar_one()
|
||||
return int(count), int(coins)
|
||||
|
||||
|
||||
def _mask_phone(phone: str) -> str:
|
||||
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
|
||||
|
||||
11 位标准手机号 → 前 3 + **** + 后 4;非标准(异常/截断)→ 只露后 4 位;太短 → "新用户"。
|
||||
"""
|
||||
p = (phone or "").strip()
|
||||
if len(p) == 11:
|
||||
return f"{p[:3]}****{p[-4:]}"
|
||||
if len(p) >= 4:
|
||||
return f"****{p[-4:]}"
|
||||
return "新用户"
|
||||
|
||||
|
||||
def get_invitees(
|
||||
db: Session, inviter_id: int, *, limit: int = 20, offset: int = 0
|
||||
) -> tuple[list[dict], int, bool]:
|
||||
"""查某邀请人的被邀请人列表(按邀请时间倒序, 分页),返回 (items, total, has_more)。
|
||||
|
||||
每条 item = {display_name, avatar_url, coins, invited_at}。降级兜底:
|
||||
名字 = 昵称 → 微信昵称 → 脱敏手机号(很多被邀请人是刚注册新用户、没设资料);
|
||||
头像 = 用户头像 → 微信头像 → None(前端画默认色块)。
|
||||
邀请关系表 join 用户表;total 单独 count(分页时算 has_more)。
|
||||
"""
|
||||
total = db.execute(
|
||||
select(func.count())
|
||||
.select_from(InviteRelation)
|
||||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||||
).scalar_one()
|
||||
|
||||
rows = db.execute(
|
||||
select(InviteRelation, User)
|
||||
.join(User, User.id == InviteRelation.invitee_user_id)
|
||||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||||
.order_by(InviteRelation.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
).all()
|
||||
|
||||
items: list[dict] = []
|
||||
for rel, u in rows:
|
||||
items.append({
|
||||
"display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone),
|
||||
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
|
||||
"coins": rel.inviter_coin,
|
||||
"invited_at": rel.created_at,
|
||||
})
|
||||
has_more = offset + len(rows) < int(total)
|
||||
return items, int(total), has_more
|
||||
|
||||
|
||||
# ===== 指纹归因(剪贴板兜底,见 [[invite-three-tasks]] 任务 3)=====
|
||||
|
||||
def record_fingerprint(
|
||||
db: Session,
|
||||
*,
|
||||
inviter_user_id: int,
|
||||
ip: str,
|
||||
screen: str,
|
||||
device_model: str,
|
||||
user_agent: str,
|
||||
) -> InviteFingerprint:
|
||||
"""落地页 dl.html 访问时调用,写一行指纹记录。
|
||||
|
||||
后续被邀请人登录、剪贴板没拿到邀请码时,/bind 会按 (ip, screen, device_model) 反查本表
|
||||
7 天内最近一条匹配 → 拿出 inviter_user_id 撞库。
|
||||
|
||||
本函数会 commit;调用方(/landing-track endpoint)在此之前无其它写操作。
|
||||
"""
|
||||
fp = InviteFingerprint(
|
||||
inviter_user_id=inviter_user_id,
|
||||
ip=ip[:64],
|
||||
screen=screen[:32],
|
||||
device_model=device_model[:64],
|
||||
user_agent=user_agent[:2000] if user_agent else "", # 截一下防异常长 UA
|
||||
)
|
||||
db.add(fp)
|
||||
db.commit()
|
||||
db.refresh(fp)
|
||||
return fp
|
||||
|
||||
|
||||
def resolve_inviter_by_fingerprint(
|
||||
db: Session,
|
||||
*,
|
||||
ip: str,
|
||||
screen: str,
|
||||
device_model: str,
|
||||
window_days: int,
|
||||
) -> User | None:
|
||||
"""根据指纹反查邀请人(time window 内最近一条匹配)。
|
||||
|
||||
匹配规则:(ip, device_model) 相等 + created_at > now - window_days。
|
||||
|
||||
screen 字段**只入库不反查**:浏览器算物理像素走 `CSS × devicePixelRatio` 路径、
|
||||
Android 走 `DisplayMetrics.widthPixels` 真实硬件值,两端浮点 round 必然 ±1 像素漂移
|
||||
(DPR 不严格是整数)→ 严格匹配注定撞不上。同 device_model 必同 screen(同型号同硬件)
|
||||
→ screen 是冗余字段,删它不损精度。撞错只有"同 IP 下同型号手机同时被邀请"才会
|
||||
发生,中国家庭/公司 WiFi 场景概率极低。
|
||||
"""
|
||||
if not ip:
|
||||
return None
|
||||
from datetime import datetime, timedelta, timezone
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=window_days)
|
||||
fp = db.execute(
|
||||
select(InviteFingerprint)
|
||||
.where(
|
||||
InviteFingerprint.ip == ip,
|
||||
InviteFingerprint.device_model == device_model,
|
||||
InviteFingerprint.created_at > cutoff,
|
||||
)
|
||||
.order_by(InviteFingerprint.created_at.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if fp is None:
|
||||
return None
|
||||
return db.get(User, fp.inviter_user_id)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""新手引导完成标记的读写(按 user_id + device_id 去重)。
|
||||
|
||||
登录时 [is_completed] 判断该 (账号, 设备) 是否走过引导;走完引导时 [mark_completed] 写一条。
|
||||
device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理,即照常走引导,不误跳过。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.onboarding import OnboardingCompletion
|
||||
|
||||
|
||||
def is_completed(db: Session, *, user_id: int, device_id: str) -> bool:
|
||||
"""该 (账号, 设备) 是否已完成新手引导。device_id 为空 → 一律 False。"""
|
||||
if not device_id:
|
||||
return False
|
||||
stmt = select(OnboardingCompletion.id).where(
|
||||
OnboardingCompletion.user_id == user_id,
|
||||
OnboardingCompletion.device_id == device_id,
|
||||
)
|
||||
return db.execute(stmt).first() is not None
|
||||
|
||||
|
||||
def mark_completed(db: Session, *, user_id: int, device_id: str) -> None:
|
||||
"""走完引导时写入。device_id 为空忽略;同 (账号, 设备) 已存在则幂等(靠唯一约束兜并发)。"""
|
||||
if not device_id:
|
||||
return
|
||||
db.add(OnboardingCompletion(user_id=user_id, device_id=device_id))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发 / 重复提交撞唯一约束:已有行即视为成功。
|
||||
db.rollback()
|
||||
+224
-48
@@ -5,51 +5,150 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条
|
||||
|
||||
种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、
|
||||
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。
|
||||
|
||||
脱敏名规则(全 feed 统一,按字符算、中英文皆适用):
|
||||
- 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 / SaveKing→S******g),
|
||||
2 字「首+星」(阿强→阿*),1 字「用户+该字」(喵→用户喵);
|
||||
- 没昵称 → 合成一个假名(见下)再脱敏,少数露「用户+id 后 3 位」(用户********618)。
|
||||
真实条:设过昵称的按真实昵称脱敏;当前多数用户没设昵称,直接展示「用户****」会清一色,故给它们合成
|
||||
假名——后期真实昵称多了,真实条会直接用真实昵称,合成占比自然下降。种子 / 兜底同样合成、避开同屏撞名;
|
||||
种子留空 / 旧「用户****xxx」模板名一律重新合成(自愈历史种子,无需迁移)。
|
||||
假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾),
|
||||
组合空间上万、中文为主英文为辅,脱敏后像真实异质用户群。**真实条按 `user_id` 播种确定性合成**(同一用户
|
||||
每次展示恒定、不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充。不依赖外部库。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed
|
||||
from app.models.user import User
|
||||
|
||||
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
|
||||
_rng = random.Random()
|
||||
|
||||
_SUFFIX_HEX = 3 # 脱敏用户名后缀位数,与真实条 md5[:3] 对齐
|
||||
_HEX = "0123456789abcdef"
|
||||
# 真实条金额上限(300 元):超过视为异常 / bug 值,不进轮播(防「节省 999 元」穿帮)
|
||||
_REAL_MAX_CENTS = 30000
|
||||
# 种子金额防呆上限(1000 元):运营手滑填超大值时拦下
|
||||
_SEED_MAX_CENTS = 100000
|
||||
|
||||
# 真实记录查询缓存:首页人人都看、真实数据变化慢 → 缓存原始行 ~30s,省掉高频 DB 往返。
|
||||
# 展示层随机(抽样/金额/时间/名字合成)仍每次重算,缓存只省查询;新记录最多晚 30s 进轮播,可接受。
|
||||
_REAL_ROWS_TTL_SECONDS = 30
|
||||
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
|
||||
_real_rows_cache: dict = {"at": None, "rows": None}
|
||||
|
||||
def _mask_user(user_id: int) -> str:
|
||||
"""真实用户脱敏:用户********+3 位哈希后缀(稳定、不暴露真实信息)。"""
|
||||
suffix = hashlib.md5(str(user_id).encode()).hexdigest()[:_SUFFIX_HEX]
|
||||
return f"用户********{suffix}"
|
||||
# ===== 用户标识脱敏 + 没真实昵称时的假名合成 =====
|
||||
# 脱敏规则(中英文皆适用,按字符算):有昵称→≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 /
|
||||
# SaveKing→S******g)、2 字「首+星」(阿强→阿*)、1 字「用户+该字」(喵→用户喵);没昵称→用户+8星+id 后 3 位。
|
||||
# 没真实昵称时(当前多数用户 / 种子 / 兜底)合成假名:本地姓/名字池组合出真名 + 网络昵称语料(中/英)
|
||||
# 混播再走同一套脱敏,像真实异质用户群。后期真实昵称多了,真实条直接用真实昵称,这里占比自然下降。
|
||||
_ANON_STARS = "*" * 8 # 无昵称匿名串固定 8 星(用户********xxx)
|
||||
|
||||
# 中文真名组合池:姓(百家姓常见 ~100)× 名字单字(~120)拼「姓 + 1~2 字」,组合空间上万。
|
||||
# 脱敏后只露「姓*」或「姓*末」,故无需真实姓名库——组合够多即看着各异(替代原 Faker zh_CN)。
|
||||
_SURNAME_ZH = list(
|
||||
"王李张刘陈杨赵黄周吴徐孙胡朱高林何郭马罗梁宋郑谢韩唐冯于董萧程曹袁邓许傅沈曾彭吕苏卢蒋蔡贾丁"
|
||||
"魏薛叶阎余潘杜戴夏钟汪田任姜范方石姚谭廖邹熊金陆郝孔白崔康毛邱秦江史顾侯邵孟龙万段钱汤尹黎"
|
||||
"易常武乔贺赖龚文施洪丰房邢"
|
||||
)
|
||||
_GIVEN_ZH = list(
|
||||
"伟芳娜秀英敏静丽强磊军洋勇艳杰娟涛明超霞平刚桂兰华健世广义兴良海山仁波宁贵福生龙元全国胜学祥"
|
||||
"才发新利清飞彬富顺信子昌成康星光天达安岩中茂进有坚和彪博诚先敬震振壮会思群豪心邦承乐绍功松善"
|
||||
"厚庆民友裕河哲亮政谦亨奇之轮翰朗伯宏言若鸣朋斌栋维启克伦翔旭鹏泽晨辰士以建家致树炎德行时泰盛雄"
|
||||
)
|
||||
# 英文名池:与英文昵称互补,偶尔带数字尾(jay87)更像注册用户名(替代原 Faker en_US)。
|
||||
_FIRST_EN = [
|
||||
"Alex", "Chris", "Sam", "Jamie", "Taylor", "Jordan", "Casey", "Morgan", "Riley", "Jessie",
|
||||
"Robin", "Drew", "Lee", "Max", "Kim", "Ray", "Dana", "Pat", "Terry", "Jay",
|
||||
"Kelly", "Sky", "Ash", "Nico", "Remy", "Quinn", "Reese", "Sage", "Toni", "Val",
|
||||
]
|
||||
|
||||
# 网络昵称语料:补一份「省钱小能手 / SaveKing」式网络昵称更像真实 app 用户。想更丰富往里加即可。
|
||||
_NICK_ZH = [
|
||||
"省钱小能手", "薅羊毛专业户", "吃货一枚", "干饭人", "奶茶续命", "精打细算过日子",
|
||||
"会过日子的人", "捡漏王", "外卖救星", "养生少年", "持家有道", "比价狂魔",
|
||||
"月光族逆袭", "摸鱼达人", "暴富锦鲤", "美食猎人", "折扣猎手", "隐形贫困人口",
|
||||
"打工不打烊", "佛系青年", "元气满满", "一只小馋猫", "爱吃的小朋友", "省钱才是硬道理",
|
||||
]
|
||||
_NICK_EN = [
|
||||
"SaveKing", "DealHunter", "FoodieLife", "BudgetBoss", "CouponQueen", "ThriftyOne",
|
||||
"SnackLover", "BargainHero", "PennyWise", "SmartShopper", "DiscountNinja", "HungryPanda",
|
||||
"MoneyMaster", "CheapEats", "SaverPro", "FrugalFox", "NoodleKing", "BubbleTeaFan",
|
||||
"GoldenSaver", "ValueHunter", "SnackAttack", "LazyFoodie",
|
||||
]
|
||||
|
||||
|
||||
def _random_suffix(used: set[str]) -> str:
|
||||
"""随机 3 位 hex 后缀,避开 used(真实条 + 已生成种子),防同屏撞名。"""
|
||||
def _mask_nickname(nick: str) -> str:
|
||||
"""昵称脱敏(按字符,中英文皆可):≥3 字→首+(隐藏字数个)星+末(省钱小能手→省***手 / SaveKing→S******g);
|
||||
2 字→首+星(阿强→阿*);1 字→「用户」+该字(喵→用户喵)。调用方需保证 nick 已 strip 且非空。"""
|
||||
n = len(nick)
|
||||
if n >= 3:
|
||||
return nick[0] + "*" * (n - 2) + nick[-1]
|
||||
if n == 2:
|
||||
return nick[0] + "*"
|
||||
return "用户" + nick
|
||||
|
||||
|
||||
def _anon_by_id(user_id: int) -> str:
|
||||
"""没昵称匿名:固定「用户」+ 8 星 + id 后 3 位(不足 3 位前补 0):用户********618。"""
|
||||
return "用户" + _ANON_STARS + f"{user_id % 1000:03d}"
|
||||
|
||||
|
||||
def _synth_full_name(rng: random.Random) -> str:
|
||||
"""合成一个完整(未脱敏)假名:中文真名(姓+1~2字) / 中文网络昵称 / 英文昵称 / 英文名(可带数字尾) 混播,
|
||||
中文为主(~70%)英文为辅(~30%)。**全程用传入 rng** → 同种子复现、不同种子各异、组合空间上万。"""
|
||||
r = rng.random()
|
||||
if r < 0.35:
|
||||
# 中文真名:姓 + 1~2 个名字字(60% 概率两字),脱敏后露「姓*」或「姓*末」
|
||||
given = rng.choice(_GIVEN_ZH) + (rng.choice(_GIVEN_ZH) if rng.random() < 0.6 else "")
|
||||
return rng.choice(_SURNAME_ZH) + given # 王伟 / 李秀兰
|
||||
if r < 0.70:
|
||||
return rng.choice(_NICK_ZH) # 中文网络昵称:省钱小能手
|
||||
if r < 0.85:
|
||||
return rng.choice(_NICK_EN) # 英文昵称:SaveKing
|
||||
base = rng.choice(_FIRST_EN) # 英文名,半数带数字尾:Jay87 / Alex
|
||||
return base + (str(rng.randint(1, 99)) if rng.random() < 0.5 else "")
|
||||
|
||||
|
||||
def _synth_masked_name(rng: random.Random) -> str:
|
||||
"""合成一条「已脱敏」假名(供没真实昵称的真实用户 / 种子 / 兜底用)。用传入 rng 决定一切随机。"""
|
||||
full = _synth_full_name(rng).strip()
|
||||
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
|
||||
|
||||
|
||||
def _mask_real(nickname: str | None, user_id: int) -> str:
|
||||
"""真实用户脱敏:设过昵称→昵称脱敏(中英文皆可);没昵称→**按 user_id 播种确定性合成假名**再脱敏
|
||||
(同一用户每次展示恒定、刷新不变脸,不同用户各异),避免无昵称用户清一色「用户****xxx」;
|
||||
少数(~15%)露「用户+真实 id 后 3 位」。当前多数用户没昵称,后期真实昵称多了占比下降。"""
|
||||
nick = (nickname or "").strip()
|
||||
if nick:
|
||||
return _mask_nickname(nick)
|
||||
# 关键:按 user_id 播种本地 rng → 同一用户的合成名跨请求/刷新恒定(0.15 抽签也用它)。
|
||||
seeded = random.Random(user_id)
|
||||
return _anon_by_id(user_id) if seeded.random() < 0.15 else _synth_masked_name(seeded)
|
||||
|
||||
|
||||
def _synth_name(rng: random.Random) -> str:
|
||||
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 3 位(用户********618)。"""
|
||||
if rng.random() < 0.15:
|
||||
return "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
|
||||
return _synth_masked_name(rng)
|
||||
|
||||
|
||||
def _unique_name(used: set[str]) -> str:
|
||||
"""随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名 / 兜底用),防同屏撞名。"""
|
||||
for _ in range(60):
|
||||
s = "".join(_rng.choice(_HEX) for _ in range(_SUFFIX_HEX))
|
||||
if s not in used:
|
||||
used.add(s)
|
||||
return s
|
||||
# 兜底(几乎不会到):线性扫一个空位
|
||||
for n in range(16 ** _SUFFIX_HEX):
|
||||
s = format(n, f"0{_SUFFIX_HEX}x")
|
||||
if s not in used:
|
||||
used.add(s)
|
||||
return s
|
||||
return "000"
|
||||
n = _synth_name(_rng)
|
||||
if n not in used:
|
||||
return n
|
||||
return "用户" + _ANON_STARS + f"{_rng.randint(0, 999):03d}" # 兜底:数字尾号天然好去重
|
||||
|
||||
|
||||
def _validate_amount(min_cents: int, max_cents: int) -> None:
|
||||
@@ -61,37 +160,69 @@ def _validate_amount(min_cents: int, max_cents: int) -> None:
|
||||
raise ValueError(f"金额上限过大(≤ {_SEED_MAX_CENTS // 100} 元)")
|
||||
|
||||
|
||||
# ===== 用户侧:轮播 feed(真实 + 种子混播) =====
|
||||
def get_feed(db: Session, limit: int = 8) -> list[dict]:
|
||||
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
|
||||
|
||||
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
|
||||
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
|
||||
种子用户名留空则随机合成(避开撞名),金额在 [min,max] 区间随机取值。
|
||||
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前递减),保证轮播永远像刚发生
|
||||
(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时/未来时间)。
|
||||
def _skewed_amount(lo: int, hi: int) -> int:
|
||||
"""长尾金额:多数贴近下限、偶尔接近上限(比均匀随机更像真实省钱分布)。
|
||||
用 u**2.2 把 [0,1) 均匀值压向 0 → 小额居多;偶尔 u 接近 1 给出大额。
|
||||
"""
|
||||
# 真实条:取较多近期记录后按 user 去重;金额超上限的异常值剔除(防穿帮)。
|
||||
if hi <= lo:
|
||||
return max(0, lo)
|
||||
u = _rng.random() ** 2.2
|
||||
return lo + int(round((hi - lo) * u))
|
||||
|
||||
|
||||
# 兜底金额区间(真实 + 种子都凑不满 limit 时,内置合成条用):3~35 元
|
||||
_FALLBACK_MIN_CENTS = 300
|
||||
_FALLBACK_MAX_CENTS = 3500
|
||||
|
||||
|
||||
# ===== 用户侧:轮播 feed(真实 + 种子混播) =====
|
||||
def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
|
||||
"""近期 success 且省到钱的 (user_id, saved_cents, nickname),按 created_at desc;带 ~30s 进程内缓存。
|
||||
join user 取真实昵称用于脱敏(无昵称则展示层走「用户+id 后 3 位」)。
|
||||
返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。
|
||||
"""
|
||||
now = datetime.now(CN_TZ)
|
||||
cached, at = _real_rows_cache["rows"], _real_rows_cache["at"]
|
||||
if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS:
|
||||
return cached
|
||||
rows = db.execute(
|
||||
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents)
|
||||
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents, User.nickname)
|
||||
.join(User, User.id == ComparisonRecord.user_id)
|
||||
.where(
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
|
||||
)
|
||||
.order_by(ComparisonRecord.created_at.desc())
|
||||
.limit(max(limit * 20, 100))
|
||||
.limit(_REAL_ROWS_FETCH_CAP)
|
||||
).all()
|
||||
out = [(int(uid), int(sc), nick) for uid, sc, nick in rows]
|
||||
_real_rows_cache["rows"], _real_rows_cache["at"] = out, now
|
||||
return out
|
||||
|
||||
|
||||
def get_feed(db: Session, limit: int = 8) -> list[dict]:
|
||||
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
|
||||
|
||||
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
|
||||
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
|
||||
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。
|
||||
真实 + 种子仍不满 limit → 内置合成条**补满**,保证轮播既不空也不稀疏。
|
||||
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、
|
||||
节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。
|
||||
"""
|
||||
# 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
|
||||
rows = _recent_real_rows(db)
|
||||
|
||||
items: list[dict] = []
|
||||
used_suffixes: set[str] = set()
|
||||
used_names: set[str] = set()
|
||||
seen_users: set[int] = set()
|
||||
for uid, sc in rows:
|
||||
for uid, sc, nick in rows:
|
||||
if uid in seen_users:
|
||||
continue
|
||||
seen_users.add(uid)
|
||||
name = _mask_user(uid)
|
||||
used_suffixes.add(name[-_SUFFIX_HEX:])
|
||||
name = _mask_real(nick, uid)
|
||||
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
|
||||
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
|
||||
if len(items) >= limit:
|
||||
break
|
||||
@@ -105,22 +236,41 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
|
||||
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
|
||||
_rng.shuffle(chosen)
|
||||
for s in chosen:
|
||||
# 用户名:填了固定名优先;留空则按脱敏格式随机合成(避开撞名)。
|
||||
if s.masked_user and s.masked_user.strip():
|
||||
name = s.masked_user.strip()
|
||||
used_suffixes.add(name[-_SUFFIX_HEX:])
|
||||
else:
|
||||
name = f"用户********{_random_suffix(used_suffixes)}"
|
||||
# 金额:在 [min,max] 随机一个(固定金额则 min==max)。
|
||||
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
|
||||
# 仅运营手动设的非模板真名才原样用。
|
||||
fixed = (s.masked_user or "").strip()
|
||||
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
|
||||
used_names.add(name)
|
||||
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
|
||||
lo = max(0, int(s.min_cents))
|
||||
hi = max(lo, int(s.max_cents))
|
||||
cents = lo if lo >= hi else _rng.randint(lo, hi)
|
||||
items.append({"masked_user": name, "saved_amount_cents": cents})
|
||||
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
|
||||
|
||||
# 统一赋「最近」时间:首条约 20 秒前,其余每条往前 2~6 分钟,降序、节奏自然。
|
||||
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
|
||||
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
|
||||
while len(items) < limit:
|
||||
name = _unique_name(used_names)
|
||||
used_names.add(name)
|
||||
items.append({
|
||||
"masked_user": name,
|
||||
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
|
||||
})
|
||||
|
||||
# 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。
|
||||
# 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。
|
||||
cursor = datetime.now(CN_TZ)
|
||||
for i, it in enumerate(items):
|
||||
cursor = cursor - timedelta(seconds=20 if i == 0 else 60 * (2 + (i * 3) % 5))
|
||||
if i == 0:
|
||||
gap = _rng.randint(8, 40)
|
||||
else:
|
||||
r = _rng.random()
|
||||
if r < 0.2:
|
||||
gap = _rng.randint(20, 55) # 偶尔扎堆
|
||||
elif r < 0.85:
|
||||
gap = _rng.randint(60, 300) # 常规 1~5 分钟
|
||||
else:
|
||||
gap = _rng.randint(300, 540) # 偶尔较长 5~9 分钟
|
||||
cursor = cursor - timedelta(seconds=gap)
|
||||
it["time"] = cursor.strftime("%H:%M:%S")
|
||||
return items
|
||||
|
||||
@@ -244,3 +394,29 @@ def delete_seed(db: Session, seed_id: int, *, commit: bool = True) -> bool:
|
||||
else:
|
||||
db.flush()
|
||||
return True
|
||||
|
||||
|
||||
def batch_delete(db: Session, ids: list[int], *, commit: bool = True) -> int:
|
||||
"""批量删除选中的种子,返回实际删除条数(不存在的 id 自动跳过)。"""
|
||||
if not ids:
|
||||
return 0
|
||||
n = db.execute(delete(OpsMarqueeSeed).where(OpsMarqueeSeed.id.in_(ids))).rowcount
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
return int(n or 0)
|
||||
|
||||
|
||||
def batch_set_enabled(db: Session, ids: list[int], enabled: bool, *, commit: bool = True) -> int:
|
||||
"""批量启用 / 停用选中的种子,返回实际更新条数。"""
|
||||
if not ids:
|
||||
return 0
|
||||
n = db.execute(
|
||||
update(OpsMarqueeSeed).where(OpsMarqueeSeed.id.in_(ids)).values(enabled=enabled)
|
||||
).rowcount
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
return int(n or 0)
|
||||
|
||||
@@ -7,7 +7,8 @@ random 自增长支持两种方式(random_kind):
|
||||
- mult:×随机倍率([min,max]/1000,恒 ≥1.0)——复利、指数增长
|
||||
- add :+随机绝对增量([step_min,step_max])——线性、可控,更像真实平台日增
|
||||
|
||||
真实值(real)模式展示 = 真实统计 + real_offset(基数偏移),冷启动期既真实又体面。
|
||||
真实值(real)模式展示 = max(真实统计, real_offset 保底值):冷启动显示保底撑场面,真实值涨过
|
||||
保底后显示纯真实、零差距(real_offset 列名沿用,语义从"偏移"改为"保底")。
|
||||
|
||||
「只增不减」护栏(allow_decrease=False,默认):real/manual 刷新时若新目标值低于当前展示值,
|
||||
保持当前值不回退——门面数字最忌当众缩水。random 天然只增(倍率≥1 / 增量≥0)。
|
||||
@@ -116,8 +117,9 @@ def _current_target(db: Session, row: OpsStatConfig) -> int:
|
||||
if row.mode == "manual":
|
||||
return max(0, int(row.manual_value or 0))
|
||||
if row.mode == "real":
|
||||
# 真实值 + 基数偏移
|
||||
return max(0, int(_real_value(db, row.metric)) + int(row.real_offset or 0))
|
||||
# 真实值保底:展示 = max(真实值, 保底值)。冷启动真实值小→显示保底撑场面;
|
||||
# 真实值涨过保底后显示纯真实、零差距(real_offset 列名沿用,语义已从"偏移"改为"保底")。
|
||||
return max(0, int(_real_value(db, row.metric)), int(row.real_offset or 0))
|
||||
# random:无显式初始基数时用真实值(纯真实,不含偏移)播种
|
||||
return max(0, int(_real_value(db, row.metric)))
|
||||
|
||||
@@ -302,7 +304,7 @@ def update_config(
|
||||
raise ValueError("增量上限不得小于下限")
|
||||
if real_offset is not None:
|
||||
if real_offset < 0:
|
||||
raise ValueError("基数偏移需为非负整数")
|
||||
raise ValueError("保底值需为非负整数")
|
||||
row.real_offset = real_offset
|
||||
if allow_decrease is not None:
|
||||
row.allow_decrease = allow_decrease
|
||||
@@ -311,7 +313,9 @@ def update_config(
|
||||
if random_initial is not None:
|
||||
if random_initial < 0:
|
||||
raise ValueError("初始基数需为非负整数")
|
||||
row.random_current = random_initial # 设起点是运营明确意图,不受护栏约束
|
||||
# 初始基数受「只增不减」护栏(与 manual/real 一致):未勾 allow_decrease 且低于
|
||||
# 当前展示值时保持现值。首次无值(random_current is None)则护栏放行、直接设。
|
||||
row.random_current = _monotonic(row, random_initial)
|
||||
row.random_last_tick_at = now
|
||||
elif apply_now:
|
||||
# 立即更新:不等钟点,马上刷新一次
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""price_report 表读写。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.price_report import PriceReport
|
||||
|
||||
|
||||
@@ -35,6 +38,7 @@ def create_report(
|
||||
reported_price_cents=reported_price_cents,
|
||||
images=images,
|
||||
status="pending",
|
||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None), # 存北京 wall-clock(同 savings/comparison)
|
||||
)
|
||||
db.add(rep)
|
||||
db.commit()
|
||||
|
||||
@@ -179,6 +179,10 @@ def create_from_report(
|
||||
client_event_id=req.client_event_id,
|
||||
device_id=req.device_id,
|
||||
source="compare",
|
||||
# created_at 显式存 naive 北京 wall-clock(与 demo 行、聚合 _local_date 的 naive 分支一致)。
|
||||
# 不用列默认 server_default=func.now()——SQLite 下它返回 UTC,会让明细页时间早 8 小时。
|
||||
# ⚠️ 生产 PG(timestamptz)对 naive 的解释与 SQLite 不同,迁前端到 PG 时需确认时区一致。
|
||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""签到 CRUD:状态查询 + 执行签到。
|
||||
|
||||
14 天循环规则:
|
||||
- 昨天签过 → 今天 cycle_day = 昨天 % 14 + 1,streak += 1(连续)
|
||||
7 天循环规则(2026-06 由 14 天改 7 天一轮,周期长度统一取 SIGNIN_CYCLE_LEN):
|
||||
- 昨天签过 → 今天 cycle_day = 昨天 % SIGNIN_CYCLE_LEN + 1,streak += 1(连续)
|
||||
- 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置)
|
||||
今天的 cycle_day 决定发多少金币(档位来自 rewards.get_signin_rewards,运营后台可改)。
|
||||
"""
|
||||
@@ -32,6 +32,10 @@ class AlreadyBoostedError(Exception):
|
||||
"""今天签到奖励已经膨胀过。"""
|
||||
|
||||
|
||||
class LastCycleDayBoostBlockedError(Exception):
|
||||
"""循环最后一天(第 SIGNIN_CYCLE_LEN 天)不允许签到膨胀。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SigninStep:
|
||||
day: int # 1..14
|
||||
@@ -73,7 +77,8 @@ def get_status(db: Session, user_id: int) -> SigninStatus:
|
||||
today_signed = last is not None and last.signin_date == today
|
||||
|
||||
if today_signed:
|
||||
today_cycle_day = last.cycle_day
|
||||
# 14→7 改制遗留:老记录 cycle_day 可能 >SIGNIN_CYCLE_LEN,归一化到 1..LEN 防 rewards_list 越界
|
||||
today_cycle_day = (last.cycle_day - 1) % SIGNIN_CYCLE_LEN + 1
|
||||
consecutive_days = last.streak
|
||||
else:
|
||||
today_cycle_day = _next_cycle_day(last, today)
|
||||
@@ -148,13 +153,27 @@ def _today_record(db: Session, user_id: int) -> SigninRecord | None:
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def boost_by_ad_ref(
|
||||
db: Session, user_id: int, ad_ref_id: str
|
||||
) -> SigninBoostRecord | None:
|
||||
"""按广告交易号查签到膨胀记录。S2S 发奖后客户端确认用。"""
|
||||
return db.execute(
|
||||
select(SigninBoostRecord).where(
|
||||
SigninBoostRecord.user_id == user_id,
|
||||
SigninBoostRecord.ad_ref_id == ad_ref_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def boost_today_signin(
|
||||
db: Session, user_id: int, *, ad_ref_id: str | None = None
|
||||
db: Session, user_id: int, *, ad_ref_id: str | None = None, commit: bool = True
|
||||
) -> tuple[SigninBoostRecord, int]:
|
||||
"""签到后看广告膨胀:补发一笔等额签到金币。返回 (膨胀记录, 补发后余额)。"""
|
||||
"""签到后看广告膨胀:固定补发配置金币。返回 (膨胀记录, 补发后余额)。"""
|
||||
record = _today_record(db, user_id)
|
||||
if record is None:
|
||||
raise NotSignedTodayError
|
||||
if record.cycle_day == SIGNIN_CYCLE_LEN:
|
||||
raise LastCycleDayBoostBlockedError
|
||||
|
||||
today = record.signin_date
|
||||
existing = db.execute(
|
||||
@@ -169,17 +188,20 @@ def boost_today_signin(
|
||||
boost = SigninBoostRecord(
|
||||
user_id=user_id,
|
||||
signin_date=today,
|
||||
coin_awarded=record.coin_awarded,
|
||||
coin_awarded=rewards.get_signin_boost_coin(db),
|
||||
ad_ref_id=ad_ref_id,
|
||||
)
|
||||
db.add(boost)
|
||||
try:
|
||||
acc, _ = crud_wallet.grant_coins(
|
||||
db, user_id, record.coin_awarded,
|
||||
biz_type="signin_boost", ref_id=today.isoformat(),
|
||||
db, user_id, boost.coin_awarded,
|
||||
biz_type="signin_boost", ref_id=ad_ref_id or today.isoformat(),
|
||||
remark=f"签到膨胀 第{record.cycle_day}天",
|
||||
)
|
||||
db.commit()
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise AlreadyBoostedError from e
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""平台店铺映射落库:一次比价(trace)一行,各目标平台解析出 id 就 upsert 进同一行。
|
||||
|
||||
合并键 = trace_id(唯一约束)。一次跨平台比价 = 一个 trace = 一个真实店铺:淘宝腿解析出
|
||||
shopId 先建行(填淘宝列),京东腿(将来)解析出 id 再 upsert 进**同一行**(填京东列)。
|
||||
|
||||
合并策略 = 填空(fill-the-blanks):只写该行当前为 NULL 的列,绝不覆盖已有非空值。保证后到
|
||||
的平台只填自己那几列、动不了先到平台的数据;共享列(geo / source / 溯源)先到先得。
|
||||
不依赖 ON CONFLICT,跨方言(PG / SQLite dev)都安全;并发撞唯一约束则回滚后转走合并路径。
|
||||
|
||||
⚠️ 一行里 id_taobao 与 id_jd 共存只表示"两条腿搜同一个源店名各自匹配到了某家店",是
|
||||
name-match 置信度、非已核实同一实体。作为 append-only 原始资产留存,跨平台精确匹配由下游做。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.store_mapping import StoreMapping
|
||||
from app.schemas.store_mapping import StoreMappingIn
|
||||
|
||||
logger = logging.getLogger("shagua.store_mapping")
|
||||
|
||||
# 源平台 → 该平台店名所在列(缓存查询的匹配键)。镜像 pricebot reporter 的同名表。
|
||||
_SOURCE_NAME_COLUMN = {
|
||||
"meituan": "name_meituan",
|
||||
"meituan_waimai": "name_meituan",
|
||||
"jd_waimai": "name_jd",
|
||||
"jd_waimai_standalone": "name_jd",
|
||||
"taobao_flash": "name_taobao",
|
||||
}
|
||||
|
||||
# 源平台 → 它自己对应的目标 key(查缓存时排除"源平台自己", 不会复用源平台的店铺 id)。
|
||||
_SOURCE_TARGET_KEY = {
|
||||
"meituan": "meituan", "meituan_waimai": "meituan",
|
||||
"jd_waimai": "jd", "jd_waimai_standalone": "jd",
|
||||
"taobao_flash": "taobao",
|
||||
}
|
||||
|
||||
# upsert 填空时可写的列。不含: trace_id(合并键)/ business_type(非空默认)/
|
||||
# id(主键)/ created_at(server_default)。各平台只会带自己那几列非空, 其余为 None 不动。
|
||||
_MERGE_COLUMNS = (
|
||||
"source_platform",
|
||||
"id_taobao", "name_taobao", "id_meituan", "name_meituan", "id_jd", "name_jd",
|
||||
"city", "geohash", "lng", "lat", "taobao_address",
|
||||
"source_device_id", "source_user_id",
|
||||
"taobao_share_url", "taobao_resolved_url", "taobao_deeplink",
|
||||
"meituan_poi_id_str", "meituan_share_url", "meituan_resolved_url", "meituan_deeplink",
|
||||
"jd_vender_id", "jd_share_url", "jd_resolved_url", "jd_deeplink",
|
||||
"attrs",
|
||||
)
|
||||
|
||||
|
||||
def _find(db: Session, trace_id: str) -> StoreMapping | None:
|
||||
return db.execute(
|
||||
select(StoreMapping).where(StoreMapping.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _merge_fill_blanks(existing: StoreMapping, payload: StoreMappingIn) -> list[str]:
|
||||
"""把 payload 里非空、且 existing 当前为 NULL 的列填进去。返回被填的列名(空=无变化)。"""
|
||||
filled: list[str] = []
|
||||
for col in _MERGE_COLUMNS:
|
||||
new = getattr(payload, col)
|
||||
if new is not None and getattr(existing, col) is None:
|
||||
setattr(existing, col, new)
|
||||
filled.append(col)
|
||||
return filled
|
||||
|
||||
|
||||
def upsert(db: Session, payload: StoreMappingIn) -> tuple[int, int | None]:
|
||||
"""落一行跨平台店铺映射,返回 (created, row_id)。
|
||||
created=1 新建该 trace 的行 / 0 合并进已存在行(填空,不覆盖)。"""
|
||||
existing = _find(db, payload.trace_id)
|
||||
if existing is None:
|
||||
# 首写: 把 payload 全部可合并列灌进去(逐列 setattr 而非硬编码构造器, 否则首写的是
|
||||
# 美团/京东腿时它们的列会漏 —— 不在硬编码列表里就丢)。trace_id/business_type 是键/默认, 显式给。
|
||||
row = StoreMapping(
|
||||
trace_id=payload.trace_id,
|
||||
business_type=payload.business_type,
|
||||
)
|
||||
for col in _MERGE_COLUMNS:
|
||||
setattr(row, col, getattr(payload, col))
|
||||
db.add(row)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发: 另一个请求刚插了同 trace → 撞唯一约束。回滚后转合并路径填空。
|
||||
db.rollback()
|
||||
existing = _find(db, payload.trace_id)
|
||||
if existing is None:
|
||||
raise
|
||||
logger.warning("store_mapping 并发冲突 trace=%s, 转填空合并", payload.trace_id)
|
||||
else:
|
||||
db.refresh(row)
|
||||
return 1, row.id
|
||||
|
||||
# 已存在(或并发回退到此): 填空合并, 只写当前 NULL 的列
|
||||
filled = _merge_fill_blanks(existing, payload)
|
||||
if filled:
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
logger.info("store_mapping 合并 trace=%s 填列=%s", payload.trace_id, filled)
|
||||
return 0, existing.id
|
||||
|
||||
|
||||
def mark_taobao_deeplink_invalid(db: Session, shop_id: str) -> int:
|
||||
"""把所有 id_taobao=shop_id 的行标记淘宝 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。
|
||||
|
||||
按 shopId 标记**所有**行 —— 同一个坏 shopId(撞淘宝"页面出错了"降级页)可能散在多次比价的
|
||||
多行里, 全标掉才能让后续 lookup 不再返回它。幂等: 已标记的行(invalid_at 非 NULL)跳过。"""
|
||||
result = db.execute(
|
||||
update(StoreMapping)
|
||||
.where(
|
||||
StoreMapping.id_taobao == shop_id,
|
||||
StoreMapping.taobao_deeplink_invalid_at.is_(None),
|
||||
)
|
||||
.values(taobao_deeplink_invalid_at=func.now())
|
||||
)
|
||||
db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接
|
||||
# deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。
|
||||
# ============================================================
|
||||
|
||||
def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
||||
"""两点球面距离(km)。仅用于同名候选里挑最近, 精度够用。"""
|
||||
r = 6371.0
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp = math.radians(lat2 - lat1)
|
||||
dl = math.radians(lng2 - lng1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def _pick_best(rows: list[StoreMapping], lat: float | None, lng: float | None) -> StoreMapping:
|
||||
"""同名 + 含目标 id 的候选里挑一条:有入参 geo 且有候选带 geo → 取最近;否则取 created_at 最新。"""
|
||||
if lat is not None and lng is not None:
|
||||
geod = [r for r in rows if r.lat is not None and r.lng is not None]
|
||||
if geod:
|
||||
return min(geod, key=lambda r: _haversine_km(lat, lng, r.lat, r.lng))
|
||||
return max(rows, key=lambda r: r.created_at)
|
||||
|
||||
|
||||
# 目标 key → (该平台店铺 id 列, 组装返回 payload 的函数)。pricebot 拿 id 现拼 deeplink。
|
||||
_TARGETS = {
|
||||
"taobao": ("id_taobao", lambda r: {"shop_id": r.id_taobao, "deeplink": r.taobao_deeplink}),
|
||||
"jd": ("id_jd", lambda r: {"store_id": r.id_jd, "vender_id": r.jd_vender_id, "deeplink": r.jd_deeplink}),
|
||||
"meituan": ("meituan_poi_id_str", lambda r: {"poi_id_str": r.meituan_poi_id_str, "deeplink": r.meituan_deeplink}),
|
||||
}
|
||||
|
||||
|
||||
def lookup_nearest(
|
||||
db: Session, source_platform: str, store_name: str,
|
||||
lat: float | None = None, lng: float | None = None,
|
||||
) -> dict:
|
||||
"""按"源平台店名"反查各目标平台已沉淀的店铺 id。返回 {target_key: {id..., deeplink, row_id, ...}}。
|
||||
- 匹配键 = 源平台对应的 name 列 == store_name(精确)。
|
||||
- 每个目标**分别**取"含该目标 id 的同名候选里最近一条"(淘宝 id / 京东 id 可能在不同行)。
|
||||
- 排除源平台自己(不复用源平台的店铺 id)。命中为空 = 没缓存, pricebot 走现场反查老路。"""
|
||||
name_col = _SOURCE_NAME_COLUMN.get(source_platform)
|
||||
if not name_col or not store_name:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(StoreMapping).where(getattr(StoreMapping, name_col) == store_name)
|
||||
).scalars().all()
|
||||
if not rows:
|
||||
return {}
|
||||
src_key = _SOURCE_TARGET_KEY.get(source_platform)
|
||||
out: dict = {}
|
||||
for tgt, (id_attr, make_payload) in _TARGETS.items():
|
||||
if tgt == src_key:
|
||||
continue # 不返回源平台自己
|
||||
cands = [r for r in rows if getattr(r, id_attr)]
|
||||
if tgt == "taobao":
|
||||
# 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。
|
||||
cands = [r for r in cands if r.taobao_deeplink_invalid_at is None]
|
||||
if not cands:
|
||||
continue
|
||||
best = _pick_best(cands, lat, lng)
|
||||
payload = make_payload(best)
|
||||
payload["row_id"] = best.id
|
||||
if best.lat is not None and best.lng is not None and lat is not None and lng is not None:
|
||||
payload["dist_km"] = round(_haversine_km(lat, lng, best.lat, best.lng), 3)
|
||||
out[tgt] = payload
|
||||
return out
|
||||
@@ -7,14 +7,31 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.task import UserTask
|
||||
from app.models.wallet import CoinTransaction
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
def _notification_grant_times(db: Session, user_id: int) -> int:
|
||||
"""打开消息提醒已领取次数 = 该 biz_type 的【正向】金币流水条数(可重复任务不写 user_task)。
|
||||
|
||||
限 amount>0 只数发放笔,避免日后出现该 biz_type 的回滚/冲正负向流水时把次数算大、减半档位错。
|
||||
"""
|
||||
biz = f"task_{rewards.TASK_ENABLE_NOTIFICATION}"
|
||||
return db.execute(
|
||||
select(func.count()).select_from(CoinTransaction).where(
|
||||
CoinTransaction.user_id == user_id,
|
||||
CoinTransaction.biz_type == biz,
|
||||
CoinTransaction.amount > 0,
|
||||
)
|
||||
).scalar_one() or 0
|
||||
|
||||
|
||||
class UnknownTaskError(Exception):
|
||||
"""task_key 不在已知任务表里。"""
|
||||
|
||||
@@ -31,14 +48,30 @@ class TaskState:
|
||||
|
||||
|
||||
def list_tasks(db: Session, user_id: int) -> list[TaskState]:
|
||||
"""返回所有已知一次性任务及其领取状态。"""
|
||||
"""返回所有已知任务及其领取状态。
|
||||
|
||||
打开消息提醒是可重复任务:claimed 恒 False,coin = 下一次(减半后)的金额;
|
||||
客户端按「通知权限是否开启」决定展不展示(开着就隐藏,不在这里判断)。
|
||||
其余为一次性任务,user_task 唯一约束去重。
|
||||
"""
|
||||
task_rewards = rewards.get_task_rewards(db)
|
||||
stmt = select(UserTask.task_key).where(UserTask.user_id == user_id)
|
||||
claimed_keys = set(db.execute(stmt).scalars().all())
|
||||
return [
|
||||
TaskState(task_key=key, coin=coin, claimed=key in claimed_keys)
|
||||
for key, coin in task_rewards.items()
|
||||
]
|
||||
result: list[TaskState] = []
|
||||
for key, coin in task_rewards.items():
|
||||
if key == rewards.TASK_ENABLE_NOTIFICATION:
|
||||
times = _notification_grant_times(db, user_id)
|
||||
exhausted = times >= rewards.notification_max_claims(coin)
|
||||
result.append(
|
||||
TaskState(
|
||||
task_key=key,
|
||||
coin=rewards.notification_reward(coin, times),
|
||||
claimed=exhausted, # 领满(减半到底)后置 True,客户端据此可隐藏
|
||||
)
|
||||
)
|
||||
else:
|
||||
result.append(TaskState(task_key=key, coin=coin, claimed=key in claimed_keys))
|
||||
return result
|
||||
|
||||
|
||||
def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]:
|
||||
@@ -50,6 +83,27 @@ def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]:
|
||||
if task_key not in task_rewards:
|
||||
raise UnknownTaskError
|
||||
|
||||
# 打开消息提醒:可重复领取,每次减半。不写 user_task(不去重),已领次数用金币流水条数算;
|
||||
# ref_id 带序号(task_key:N)让流水可读、且 (user_id,biz_type,ref_id) 唯一索引能挡并发/连点
|
||||
# 重复发放。减半到底(notification_max_claims)后不再可领。客户端只在通知权限关闭时展示+触发。
|
||||
if task_key == rewards.TASK_ENABLE_NOTIFICATION:
|
||||
base = task_rewards[task_key]
|
||||
times = _notification_grant_times(db, user_id)
|
||||
if times >= rewards.notification_max_claims(base):
|
||||
raise AlreadyClaimedError
|
||||
coin = rewards.notification_reward(base, times)
|
||||
try:
|
||||
acc, _ = crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type=f"task_{task_key}", ref_id=f"{task_key}:{times + 1}",
|
||||
)
|
||||
db.commit()
|
||||
except IntegrityError as e:
|
||||
# 并发/连点:另一个请求已抢先发了同一序号(撞唯一索引)。回滚,按已领处理,不双发。
|
||||
db.rollback()
|
||||
raise AlreadyClaimedError from e
|
||||
return coin, acc.coin_balance
|
||||
|
||||
existing = db.execute(
|
||||
select(UserTask).where(
|
||||
UserTask.user_id == user_id, UserTask.task_key == task_key
|
||||
|
||||
+257
-15
@@ -12,6 +12,7 @@ import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
@@ -31,6 +32,7 @@ from app.models.wallet import (
|
||||
_WX_STATE_SUCCESS = "SUCCESS"
|
||||
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
|
||||
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
|
||||
_WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"}
|
||||
# 免确认收款授权状态
|
||||
_WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账
|
||||
_WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启
|
||||
@@ -60,6 +62,10 @@ class InsufficientCashError(Exception):
|
||||
"""现金余额不足。"""
|
||||
|
||||
|
||||
class WithdrawTooFrequentError(Exception):
|
||||
"""提现申请过于频繁,或已有未完成提现单。"""
|
||||
|
||||
|
||||
class WithdrawTransferError(Exception):
|
||||
"""调用微信转账失败(已退回余额)。"""
|
||||
|
||||
@@ -72,9 +78,15 @@ class WithdrawNotReviewable(Exception):
|
||||
"""提现单当前状态不可审核(非 reviewing,可能已被处理过)。"""
|
||||
|
||||
|
||||
def get_or_create_account(db: Session, user_id: int, *, commit: bool = True) -> CoinAccount:
|
||||
"""取用户金币账户,不存在则建一个空账户。"""
|
||||
acc = db.get(CoinAccount, user_id)
|
||||
def get_or_create_account(
|
||||
db: Session, user_id: int, *, commit: bool = True, lock: bool = False
|
||||
) -> CoinAccount:
|
||||
"""取用户金币账户,不存在则建一个空账户。
|
||||
|
||||
lock=True 时对已存在的账户行加 SELECT FOR UPDATE(读-算-写余额的调用方串行化,防并发
|
||||
双写余额错位,如 admin set 模式连点);默认 False 不改 C 端发奖行为。SQLite 下为 no-op。
|
||||
"""
|
||||
acc = db.get(CoinAccount, user_id, with_for_update=True) if lock else db.get(CoinAccount, user_id)
|
||||
if acc is None:
|
||||
acc = CoinAccount(
|
||||
user_id=user_id,
|
||||
@@ -116,6 +128,38 @@ def grant_coins(
|
||||
biz_type=biz_type,
|
||||
ref_id=ref_id,
|
||||
remark=remark,
|
||||
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), # 存北京 wall-clock(客户端原样切片显示)
|
||||
)
|
||||
db.add(txn)
|
||||
db.flush()
|
||||
return acc, txn
|
||||
|
||||
|
||||
def grant_cash(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
amount_cents: int,
|
||||
*,
|
||||
biz_type: str,
|
||||
ref_id: str | None = None,
|
||||
remark: str | None = None,
|
||||
) -> tuple[CoinAccount, CashTransaction]:
|
||||
"""现金变动入口(正数入账 / 负数出账)。更新现金余额 + 写流水,不 commit。
|
||||
|
||||
与 [grant_coins] 同模式(运营手动调现金 / 测试发现金用)。返回 (account, transaction),
|
||||
调用方负责 commit。不在此校验扣成负——由调用方(admin router)按业务保护。
|
||||
"""
|
||||
acc = get_or_create_account(db, user_id, commit=False)
|
||||
acc.cash_balance_cents += amount_cents
|
||||
|
||||
txn = CashTransaction(
|
||||
user_id=user_id,
|
||||
amount_cents=amount_cents,
|
||||
balance_after_cents=acc.cash_balance_cents,
|
||||
biz_type=biz_type,
|
||||
ref_id=ref_id,
|
||||
remark=remark,
|
||||
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), # 存北京 wall-clock
|
||||
)
|
||||
db.add(txn)
|
||||
db.flush()
|
||||
@@ -145,14 +189,22 @@ def list_coin_transactions(
|
||||
|
||||
|
||||
def exchange_coins_to_cash(
|
||||
db: Session, user_id: int, coin_amount: int
|
||||
db: Session,
|
||||
user_id: int,
|
||||
coin_amount: int,
|
||||
*,
|
||||
remark: str | None = None,
|
||||
enforce_min: bool = True,
|
||||
) -> tuple[CoinAccount, int]:
|
||||
"""金币兑现金。返回 (account, 本次兑入的分)。
|
||||
|
||||
校验:金额 >= MIN_EXCHANGE_COIN 且为整分倍数(InvalidExchangeAmountError),
|
||||
余额充足(InsufficientCoinError)。扣金币 + 加现金,两条流水同事务 commit。
|
||||
校验:金额为整分倍数(InvalidExchangeAmountError)、余额充足(InsufficientCoinError);
|
||||
`enforce_min=True`(手动兑默认)时还要 >= 后台配置的兑换下限,`enforce_min=False`
|
||||
(0 点自动兑「到分全额」用)只要够 1 分(COIN_PER_CENT)即可,不受手动下限约束。
|
||||
`remark` 不传则用默认「N金币兑M分」。扣金币 + 加现金,两条流水同事务 commit。
|
||||
"""
|
||||
if coin_amount < rewards.get_min_exchange_coin(db) or coin_amount % COIN_PER_CENT != 0:
|
||||
floor_min = rewards.get_min_exchange_coin(db) if enforce_min else COIN_PER_CENT
|
||||
if coin_amount < floor_min or coin_amount % COIN_PER_CENT != 0:
|
||||
raise InvalidExchangeAmountError
|
||||
|
||||
acc = get_or_create_account(db, user_id, commit=False)
|
||||
@@ -160,10 +212,10 @@ def exchange_coins_to_cash(
|
||||
raise InsufficientCoinError
|
||||
|
||||
cents = coins_to_cents(coin_amount)
|
||||
remark = f"{coin_amount}金币兑{cents}分"
|
||||
tx_remark = remark or f"{coin_amount}金币兑{cents}分"
|
||||
|
||||
# 1. 扣金币(写 coin_transaction)
|
||||
grant_coins(db, user_id, -coin_amount, biz_type="exchange_out", remark=remark)
|
||||
grant_coins(db, user_id, -coin_amount, biz_type="exchange_out", remark=tx_remark)
|
||||
# 2. 加现金(写 cash_transaction)
|
||||
acc.cash_balance_cents += cents
|
||||
db.add(
|
||||
@@ -172,7 +224,8 @@ def exchange_coins_to_cash(
|
||||
amount_cents=cents,
|
||||
balance_after_cents=acc.cash_balance_cents,
|
||||
biz_type="exchange_in",
|
||||
remark=remark,
|
||||
remark=tx_remark,
|
||||
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
@@ -180,6 +233,70 @@ def exchange_coins_to_cash(
|
||||
return acc, cents
|
||||
|
||||
|
||||
def _has_exchange_in_on(db: Session, user_id: int, day) -> bool:
|
||||
"""该用户在指定日期(北京日)是否已有 exchange_in 现金流水。
|
||||
|
||||
自动兑复用 exchange_in 类型,故用「当天是否已有 exchange_in」做幂等键:timer 重触发 /
|
||||
手动重跑当天不会重复兑。手动兑入口已下线,不会与之误判混淆(若日后恢复手动兑需改用独立类型)。
|
||||
created_at 落库为北京 naive 时间(见 exchange_coins_to_cash),按 [当天 0 点, 次日 0 点) 比较。
|
||||
"""
|
||||
day_start = datetime.combine(day, datetime.min.time())
|
||||
next_day = day_start + timedelta(days=1)
|
||||
return (
|
||||
db.execute(
|
||||
select(CashTransaction.id)
|
||||
.where(
|
||||
CashTransaction.user_id == user_id,
|
||||
CashTransaction.biz_type == "exchange_in",
|
||||
CashTransaction.created_at >= day_start,
|
||||
CashTransaction.created_at < next_day,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def daily_auto_exchange(db: Session) -> dict:
|
||||
"""0 点定时任务:把每个用户金币「到分全额」兑成现金(复用 exchange_in 流水类型)。
|
||||
|
||||
- **到分全额**:coin_amount = floor(余额 / 100) * 100;余额不足 1 分(<100)的零头留到下次。
|
||||
- **幂等**:当天已有 exchange_in 的用户跳过(见 [_has_exchange_in_on])。
|
||||
- **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit。
|
||||
返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)。
|
||||
"""
|
||||
today = rewards.cn_today()
|
||||
stats = {
|
||||
"scanned": 0, "converted": 0, "skipped_done": 0,
|
||||
"skipped_dust": 0, "failed": 0, "total_cents": 0,
|
||||
}
|
||||
user_ids = db.execute(
|
||||
select(CoinAccount.user_id).where(CoinAccount.coin_balance >= COIN_PER_CENT)
|
||||
).scalars().all()
|
||||
|
||||
for user_id in user_ids:
|
||||
stats["scanned"] += 1
|
||||
try:
|
||||
if _has_exchange_in_on(db, user_id, today):
|
||||
stats["skipped_done"] += 1
|
||||
continue
|
||||
acc = get_or_create_account(db, user_id, commit=False)
|
||||
coin_amount = (acc.coin_balance // COIN_PER_CENT) * COIN_PER_CENT
|
||||
if coin_amount < COIN_PER_CENT:
|
||||
stats["skipped_dust"] += 1
|
||||
continue
|
||||
_, cents = exchange_coins_to_cash(
|
||||
db, user_id, coin_amount, remark="0 点自动兑现金", enforce_min=False
|
||||
)
|
||||
stats["converted"] += 1
|
||||
stats["total_cents"] += cents
|
||||
except Exception: # noqa: BLE001 - 批处理不因单用户异常中断
|
||||
db.rollback()
|
||||
stats["failed"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def list_cash_transactions(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
@@ -223,6 +340,26 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
|
||||
return info
|
||||
|
||||
|
||||
def has_reviewing_withdraw(db: Session, user_id: int) -> bool:
|
||||
"""用户是否有「待审核(reviewing)」的提现单。
|
||||
|
||||
只看 reviewing:这类单还没打款,用户确认解绑时会被立即退回现金
|
||||
(refund_reviewing_withdraws_on_unbind),解绑前据此提示用户知情确认。
|
||||
pending 单转账已在途(execute 已过 openid 闸),解绑影响不到、照常打款,故不计入。
|
||||
"""
|
||||
return (
|
||||
db.execute(
|
||||
select(WithdrawOrder.id)
|
||||
.where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.status == "reviewing",
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def unbind_wechat_openid(db: Session, user_id: int) -> None:
|
||||
"""解绑微信:清空 openid。已绑定才清,未绑定为幂等空操作。"""
|
||||
user = db.get(User, user_id)
|
||||
@@ -235,6 +372,33 @@ def unbind_wechat_openid(db: Session, user_id: int) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def refund_reviewing_withdraws_on_unbind(db: Session, user_id: int) -> int:
|
||||
"""解绑微信时,把该用户所有「待审核(reviewing)」提现单立即退回现金并置终态。
|
||||
|
||||
reviewing 单还没打款(钱在 create_withdraw 时已从现金扣进待审核单),解绑后这笔无法
|
||||
打款到微信,直接退回现金最干净:用户即时拿回钱、后台不再挂死单(不必等管理员审核)。
|
||||
复用 _refund_withdraw(退现金 + 流水 + 幂等)。返回退掉的单数。
|
||||
"""
|
||||
orders = (
|
||||
db.execute(
|
||||
select(WithdrawOrder).where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.status == "reviewing",
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for order in orders:
|
||||
_refund_withdraw(
|
||||
db,
|
||||
order,
|
||||
reason="用户解绑微信,待审核提现自动退回",
|
||||
remark="解绑微信,提现已退回现金余额",
|
||||
)
|
||||
return len(orders)
|
||||
|
||||
|
||||
_OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$")
|
||||
|
||||
|
||||
@@ -270,7 +434,8 @@ def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
|
||||
|
||||
|
||||
def _refund_withdraw(
|
||||
db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed"
|
||||
db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed",
|
||||
remark: str | None = None,
|
||||
) -> None:
|
||||
"""退回现金 + 写 withdraw_refund 流水 + 单子置终态。幂等:已是退款终态不重复退。
|
||||
|
||||
@@ -278,9 +443,22 @@ def _refund_withdraw(
|
||||
- "failed" (默认):微信转账失败/取消 → 自动退款
|
||||
- "rejected":管理员审核拒绝 → 退款(走 reject_withdraw)
|
||||
两种都已退款,用 in 判定防重复退(并发/对账/拒绝重复点)。
|
||||
remark:给用户可见的现金流水文案;省略则按 final_status 取默认(解绑退回等场景可自定义)。
|
||||
"""
|
||||
if order.status in ("failed", "rejected"):
|
||||
return # 防重复退款(并发/对账与查单/重复拒绝同时触发)
|
||||
refunded_txn_id = db.execute(
|
||||
select(CashTransaction.id).where(
|
||||
CashTransaction.user_id == order.user_id,
|
||||
CashTransaction.biz_type == "withdraw_refund",
|
||||
CashTransaction.ref_id == order.out_bill_no,
|
||||
).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if refunded_txn_id is not None:
|
||||
order.status = final_status
|
||||
order.fail_reason = reason[:256]
|
||||
db.commit()
|
||||
return
|
||||
bal = _add_cash(db, order.user_id, order.amount_cents)
|
||||
db.add(
|
||||
CashTransaction(
|
||||
@@ -290,12 +468,39 @@ def _refund_withdraw(
|
||||
biz_type="withdraw_refund",
|
||||
ref_id=order.out_bill_no,
|
||||
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
|
||||
remark="提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回",
|
||||
remark=(
|
||||
remark
|
||||
or ("提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回")
|
||||
),
|
||||
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
)
|
||||
order.status = final_status
|
||||
order.fail_reason = reason[:256]
|
||||
db.commit()
|
||||
out_bill_no = order.out_bill_no
|
||||
user_id = order.user_id
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发退款兜底:唯一退款流水已被另一事务写入时,回滚本事务的加钱和流水,
|
||||
# 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,现金最多退一次。
|
||||
db.rollback()
|
||||
refunded_txn_id = db.execute(
|
||||
select(CashTransaction.id).where(
|
||||
CashTransaction.user_id == user_id,
|
||||
CashTransaction.biz_type == "withdraw_refund",
|
||||
CashTransaction.ref_id == out_bill_no,
|
||||
).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if refunded_txn_id is None:
|
||||
raise
|
||||
fresh_order = db.execute(
|
||||
select(WithdrawOrder).where(WithdrawOrder.out_bill_no == out_bill_no)
|
||||
).scalar_one_or_none()
|
||||
if fresh_order is not None and fresh_order.status not in ("failed", "rejected"):
|
||||
fresh_order.status = final_status
|
||||
fresh_order.fail_reason = reason[:256]
|
||||
db.commit()
|
||||
|
||||
|
||||
def _wx_not_found(result: dict) -> bool:
|
||||
@@ -382,6 +587,15 @@ def create_withdraw(
|
||||
else:
|
||||
out_bill_no = uuid.uuid4().hex
|
||||
|
||||
active_order_id = db.execute(
|
||||
select(WithdrawOrder.id).where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.status.in_(_WITHDRAW_ACTIVE_STATUSES),
|
||||
).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if active_order_id is not None:
|
||||
raise WithdrawTooFrequentError
|
||||
|
||||
# 账户须存在(原子扣款的 UPDATE 不会建账户)
|
||||
get_or_create_account(db, user_id, commit=True)
|
||||
|
||||
@@ -401,6 +615,7 @@ def create_withdraw(
|
||||
biz_type="withdraw",
|
||||
ref_id=out_bill_no,
|
||||
remark="提现到微信零钱(待审核)",
|
||||
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
)
|
||||
order = WithdrawOrder(
|
||||
@@ -411,7 +626,26 @@ def create_withdraw(
|
||||
status="reviewing",
|
||||
)
|
||||
db.add(order)
|
||||
db.commit()
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
existing = db.execute(
|
||||
select(WithdrawOrder).where(
|
||||
WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
active_order_id = db.execute(
|
||||
select(WithdrawOrder.id).where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.status.in_(_WITHDRAW_ACTIVE_STATUSES),
|
||||
).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if active_order_id is not None:
|
||||
raise WithdrawTooFrequentError from None
|
||||
raise
|
||||
db.refresh(order)
|
||||
return order # 待管理员审核;**不在此处打款**
|
||||
|
||||
@@ -699,7 +933,15 @@ def refresh_withdraw_status(
|
||||
if order.status != "pending":
|
||||
return order # 已终态,不再查
|
||||
|
||||
result = wxpay.query_transfer(out_bill_no)
|
||||
try:
|
||||
result = wxpay.query_transfer(out_bill_no)
|
||||
except wxpay.WxPayNotConfiguredError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - 查单失败不能把运营后台打成 500
|
||||
order.fail_reason = f"微信查单异常,保持pending: {exc}"[:256]
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
if result["status_code"] != 200:
|
||||
if _wx_not_found(result):
|
||||
# 微信明确无此单 → 转账从未创建(如崩溃在扣款后/调用前),退款安全
|
||||
|
||||
+29
-6
@@ -28,7 +28,7 @@ class AdRewardStatusOut(BaseModel):
|
||||
used_today: int = Field(..., description="今日已成功发奖次数")
|
||||
daily_limit: int = Field(..., description="每日发奖次数上限")
|
||||
remaining: int = Field(..., description="今日剩余可领次数")
|
||||
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
|
||||
coin_per_ad: int = Field(..., description="历史兼容字段;正式发放按 eCPM 动态计算,当前返回 0")
|
||||
round_count: int = Field(
|
||||
..., description="本轮已看次数,当前 round_size=1,客户端可由 cooldown_until 判断短冷却"
|
||||
)
|
||||
@@ -46,11 +46,15 @@ class AdRewardStatusOut(BaseModel):
|
||||
class EcpmReportIn(BaseModel):
|
||||
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
|
||||
|
||||
user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串(单位待确认)。
|
||||
user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按分/千次展示处理(SDK getEcpm 原值,非元)。
|
||||
"""
|
||||
|
||||
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等")
|
||||
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,单位待确认,原样上报")
|
||||
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按分/千次展示处理(SDK getEcpm 原值,非元)")
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64,
|
||||
description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值",
|
||||
)
|
||||
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
|
||||
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
|
||||
|
||||
@@ -78,16 +82,32 @@ class WatchReportOut(BaseModel):
|
||||
watch_seconds_remaining: int = Field(..., description="今日剩余可观看秒数")
|
||||
|
||||
|
||||
class TestGrantIn(BaseModel):
|
||||
"""[仅本地联调]模拟发奖入参。"""
|
||||
|
||||
reward_scene: str = Field(
|
||||
"reward_video",
|
||||
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)",
|
||||
)
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64,
|
||||
description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端"
|
||||
"已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币",
|
||||
)
|
||||
|
||||
|
||||
class TestGrantOut(BaseModel):
|
||||
"""[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。"""
|
||||
|
||||
granted: bool = Field(..., description="本次是否真的发了金币(达每日上限则 False)")
|
||||
status: str = Field(..., description="granted / capped")
|
||||
status: str = Field(
|
||||
..., description="granted / capped / not_signed / already_boosted / last_day / unknown_scene"
|
||||
)
|
||||
coin: int = Field(..., description="本次发放金币(capped 时为 0)")
|
||||
used_today: int = Field(..., description="今日已成功发奖次数")
|
||||
daily_limit: int = Field(..., description="每日发奖次数上限")
|
||||
remaining: int = Field(..., description="今日剩余可领次数")
|
||||
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
|
||||
coin_per_ad: int = Field(..., description="历史兼容字段;正式发放按 eCPM 动态计算,当前返回 0")
|
||||
round_count: int = Field(..., description="本轮已看次数,详见 AdRewardStatusOut")
|
||||
cooldown_until: datetime | None = Field(
|
||||
None, description="本轮冷却结束时间(UTC),详见 AdRewardStatusOut"
|
||||
@@ -101,7 +121,10 @@ class FeedRewardIn(BaseModel):
|
||||
"""
|
||||
|
||||
client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
|
||||
ecpm: str = Field(..., description="本条信息流广告 eCPM,按元/千次展示处理")
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
|
||||
)
|
||||
ecpm: str = Field(..., description="本条信息流广告 eCPM,按分/千次展示处理(SDK getEcpm 原值,非元)")
|
||||
duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数")
|
||||
adn: str | None = Field(None, description="实际投放 ADN")
|
||||
slot_id: str | None = Field(None, description="实际展示代码位")
|
||||
|
||||
@@ -25,6 +25,8 @@ class UserOut(BaseModel):
|
||||
status: str
|
||||
created_at: datetime
|
||||
last_login_at: datetime
|
||||
# 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。
|
||||
debug_trace_enabled: bool = False
|
||||
|
||||
|
||||
# ===== Token 通用结构 =====
|
||||
@@ -39,6 +41,11 @@ class TokenPair(BaseModel):
|
||||
|
||||
class TokenWithUser(TokenPair):
|
||||
user: UserOut
|
||||
onboarding_completed: bool = Field(
|
||||
False,
|
||||
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
|
||||
"true → 客户端登录后直接进首页,跳过引导。",
|
||||
)
|
||||
|
||||
|
||||
# ===== 极光一键登录 =====
|
||||
@@ -46,6 +53,10 @@ class TokenWithUser(TokenPair):
|
||||
class JverifyLoginRequest(BaseModel):
|
||||
login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken", min_length=1)
|
||||
operator: str = Field("", description="CM/CU/CT,用于日志,可选")
|
||||
device_id: str = Field(
|
||||
"", max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
|
||||
)
|
||||
|
||||
|
||||
# ===== 短信验证码 =====
|
||||
@@ -63,6 +74,10 @@ class SmsSendResponse(BaseModel):
|
||||
class SmsLoginRequest(BaseModel):
|
||||
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
|
||||
code: str = Field(..., min_length=4, max_length=8)
|
||||
device_id: str = Field(
|
||||
"", max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
|
||||
)
|
||||
|
||||
|
||||
# ===== Refresh =====
|
||||
|
||||
@@ -24,6 +24,13 @@ class ComparisonItemIn(BaseModel):
|
||||
specs: list[str] | None = None
|
||||
|
||||
|
||||
class AppliedCouponIn(BaseModel):
|
||||
"""单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。"""
|
||||
|
||||
name: str
|
||||
amount: float
|
||||
|
||||
|
||||
class ComparisonResultIn(BaseModel):
|
||||
"""逐平台对比项(来自 done.params.comparison_results)。price 单位:元。"""
|
||||
|
||||
@@ -40,6 +47,10 @@ class ComparisonResultIn(BaseModel):
|
||||
# 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。
|
||||
# None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。
|
||||
coupon_name: str | None = None
|
||||
# 多券明细 [{name, amount}](全口径: 平台红包+商家券+满减+配送减免, amount 单位元正数)。
|
||||
# 跟 coupon_saved 并存, 是更丰富的明细; 空=没抠到 → 前端回退单券路径。
|
||||
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
|
||||
applied_coupons: list[AppliedCouponIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ComparisonRecordIn(BaseModel):
|
||||
@@ -67,6 +78,9 @@ class ComparisonRecordIn(BaseModel):
|
||||
status: str | None = Field(None, description="success / failed,可不传由服务端派生")
|
||||
# 最优平台商家/商品深链(客户端从 collectedLinks[best_index] 取);「再次比价」直达用
|
||||
best_deeplink: str | None = Field(None, description="最优平台深链,再次比价直达")
|
||||
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
|
||||
# 时分秒前端拼不出,必须由后端透传)。
|
||||
trace_url: str | None = Field(None, description="本次比价公网调试链接")
|
||||
|
||||
|
||||
# ===== 读取出参 =====
|
||||
@@ -79,6 +93,8 @@ class ComparisonRecordOut(BaseModel):
|
||||
id: int
|
||||
business_type: str
|
||||
trace_id: str
|
||||
# 公网调试链接;仅当 user.debug_trace_enabled 时由端点填充,否则端点层置 None(权限闸)。
|
||||
trace_url: str | None = None
|
||||
source_platform_id: str | None = None
|
||||
source_platform_name: str | None = None
|
||||
source_package: str | None = None
|
||||
|
||||
@@ -7,15 +7,35 @@ from pydantic import BaseModel
|
||||
class CouponPromptDismissIn(BaseModel):
|
||||
"""客户端拒绝/关闭领券引导窗的通知体。
|
||||
|
||||
server 据此记一条今日 engagement(dismissed)→ 今天这台设备不再弹引导窗。
|
||||
server 据此记一条今日 engagement(dismissed)→ 今天**这个 App** 不再弹引导窗。
|
||||
频控按 (device, package, 日):各 App 独立。package 缺省 ""(老客户端兼容,退化为全局态)。
|
||||
MVP 不鉴权,按 device_id 判断;user_id 登录态带上就一并记(资产),可空。
|
||||
"""
|
||||
|
||||
device_id: str
|
||||
package: str = ""
|
||||
user_id: int | None = None
|
||||
|
||||
|
||||
class CouponPromptShownIn(BaseModel):
|
||||
"""客户端弹出领券引导窗即上报(记 shown)。
|
||||
|
||||
弹出那刻就记一条今日 engagement(shown)→ 今天**这个 App** 不再自动弹(频控主判据,
|
||||
管跨重装;本地 SP 兜后台抖动)。后续用户点领取/拒绝再把 type 升级成 claim_started/dismissed。
|
||||
"""
|
||||
|
||||
device_id: str
|
||||
package: str
|
||||
user_id: int | None = None
|
||||
|
||||
|
||||
class CouponPromptShouldShowOut(BaseModel):
|
||||
"""切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。"""
|
||||
"""切到外卖 App 时是否还应弹领券引导窗。今天**这个 App** 已 engage(弹/领/拒)过 → false。"""
|
||||
|
||||
should_show: bool
|
||||
|
||||
|
||||
class CouponCompletedTodayOut(BaseModel):
|
||||
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
|
||||
|
||||
completed: bool
|
||||
|
||||
+51
-3
@@ -1,6 +1,8 @@
|
||||
"""邀请相关 API 收发模型。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -11,15 +13,61 @@ class InviteInfoOut(BaseModel):
|
||||
coins_earned: int # 累计从邀请获得的金币
|
||||
|
||||
|
||||
class LandingTrackIn(BaseModel):
|
||||
"""落地页 dl.html 访问时上报的指纹(用于剪贴板归因失败时兜底)。"""
|
||||
ref: str = Field(..., min_length=4, max_length=16, description="邀请码(落地页 ?ref=)")
|
||||
screen: str = Field("", max_length=32, description="屏幕分辨率,如 1080x2400")
|
||||
# IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
|
||||
|
||||
|
||||
class LandingTrackOut(BaseModel):
|
||||
status: str # ok / invalid_code / no_ip
|
||||
|
||||
|
||||
class FingerprintIn(BaseModel):
|
||||
"""客户端登录后剪贴板归因失败时上报的设备指纹。
|
||||
|
||||
跨端匹配字段:浏览器落地页存的同名字段 == 客户端 Build.MODEL / DisplayMetrics 算的值。
|
||||
IP 服务端从 HTTP 头拿,不在这里。
|
||||
"""
|
||||
screen: str = Field("", max_length=32)
|
||||
device_model: str = Field("", max_length=64)
|
||||
|
||||
|
||||
class BindInviteIn(BaseModel):
|
||||
invite_code: str = Field(..., min_length=4, max_length=16, description="邀请人的邀请码")
|
||||
# invite_code: 可选 — 走指纹兜底时为空(channel=fingerprint)
|
||||
invite_code: str | None = Field(
|
||||
None, max_length=16, description="邀请码;走指纹兜底时为空"
|
||||
)
|
||||
channel: str = Field(
|
||||
"clipboard", max_length=16,
|
||||
description="归因来源:clipboard(首启读剪贴板自动) / manual(用户手动填)",
|
||||
description="归因来源:clipboard(首启读剪贴板自动) / manual(手动填) / fingerprint(指纹兜底)",
|
||||
)
|
||||
# 当 invite_code 为空、channel=fingerprint 时,后端用这组指纹反查
|
||||
fingerprint: FingerprintIn | None = None
|
||||
|
||||
|
||||
class BindInviteOut(BaseModel):
|
||||
status: str # success / already_bound / invalid_code / self_invite / not_eligible
|
||||
status: str # success / already_bound / invalid_code / self_invite / not_eligible / fp_not_found
|
||||
coins_awarded: int # 本次给当前用户(被邀请人)发的金币
|
||||
message: str # 给前端直接展示的文案
|
||||
|
||||
|
||||
class InviteeItem(BaseModel):
|
||||
"""被邀请人列表的一条(邀请页小窗 / 完整列表页共用)。
|
||||
|
||||
display_name / avatar_url 的"降级兜底"在后端算好(见 repositories/invite.get_invitees):
|
||||
名字 = 昵称 → 微信昵称 → 脱敏手机号(前端拿不到完整号,必须后端脱敏);
|
||||
头像 = 用户头像 → 微信头像 → null(前端拿到 null 画默认色块)。
|
||||
"""
|
||||
display_name: str # 已兜底好的显示名(真名/脱敏号)
|
||||
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
|
||||
coins: int # 这次邀请给我(邀请人)发的金币
|
||||
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
|
||||
|
||||
|
||||
class InviteeListOut(BaseModel):
|
||||
"""GET /invite/invitees 响应:被邀请人列表 + 分页。"""
|
||||
items: list[InviteeItem]
|
||||
total: int # 我邀请的总人数(用于"共 N 人")
|
||||
has_more: bool # 还有下一页吗(完整列表页滚到底加载更多)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user