Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e91c9f72f | |||
| a69b7d777d | |||
| 0fc8521c3b | |||
| 15fb73791f | |||
| d9177cbb71 | |||
| eeecb5faf0 | |||
| 31bff63ed4 | |||
| ef0ab9d95a | |||
| 06cd718610 | |||
| aa1a1240f2 | |||
| b6ddb275f4 | |||
| ebacf01742 | |||
| 89f266419b | |||
| 675c7ecf81 | |||
| 53c3b7f60f | |||
| 4bd4e66678 | |||
| 90c6fe599a | |||
| e529112a90 | |||
| 50da718e35 | |||
| ed76820e97 | |||
| f05dd1cf74 | |||
| b5962464e8 | |||
| 36ce18a250 | |||
| 58d609e5d2 | |||
| 8e01ae0bf9 | |||
| 7e17df4130 | |||
| 46f68b88e3 | |||
| 1226bc8365 | |||
| 22a1105000 | |||
| b2a528eba1 | |||
| cdd49c6421 | |||
| 775a503d6f |
+59
-74
@@ -27,72 +27,55 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
|
|||||||
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
|
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
|
||||||
JG_REQUEST_TIMEOUT_SEC=15
|
JG_REQUEST_TIMEOUT_SEC=15
|
||||||
|
|
||||||
# ===== 厂商直推(无障碍保护存活告警 + 消息中心 13 类通知)=====
|
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
|
||||||
# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。
|
|
||||||
# 各厂商配置状态可随时 GET /api/v1/push/vendors 查看(缺哪些键一目了然)。
|
|
||||||
ANDROID_PACKAGE_NAME=com.jishisongfu.shaguabijia
|
|
||||||
PUSH_REQUEST_TIMEOUT_SEC=15
|
|
||||||
PUSH_TIME_TO_LIVE_SEC=86400
|
|
||||||
|
|
||||||
HONOR_PUSH_APP_ID=
|
|
||||||
HONOR_PUSH_CLIENT_ID=
|
|
||||||
HONOR_PUSH_CLIENT_SECRET=
|
|
||||||
HONOR_PUSH_TOKEN_ENDPOINT=https://iam.developer.honor.com/auth/token
|
|
||||||
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage
|
|
||||||
|
|
||||||
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,AppId + AppSecret
|
|
||||||
HUAWEI_PUSH_APP_ID=
|
|
||||||
HUAWEI_PUSH_APP_SECRET=
|
|
||||||
HUAWEI_PUSH_TOKEN_ENDPOINT=https://oauth-login.cloud.huawei.com/oauth2/v3/token
|
|
||||||
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.huawei.com/v1/{app_id}/messages:send
|
|
||||||
|
|
||||||
VIVO_PUSH_APP_ID=
|
|
||||||
VIVO_PUSH_APP_KEY=
|
|
||||||
VIVO_PUSH_APP_SECRET=
|
|
||||||
VIVO_PUSH_AUTH_ENDPOINT=https://api-push.vivo.com.cn/message/auth
|
|
||||||
VIVO_PUSH_SEND_ENDPOINT=https://api-push.vivo.com.cn/message/send
|
|
||||||
# vivo 未上架测试时可用 push_mode=1; 上架正式推送改为 0。
|
|
||||||
VIVO_PUSH_MODE=1
|
|
||||||
VIVO_PUSH_NOTIFY_TYPE=4
|
|
||||||
VIVO_PUSH_CATEGORY=DEVICE_REMINDER
|
|
||||||
|
|
||||||
XIAOMI_PUSH_APP_SECRET=
|
|
||||||
XIAOMI_PUSH_SEND_ENDPOINT=https://api.xmpush.xiaomi.com/v3/message/regid
|
|
||||||
XIAOMI_PUSH_CHANNEL_ID=
|
|
||||||
XIAOMI_PUSH_TEMPLATE_ID=
|
|
||||||
XIAOMI_PUSH_TEMPLATE_TITLE=
|
|
||||||
XIAOMI_PUSH_TEMPLATE_DESCRIPTION=
|
|
||||||
# 可选: JSON 字符串,支持 {title}/{alert} 占位符,例如 {"title":"{title}","content":"{alert}"}
|
|
||||||
XIAOMI_PUSH_TEMPLATE_PARAM_JSON=
|
|
||||||
|
|
||||||
OPPO_PUSH_APP_KEY=
|
|
||||||
OPPO_PUSH_MASTER_SECRET=
|
|
||||||
OPPO_PUSH_AUTH_ENDPOINT=https://api.push.oppomobile.com/server/v1/auth
|
|
||||||
OPPO_PUSH_SEND_ENDPOINT=https://api.push.oppomobile.com/server/v1/message/notification/unicast
|
|
||||||
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带 category;channel_id 为后台「通道ID」;
|
|
||||||
# notify_level 0=不传走默认,内容营销类仅支持 1/2)
|
|
||||||
OPPO_PUSH_CHANNEL_ID=
|
|
||||||
OPPO_PUSH_CATEGORY=
|
|
||||||
OPPO_PUSH_NOTIFY_LEVEL=0
|
|
||||||
|
|
||||||
# ===== 无障碍保护存活监控(推送 + pull 后置兜底)=====
|
|
||||||
HEARTBEAT_MONITOR_ENABLED=true
|
HEARTBEAT_MONITOR_ENABLED=true
|
||||||
HEARTBEAT_TIMEOUT_MINUTES=60
|
HEARTBEAT_TIMEOUT_MINUTES=60
|
||||||
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
||||||
|
|
||||||
# ===== 短信 (mock 模式) =====
|
# ===== 短信 (mock 模式) =====
|
||||||
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。
|
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。生产改 false。
|
||||||
# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。
|
|
||||||
SMS_MOCK=true
|
SMS_MOCK=true
|
||||||
SMS_CODE_TTL_SEC=300
|
SMS_CODE_TTL_SEC=300
|
||||||
SMS_SEND_INTERVAL_SEC=60
|
SMS_SEND_INTERVAL_SEC=60
|
||||||
|
|
||||||
|
# ===== 短信提供商(可切换:jiguang 默认 / aliyun 阿里云号码认证 / chuanglan 创蓝云智)=====
|
||||||
|
# jiguang :本服务生成验证码,极光 REST 只负责下发,本地内存校验(复用上面极光 JG_* 凭证)。
|
||||||
|
# aliyun :阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A,核验免费);缺凭证时 /sms/* 返 503。
|
||||||
|
# 需在阿里云号码认证控制台开通「融合认证」,并使用系统赠送签名 + 赠送模板。
|
||||||
|
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
|
||||||
|
# 用 YZM 前缀验证码账号;服务器出网 IP 需在创蓝控制台加白名单(否则 117)。见 docs/integrations/chuanglan/tpl-send.md。
|
||||||
|
SMS_PROVIDER=jiguang
|
||||||
|
# 极光→创蓝 容灾:主 provider(SMS_PROVIDER)供应商不可用(503)时转投的备。
|
||||||
|
# 空=关闭 fallback(默认);生产设 chuanglan 开启极光主、创蓝备;置空即秒回退。
|
||||||
|
SMS_FALLBACK_PROVIDER=
|
||||||
|
ALIYUN_SMS_ACCESS_KEY_ID=
|
||||||
|
ALIYUN_SMS_ACCESS_KEY_SECRET=
|
||||||
|
ALIYUN_SMS_SIGN_NAME=
|
||||||
|
ALIYUN_SMS_TEMPLATE_CODE=
|
||||||
|
# 方案名:留空=默认方案;若填,发码与校验须一致(本服务已共用同一配置项,不会不匹配)。
|
||||||
|
ALIYUN_SMS_SCHEME_NAME=
|
||||||
|
ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com
|
||||||
|
ALIYUN_SMS_CODE_LENGTH=6
|
||||||
|
ALIYUN_SMS_VALID_TIME_SEC=300
|
||||||
|
ALIYUN_SMS_INTERVAL_SEC=60
|
||||||
|
ALIYUN_SMS_TIMEOUT_SEC=15
|
||||||
|
# --- 创蓝云智(253)---
|
||||||
|
CHUANGLAN_SMS_ACCOUNT=
|
||||||
|
CHUANGLAN_SMS_PASSWORD=
|
||||||
|
CHUANGLAN_SMS_TEMPLATE_ID=1022457679
|
||||||
|
# 短信签名文案【品牌】;模板已关联签名则留空。
|
||||||
|
CHUANGLAN_SMS_SIGNATURE=
|
||||||
|
CHUANGLAN_SMS_ENDPOINT=https://smssh.253.com/msg/sms/v2/tpl/send
|
||||||
|
CHUANGLAN_SMS_TIMEOUT_SEC=10
|
||||||
|
|
||||||
# ===== 测试账号(release 包全流程联调用)=====
|
# ===== 测试账号(release 包全流程联调用)=====
|
||||||
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
||||||
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
|
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
|
||||||
# 逻辑见 app/core/test_account.py,与其他业务解耦。
|
# 逻辑见 app/core/test_account.py,与其他业务解耦。
|
||||||
# ⚠️ 留空 = 关闭整功能(生产默认);要启用才填号(如 11111111111)。改完重启生效,随时可清空停用。
|
# ⚠️ 两项都留空 = 关闭整功能(生产默认)。多账号用英文逗号分隔,改完重启生效。
|
||||||
TEST_ACCOUNT_PHONE=
|
TEST_ACCOUNT_PHONE=
|
||||||
|
# 推荐新配置;例如联调环境填 11111111111,22222222222。
|
||||||
|
TEST_ACCOUNT_PHONES=
|
||||||
# 该测试号每日最多登录次数,当日超过即拒绝(429),次日归零。
|
# 该测试号每日最多登录次数,当日超过即拒绝(429),次日归零。
|
||||||
TEST_ACCOUNT_DAILY_LIMIT=500
|
TEST_ACCOUNT_DAILY_LIMIT=500
|
||||||
|
|
||||||
@@ -113,13 +96,6 @@ JD_UNION_APP_SECRET=
|
|||||||
JD_UNION_SITE_ID=
|
JD_UNION_SITE_ID=
|
||||||
JD_UNION_AUTH_KEY=
|
JD_UNION_AUTH_KEY=
|
||||||
|
|
||||||
# 美团 + 京东订单每天北京时间 05:00 自动对账;按更新时间回拉近 3 天,重叠防漏单并刷新状态。
|
|
||||||
# 手动对账按钮不受该开关影响。通常保持开启;临时停自动任务时设为 false。
|
|
||||||
CPS_AUTO_RECONCILE_ENABLED=true
|
|
||||||
CPS_AUTO_RECONCILE_RUN_HOUR=5
|
|
||||||
CPS_AUTO_RECONCILE_LOOKBACK_DAYS=3
|
|
||||||
CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC=60
|
|
||||||
|
|
||||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||||
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
||||||
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
|
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
|
||||||
@@ -193,18 +169,27 @@ PANGLE_REPORT_SECURITY_KEY=
|
|||||||
PANGLE_REPORT_SITE_ID_PROD=5830519
|
PANGLE_REPORT_SITE_ID_PROD=5830519
|
||||||
PANGLE_REPORT_SITE_ID_TEST=5832303
|
PANGLE_REPORT_SITE_ID_TEST=5832303
|
||||||
|
|
||||||
# ===== 可观测(OpenObserve 接口指标)=====
|
# ===== 客户端运行日志上报(POST /api/v1/applog/batch)=====
|
||||||
# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。
|
# 客户端批量上报的 App 运行日志逐条落到独立滚动文件 logs/app-client.log,供阿里云 Logtail
|
||||||
# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。
|
# 采进【独立 SLS logstore】(与服务日志 app-server.log 分开;滚动机制相同,trace_id 可跨层检索)。
|
||||||
# 未开/缺凭证 → 中间件透传、worker 不启动,整套 no-op,不影响业务。
|
# 全部有默认值,不填即用默认(定义见 app/core/client_log.py 与 app/api/v1/applog.py)。
|
||||||
OBSERVE_ENABLED=false
|
# CLIENT_LOG_FILE=logs/app-client.log # 落盘路径
|
||||||
OBSERVE_ENDPOINT=http://localhost:5080
|
# CLIENT_LOG_MAX_BYTES=20971520 # 单文件 20MB 滚动
|
||||||
OBSERVE_ORG=default
|
# CLIENT_LOG_BACKUP_COUNT=10 # 保留 10 个 → ~200MB 缓冲(给 Logtail 断线留余量)
|
||||||
OBSERVE_STREAM=app_requests
|
# CLIENT_LOG_SERVICE_NAME=app-client # 输出行 service 字段
|
||||||
OBSERVE_USER=admin@shaguabijia.local
|
# APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422;导入期常量,改需重启)
|
||||||
OBSERVE_PASSWORD=Complexpass#123
|
# APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413;运行期可调)
|
||||||
# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒
|
# APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超此字节数截断
|
||||||
OBSERVE_FLUSH_INTERVAL_SEC=5
|
|
||||||
OBSERVE_BATCH_MAX=200
|
# ===== 荣耀 Push =====
|
||||||
OBSERVE_QUEUE_MAX=10000
|
HONOR_PUSH_APP_ID=
|
||||||
OBSERVE_TIMEOUT_SEC=5
|
HONOR_PUSH_CLIENT_ID=
|
||||||
|
HONOR_PUSH_CLIENT_SECRET=
|
||||||
|
# 0=正式消息(默认);1=测试消息(仅开发联调,生产必须保持 0)
|
||||||
|
HONOR_PUSH_TARGET_USER_TYPE=0
|
||||||
|
|
||||||
|
# ===== 华为 Push =====
|
||||||
|
HUAWEI_PUSH_APP_ID=
|
||||||
|
HUAWEI_PUSH_APP_SECRET=
|
||||||
|
# 0=正式消息(默认);1=测试消息(仅开发联调,生产必须保持 0)
|
||||||
|
HUAWEI_PUSH_TARGET_USER_TYPE=0
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ secrets/*
|
|||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# 本地 admin server(端口 8771)Windows 启动脚本,个人调试用,不入库
|
||||||
|
/run8771.bat
|
||||||
|
|
||||||
# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。
|
# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。
|
||||||
# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。
|
# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。
|
||||||
.claude/settings.json
|
.claude/settings.json
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""merge comparison platforms + fail_reason heads
|
||||||
|
|
||||||
|
Revision ID: 6d2309208549
|
||||||
|
Revises: comparison_platforms_col, comparison_record_fail_reason
|
||||||
|
Create Date: 2026-07-29 01:48:41.868083
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '6d2309208549'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ('comparison_platforms_col', 'comparison_record_fail_reason')
|
||||||
|
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,47 @@
|
|||||||
|
"""add platforms unified array column to comparison_record
|
||||||
|
|
||||||
|
展示模型统一数组(pricebot done.params.platforms 原样存): 每平台一行、自带
|
||||||
|
status/is_best/display, 记录页据此直接渲染, 不再靠 comparison_results + 客户端合并 + 前端派生。
|
||||||
|
纯新增列, 老记录为空 → 前端回退老 comparison_results。
|
||||||
|
|
||||||
|
Revision ID: comparison_platforms_col
|
||||||
|
Revises: user_manual_risk_fields
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "comparison_platforms_col"
|
||||||
|
down_revision: str | None = "user_manual_risk_fields"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 幂等: 线上为了提前给历史数据补 platforms(2026-07-29), 已手动
|
||||||
|
# `ALTER TABLE comparison_record ADD COLUMN IF NOT EXISTS platforms jsonb
|
||||||
|
# NOT NULL DEFAULT '[]'::jsonb`(与本 migration 定义一致)。列已存在时跳过,
|
||||||
|
# 否则上线 alembic upgrade head 会撞 DuplicateColumn 直接部署失败。
|
||||||
|
bind = op.get_bind()
|
||||||
|
cols = {c["name"] for c in sa.inspect(bind).get_columns("comparison_record")}
|
||||||
|
if "platforms" in cols:
|
||||||
|
return
|
||||||
|
with op.batch_alter_table("comparison_record") as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"platforms", _JSON, nullable=False,
|
||||||
|
server_default=sa.text("'[]'"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("comparison_record") as batch_op:
|
||||||
|
batch_op.drop_column("platforms")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""comparison_record.fail_reason (失败卡展示原因)
|
||||||
|
|
||||||
|
Revision ID: comparison_record_fail_reason
|
||||||
|
Revises: user_manual_risk_fields
|
||||||
|
Create Date: 2026-07-28 12:00:00.000000
|
||||||
|
|
||||||
|
失败记录的展示原因:information 具体则=它;笼统则由写路径从 platform_results 捞出的
|
||||||
|
业务原因;纯系统失败为 None(端侧品牌兜底)。见 repositories.comparison._derive_fail_display。
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'comparison_record_fail_reason'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'user_manual_risk_fields'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('fail_reason', sa.String(length=256), nullable=True))
|
||||||
|
# 回填老失败记录:information 具体的直接搬过来(笼统/系统失败留 None → 端侧品牌兜底)。
|
||||||
|
# 新记录由写路径 _derive_fail_display 落库(含 platform_results 救援/补判),不走这条。
|
||||||
|
# platform_results 只在 raw_payload 里,SQL 里不易解析,故老记录不做救援/补判(可接受:
|
||||||
|
# 老 mixed/打烊记录回退品牌兜底);具体 information 的老记录本次即可显示真实原因。
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE comparison_record
|
||||||
|
SET fail_reason = information
|
||||||
|
WHERE status = 'failed'
|
||||||
|
AND information IS NOT NULL
|
||||||
|
AND information <> ''
|
||||||
|
AND information NOT IN (
|
||||||
|
'比价过程出错,请稍后重试',
|
||||||
|
'比价出错',
|
||||||
|
'比价未完成',
|
||||||
|
'done 参数缺少可验证的目标平台结果'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('fail_reason')
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""guide video play count is independent for coupon and comparison
|
||||||
|
|
||||||
|
Revision ID: guide_video_scene_unique
|
||||||
|
Revises: 6d2309208549
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "guide_video_scene_unique"
|
||||||
|
down_revision = "6d2309208549"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_index("uq_guide_video_play_user_seq", table_name="guide_video_play")
|
||||||
|
op.create_index(
|
||||||
|
"uq_guide_video_play_user_scene_seq",
|
||||||
|
"guide_video_play",
|
||||||
|
["user_id", "scene", "seq"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_guide_video_play_user_scene_seq", table_name="guide_video_play")
|
||||||
|
op.create_index(
|
||||||
|
"uq_guide_video_play_user_seq",
|
||||||
|
"guide_video_play",
|
||||||
|
["user_id", "seq"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""guide video prepare/start/ten-circle reward state machine
|
||||||
|
|
||||||
|
Revision ID: guide_video_ten_circle_v2
|
||||||
|
Revises: guide_video_scene_unique
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "guide_video_ten_circle_v2"
|
||||||
|
down_revision = "guide_video_scene_unique"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_configs(connection) -> None:
|
||||||
|
from app.core import media
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
for key in ("coupon_guide_video", "comparison_guide_video"):
|
||||||
|
row = connection.execute(
|
||||||
|
sa.text("SELECT value FROM app_config WHERE key = :key"), {"key": key}
|
||||||
|
).first()
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
raw = row[0]
|
||||||
|
value = json.loads(raw) if isinstance(raw, str) else dict(raw or {})
|
||||||
|
if value.get("guide_video_v2_migrated") is True:
|
||||||
|
continue
|
||||||
|
maximum = int(value.get("max_plays", 3) or 0)
|
||||||
|
reward = int(value.get("reward_coin", 100) or 0)
|
||||||
|
video_url = str(value.get("video_url") or "")
|
||||||
|
error = None
|
||||||
|
analysis = None
|
||||||
|
if not 1 <= maximum <= 50:
|
||||||
|
error = "旧播放次数不符合 1~50 约束"
|
||||||
|
elif not 10 <= reward <= 10_000 or reward % 10:
|
||||||
|
error = "旧金币总价不符合 10~10000 且为 10 的倍数约束"
|
||||||
|
elif not video_url:
|
||||||
|
error = "尚未上传视频"
|
||||||
|
else:
|
||||||
|
prefix = f"{settings.MEDIA_URL_PREFIX}/guide_video/"
|
||||||
|
if not video_url.startswith(prefix):
|
||||||
|
error = "旧视频不是服务端托管文件,请重新上传"
|
||||||
|
else:
|
||||||
|
path = Path(settings.MEDIA_ROOT) / "guide_video" / video_url[len(prefix):]
|
||||||
|
if not path.is_file():
|
||||||
|
error = "旧视频文件缺失,请重新上传"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
analysis = media._probe_guide_video(path)
|
||||||
|
except media.MediaError as exc:
|
||||||
|
error = str(exc)
|
||||||
|
value["config_version"] = int(value.get("config_version", 0) or 0) + 1
|
||||||
|
value["guide_video_v2_migrated"] = True
|
||||||
|
if analysis:
|
||||||
|
value.update(analysis)
|
||||||
|
else:
|
||||||
|
value.update(
|
||||||
|
enabled=False,
|
||||||
|
duration_ms=None,
|
||||||
|
video_codec=None,
|
||||||
|
audio_codec=None,
|
||||||
|
analysis_status="invalid" if video_url else "missing",
|
||||||
|
analysis_error=error,
|
||||||
|
)
|
||||||
|
statement = sa.text(
|
||||||
|
"UPDATE app_config SET value = :value WHERE key = :key"
|
||||||
|
).bindparams(sa.bindparam("value", type_=sa.JSON()))
|
||||||
|
connection.execute(statement, {"key": key, "value": value})
|
||||||
|
|
||||||
|
|
||||||
|
def _seal_legacy_plays(connection) -> None:
|
||||||
|
"""旧整笔发奖 token 永久封口;旧未发奖会话终止但仍保留计次行。"""
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE guide_video_play
|
||||||
|
SET status = CASE WHEN status = 'granted' THEN 'legacy_completed' ELSE 'legacy_closed' END,
|
||||||
|
settled_circles = CASE WHEN status = 'granted' THEN 10 ELSE 0 END,
|
||||||
|
prepared_at = started_at,
|
||||||
|
expires_at = started_at
|
||||||
|
WHERE status IN ('granted', 'playing')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_downgrade(connection) -> None:
|
||||||
|
"""清除未起播计划,并把所有保留 token 封成旧版不可再领奖的 granted。"""
|
||||||
|
connection.execute(
|
||||||
|
sa.text("DELETE FROM guide_video_play WHERE status = 'prepared'")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE guide_video_play
|
||||||
|
SET status = 'granted',
|
||||||
|
completed = CASE WHEN settled_circles >= 10 THEN 1 ELSE completed END,
|
||||||
|
granted_at = CASE
|
||||||
|
WHEN settled_circles >= 10 AND granted_at IS NULL THEN started_at
|
||||||
|
ELSE granted_at
|
||||||
|
END
|
||||||
|
WHERE status IN (
|
||||||
|
'started', 'completed', 'legacy_completed', 'legacy_closed'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 防御未知/中间状态:宁可删除,也绝不让旧应用把它当 playing 整笔发奖。
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
DELETE FROM guide_video_play
|
||||||
|
WHERE seq IS NULL OR started_at IS NULL OR status != 'granted'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("guide_video_play") as batch:
|
||||||
|
batch.alter_column("seq", existing_type=sa.Integer(), nullable=True)
|
||||||
|
batch.alter_column("started_at", existing_type=sa.DateTime(timezone=True), nullable=True)
|
||||||
|
batch.add_column(sa.Column("duration_ms", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
batch.add_column(sa.Column("config_version", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
batch.add_column(sa.Column("settled_circles", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
batch.add_column(
|
||||||
|
sa.Column("prepared_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now())
|
||||||
|
)
|
||||||
|
batch.add_column(sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
batch.alter_column("status", existing_type=sa.String(length=16), type_=sa.String(length=24))
|
||||||
|
op.create_index("ix_guide_video_play_expires_at", "guide_video_play", ["expires_at"])
|
||||||
|
op.create_index(
|
||||||
|
"ux_coin_transaction_guide_video_ref",
|
||||||
|
"coin_transaction",
|
||||||
|
["user_id", "biz_type", "ref_id"],
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=sa.text("biz_type = 'guide_video' AND ref_id IS NOT NULL"),
|
||||||
|
postgresql_where=sa.text("biz_type = 'guide_video' AND ref_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
connection = op.get_bind()
|
||||||
|
_seal_legacy_plays(connection)
|
||||||
|
_backfill_configs(connection)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_prepare_downgrade(op.get_bind())
|
||||||
|
op.drop_index("ux_coin_transaction_guide_video_ref", table_name="coin_transaction")
|
||||||
|
op.drop_index("ix_guide_video_play_expires_at", table_name="guide_video_play")
|
||||||
|
with op.batch_alter_table("guide_video_play") as batch:
|
||||||
|
batch.alter_column("status", existing_type=sa.String(length=24), type_=sa.String(length=16))
|
||||||
|
batch.drop_column("expires_at")
|
||||||
|
batch.drop_column("prepared_at")
|
||||||
|
batch.drop_column("settled_circles")
|
||||||
|
batch.drop_column("config_version")
|
||||||
|
batch.drop_column("duration_ms")
|
||||||
|
batch.alter_column("started_at", existing_type=sa.DateTime(timezone=True), nullable=False)
|
||||||
|
batch.alter_column("seq", existing_type=sa.Integer(), nullable=False)
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""store all global limit values in one complete JSON document
|
||||||
|
|
||||||
|
Revision ID: limit_policy_global_bundle
|
||||||
|
Revises: limit_policy_whitelist
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "limit_policy_global_bundle"
|
||||||
|
down_revision: str | Sequence[str] | None = "limit_policy_whitelist"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||||
|
_BUNDLE_KEY = "limit_policy_global"
|
||||||
|
|
||||||
|
# rule_code, old sparse key, default, legacy structured key, legacy JSON field
|
||||||
|
_RULES: tuple[tuple[str, str, int, str | None, str | None], ...] = (
|
||||||
|
("compare.start.daily", "compare_daily_limit", 100, None, None),
|
||||||
|
("sms.send.hourly", "sms_send_hourly_limit", 5, None, None),
|
||||||
|
("sms.send.daily", "sms_send_daily_limit", 20, None, None),
|
||||||
|
("sms.phone.cooldown", "sms_phone_cooldown_seconds", 60, None, None),
|
||||||
|
("sms.code.failed_attempts", "sms_code_max_failed_attempts", 5, None, None),
|
||||||
|
("sms.login.hourly", "sms_login_hourly_limit", 5, None, None),
|
||||||
|
("wechat.bind.hourly", "wechat_bind_sms_hourly_limit", 5, None, None),
|
||||||
|
("wechat.conflict.hourly", "wechat_conflict_hourly_limit", 5, None, None),
|
||||||
|
(
|
||||||
|
"ad.reward_video.daily",
|
||||||
|
"ad_reward_video_daily_limit",
|
||||||
|
500,
|
||||||
|
"ad_daily_limit",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ad.feed.daily",
|
||||||
|
"ad_feed_daily_limit",
|
||||||
|
500,
|
||||||
|
"ad_daily_limit",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
("ad.reward_video.cooldown", "ad_cooldown_sec", 3, None, None),
|
||||||
|
(
|
||||||
|
"guide.video.lifetime",
|
||||||
|
"guide_video_max_plays",
|
||||||
|
3,
|
||||||
|
"coupon_guide_video",
|
||||||
|
"max_plays",
|
||||||
|
),
|
||||||
|
("phone.rebind.days", "phone_rebind_days", 30, None, None),
|
||||||
|
("risk.sms.hourly", "risk_sms_hourly_threshold", 5, None, None),
|
||||||
|
(
|
||||||
|
"risk.oneclick.daily",
|
||||||
|
"risk_oneclick_daily_threshold",
|
||||||
|
20,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"risk.compare.daily",
|
||||||
|
"risk_compare_daily_threshold",
|
||||||
|
100,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _table() -> sa.TableClause:
|
||||||
|
return sa.table(
|
||||||
|
"app_config",
|
||||||
|
sa.column("key", sa.String(64)),
|
||||||
|
sa.column("value", _JSON),
|
||||||
|
sa.column("updated_by_admin_id", sa.Integer),
|
||||||
|
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(conn, table, key: str):
|
||||||
|
return conn.execute(
|
||||||
|
sa.select(
|
||||||
|
table.c.value,
|
||||||
|
table.c.updated_by_admin_id,
|
||||||
|
).where(table.c.key == key)
|
||||||
|
).mappings().first()
|
||||||
|
|
||||||
|
|
||||||
|
def _int_or_none(value: Any) -> int | None:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
table = _table()
|
||||||
|
values = {rule_code: default for rule_code, _, default, _, _ in _RULES}
|
||||||
|
|
||||||
|
# Old shared/structured values are the lowest-precedence compatibility
|
||||||
|
# source. Dedicated sparse keys override them.
|
||||||
|
for rule_code, _, _, legacy_key, legacy_field in _RULES:
|
||||||
|
if legacy_key is None:
|
||||||
|
continue
|
||||||
|
legacy = _row(conn, table, legacy_key)
|
||||||
|
if legacy is None:
|
||||||
|
continue
|
||||||
|
raw = legacy["value"]
|
||||||
|
if legacy_field is not None:
|
||||||
|
raw = raw.get(legacy_field) if isinstance(raw, dict) else None
|
||||||
|
parsed = _int_or_none(raw)
|
||||||
|
if parsed is not None:
|
||||||
|
values[rule_code] = parsed
|
||||||
|
|
||||||
|
for rule_code, sparse_key, _, _, _ in _RULES:
|
||||||
|
sparse = _row(conn, table, sparse_key)
|
||||||
|
parsed = _int_or_none(sparse["value"]) if sparse is not None else None
|
||||||
|
if parsed is not None:
|
||||||
|
values[rule_code] = parsed
|
||||||
|
|
||||||
|
# If a deployment already wrote the new key, preserve it over old keys.
|
||||||
|
bundle = _row(conn, table, _BUNDLE_KEY)
|
||||||
|
if bundle is not None and isinstance(bundle["value"], dict):
|
||||||
|
for rule_code, raw in bundle["value"].items():
|
||||||
|
if rule_code not in values:
|
||||||
|
continue
|
||||||
|
parsed = _int_or_none(raw)
|
||||||
|
if parsed is not None:
|
||||||
|
values[rule_code] = parsed
|
||||||
|
|
||||||
|
if bundle is None:
|
||||||
|
conn.execute(
|
||||||
|
table.insert().values(
|
||||||
|
key=_BUNDLE_KEY,
|
||||||
|
value=values,
|
||||||
|
updated_by_admin_id=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
table.update()
|
||||||
|
.where(table.c.key == _BUNDLE_KEY)
|
||||||
|
.values(value=values, updated_at=sa.func.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
sparse_keys = [sparse_key for _, sparse_key, _, _, _ in _RULES]
|
||||||
|
conn.execute(table.delete().where(table.c.key.in_(sparse_keys)))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
table = _table()
|
||||||
|
bundle = _row(conn, table, _BUNDLE_KEY)
|
||||||
|
values = (
|
||||||
|
bundle["value"]
|
||||||
|
if bundle is not None and isinstance(bundle["value"], dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
admin_id = bundle["updated_by_admin_id"] if bundle is not None else None
|
||||||
|
|
||||||
|
for rule_code, sparse_key, default, _, _ in _RULES:
|
||||||
|
value = _int_or_none(values.get(rule_code))
|
||||||
|
if value is None:
|
||||||
|
value = default
|
||||||
|
existing = _row(conn, table, sparse_key)
|
||||||
|
if existing is None:
|
||||||
|
conn.execute(
|
||||||
|
table.insert().values(
|
||||||
|
key=sparse_key,
|
||||||
|
value=value,
|
||||||
|
updated_by_admin_id=admin_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
table.update()
|
||||||
|
.where(table.c.key == sparse_key)
|
||||||
|
.values(
|
||||||
|
value=value,
|
||||||
|
updated_by_admin_id=admin_id,
|
||||||
|
updated_at=sa.func.now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(table.delete().where(table.c.key == _BUNDLE_KEY))
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""add per-subject limit policy whitelist
|
||||||
|
|
||||||
|
Revision ID: limit_policy_whitelist
|
||||||
|
Revises: push_binding_isolation
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "limit_policy_whitelist"
|
||||||
|
down_revision: str | Sequence[str] | None = "push_binding_isolation"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||||
|
_PAGE = "limit-whitelist"
|
||||||
|
_DEFAULT_ROLES = ("operator", "tech")
|
||||||
|
|
||||||
|
|
||||||
|
def _role_table() -> sa.TableClause:
|
||||||
|
return sa.table(
|
||||||
|
"admin_role",
|
||||||
|
sa.column("name", sa.String),
|
||||||
|
sa.column("pages", _JSON),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_default_role_permissions() -> None:
|
||||||
|
"""Grant the page without replacing existing role customisations."""
|
||||||
|
role = _role_table()
|
||||||
|
conn = op.get_bind()
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.select(role.c.name, role.c.pages).where(
|
||||||
|
role.c.name.in_(_DEFAULT_ROLES)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for name, pages in rows:
|
||||||
|
current_pages = list(pages or [])
|
||||||
|
if _PAGE not in current_pages:
|
||||||
|
conn.execute(
|
||||||
|
role.update()
|
||||||
|
.where(role.c.name == name)
|
||||||
|
.values(pages=[*current_pages, _PAGE])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_default_role_permissions() -> None:
|
||||||
|
role = _role_table()
|
||||||
|
conn = op.get_bind()
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.select(role.c.name, role.c.pages).where(
|
||||||
|
role.c.name.in_(_DEFAULT_ROLES)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for name, pages in rows:
|
||||||
|
current_pages = list(pages or [])
|
||||||
|
if _PAGE in current_pages:
|
||||||
|
conn.execute(
|
||||||
|
role.update()
|
||||||
|
.where(role.c.name == name)
|
||||||
|
.values(
|
||||||
|
pages=[page for page in current_pages if page != _PAGE]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"limit_policy_override",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("subject_type", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("subject_value", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("rule_code", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(length=24), nullable=False),
|
||||||
|
sa.Column("limit_value", sa.Integer(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"enabled", sa.Boolean(), server_default=sa.true(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("reason", sa.String(length=256), nullable=True),
|
||||||
|
sa.Column("created_by_admin_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"subject_type",
|
||||||
|
"subject_value",
|
||||||
|
"rule_code",
|
||||||
|
name="uq_limit_policy_subject_rule",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_limit_policy_lookup",
|
||||||
|
"limit_policy_override",
|
||||||
|
["subject_type", "subject_value", "rule_code", "enabled"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_limit_policy_expires",
|
||||||
|
"limit_policy_override",
|
||||||
|
["expires_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
_add_default_role_permissions()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_remove_default_role_permissions()
|
||||||
|
op.drop_index("ix_limit_policy_expires", table_name="limit_policy_override")
|
||||||
|
op.drop_index("ix_limit_policy_lookup", table_name="limit_policy_override")
|
||||||
|
op.drop_table("limit_policy_override")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""isolate vendor push binding across user accounts
|
||||||
|
|
||||||
|
Revision ID: push_binding_isolation
|
||||||
|
Revises: guide_video_ten_circle_v2
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "push_binding_isolation"
|
||||||
|
down_revision = "guide_video_ten_circle_v2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("device_liveness") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("push_binding_id", sa.String(length=128), nullable=True))
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"push_binding_revoked",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.false(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
"ix_device_liveness_push_binding_id",
|
||||||
|
["push_binding_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
device = sa.table(
|
||||||
|
"device_liveness",
|
||||||
|
sa.column("id", sa.Integer),
|
||||||
|
sa.column("push_vendor", sa.String),
|
||||||
|
sa.column("push_token", sa.String),
|
||||||
|
sa.column("updated_at", sa.DateTime),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
device.update()
|
||||||
|
.where(
|
||||||
|
sa.or_(
|
||||||
|
device.c.push_vendor == "",
|
||||||
|
device.c.push_token == "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(push_vendor=None, push_token=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = connection.execute(
|
||||||
|
sa.select(device.c.id, device.c.push_vendor, device.c.push_token)
|
||||||
|
.where(
|
||||||
|
device.c.push_vendor.is_not(None),
|
||||||
|
device.c.push_token.is_not(None),
|
||||||
|
)
|
||||||
|
.order_by(device.c.updated_at.desc(), device.c.id.desc())
|
||||||
|
)
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
duplicate_ids: list[int] = []
|
||||||
|
for row in rows:
|
||||||
|
key = (row.push_vendor, row.push_token)
|
||||||
|
if key in seen:
|
||||||
|
duplicate_ids.append(row.id)
|
||||||
|
else:
|
||||||
|
seen.add(key)
|
||||||
|
if duplicate_ids:
|
||||||
|
connection.execute(
|
||||||
|
device.update()
|
||||||
|
.where(device.c.id.in_(duplicate_ids))
|
||||||
|
.values(push_vendor=None, push_token=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table("device_liveness") as batch_op:
|
||||||
|
batch_op.create_unique_constraint(
|
||||||
|
"uq_device_liveness_vendor_token",
|
||||||
|
["push_vendor", "push_token"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("device_liveness") as batch_op:
|
||||||
|
batch_op.drop_constraint("uq_device_liveness_vendor_token", type_="unique")
|
||||||
|
batch_op.drop_index("ix_device_liveness_push_binding_id")
|
||||||
|
batch_op.drop_column("push_binding_revoked")
|
||||||
|
batch_op.drop_column("push_binding_id")
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""add manual high-risk flag and note to user
|
||||||
|
|
||||||
|
Revision ID: user_manual_risk_fields
|
||||||
|
Revises: risk_monitor_generic
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "user_manual_risk_fields"
|
||||||
|
down_revision: str | None = "risk_monitor_generic"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||||
|
_INVITE_WITHDRAW_PAGE = "invite-withdraws"
|
||||||
|
|
||||||
|
|
||||||
|
def _finance_role() -> sa.TableClause:
|
||||||
|
return sa.table(
|
||||||
|
"admin_role",
|
||||||
|
sa.column("name", sa.String),
|
||||||
|
sa.column("pages", _JSON),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("user") as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"is_high_risk",
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.false(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
batch_op.add_column(sa.Column("high_risk_note", sa.Text(), nullable=True))
|
||||||
|
batch_op.create_index("ix_user_is_high_risk", ["is_high_risk"], unique=False)
|
||||||
|
role = _finance_role()
|
||||||
|
conn = op.get_bind()
|
||||||
|
row = conn.execute(
|
||||||
|
sa.select(role.c.pages).where(role.c.name == "finance")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is not None and _INVITE_WITHDRAW_PAGE not in (row or []):
|
||||||
|
conn.execute(
|
||||||
|
role.update()
|
||||||
|
.where(role.c.name == "finance")
|
||||||
|
.values(pages=[*(row or []), _INVITE_WITHDRAW_PAGE])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
role = _finance_role()
|
||||||
|
conn = op.get_bind()
|
||||||
|
row = conn.execute(
|
||||||
|
sa.select(role.c.pages).where(role.c.name == "finance")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is not None:
|
||||||
|
conn.execute(
|
||||||
|
role.update()
|
||||||
|
.where(role.c.name == "finance")
|
||||||
|
.values(
|
||||||
|
pages=[
|
||||||
|
page for page in (row or []) if page != _INVITE_WITHDRAW_PAGE
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with op.batch_alter_table("user") as batch_op:
|
||||||
|
batch_op.drop_index("ix_user_is_high_risk")
|
||||||
|
batch_op.drop_column("high_risk_note")
|
||||||
|
batch_op.drop_column("is_high_risk")
|
||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
"""Admin 后台 FastAPI app(独立进程)。
|
"""Admin 后台 FastAPI app(独立进程)。
|
||||||
|
|
||||||
启动:uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8771
|
启动:uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8773
|
||||||
复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。
|
复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。
|
||||||
现有 app.main:app 不 import 本模块,两进程互不影响。
|
现有 app.main:app 不 import 本模块,两进程互不影响。
|
||||||
"""
|
"""
|
||||||
@@ -31,6 +31,7 @@ from app.admin.routers.feedback import router as feedback_router
|
|||||||
from app.admin.routers.feedback_qr import router as feedback_qr_router
|
from app.admin.routers.feedback_qr import router as feedback_qr_router
|
||||||
from app.admin.routers.guide_video import router as guide_video_router
|
from app.admin.routers.guide_video import router as guide_video_router
|
||||||
from app.admin.routers.huawei_review import router as huawei_review_router
|
from app.admin.routers.huawei_review import router as huawei_review_router
|
||||||
|
from app.admin.routers.limit_whitelist import router as limit_whitelist_router
|
||||||
from app.admin.routers.onboarding import router as onboarding_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.ops_marquee_seed import router as ops_marquee_seed_router
|
||||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||||
@@ -103,6 +104,7 @@ admin_app.include_router(wallet_router)
|
|||||||
admin_app.include_router(withdraw_router)
|
admin_app.include_router(withdraw_router)
|
||||||
admin_app.include_router(price_report_router)
|
admin_app.include_router(price_report_router)
|
||||||
admin_app.include_router(risk_monitor_router)
|
admin_app.include_router(risk_monitor_router)
|
||||||
|
admin_app.include_router(limit_whitelist_router)
|
||||||
admin_app.include_router(feedback_router)
|
admin_app.include_router(feedback_router)
|
||||||
admin_app.include_router(event_logs_router)
|
admin_app.include_router(event_logs_router)
|
||||||
admin_app.include_router(analytics_health_router)
|
admin_app.include_router(analytics_health_router)
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ PERMISSION_CATALOG: list[dict] = [
|
|||||||
{"key": "cps", "label": "CPS收益"},
|
{"key": "cps", "label": "CPS收益"},
|
||||||
]},
|
]},
|
||||||
{"group": "奖励审核", "pages": [
|
{"group": "奖励审核", "pages": [
|
||||||
{"key": "withdraws", "label": "提现审核"},
|
{"key": "invite-withdraws", "label": "邀请提现审核"},
|
||||||
|
{"key": "withdraws", "label": "其他提现审核"},
|
||||||
{"key": "price-reports", "label": "低价审核"},
|
{"key": "price-reports", "label": "低价审核"},
|
||||||
{"key": "feedbacks", "label": "用户反馈"},
|
{"key": "feedbacks", "label": "用户反馈"},
|
||||||
]},
|
]},
|
||||||
@@ -39,6 +40,7 @@ PERMISSION_CATALOG: list[dict] = [
|
|||||||
{"key": "analytics-health", "label": "埋点成功率"},
|
{"key": "analytics-health", "label": "埋点成功率"},
|
||||||
{"key": "event-logs", "label": "埋点日志"},
|
{"key": "event-logs", "label": "埋点日志"},
|
||||||
{"key": "audit-logs", "label": "审计日志"},
|
{"key": "audit-logs", "label": "审计日志"},
|
||||||
|
{"key": "limit-whitelist", "label": "白名单"},
|
||||||
]},
|
]},
|
||||||
{"group": "其他", "pages": [
|
{"group": "其他", "pages": [
|
||||||
{"key": "admins", "label": "权限管理"},
|
{"key": "admins", "label": "权限管理"},
|
||||||
@@ -57,13 +59,14 @@ BUILTIN_ROLES: list[dict] = [
|
|||||||
{"name": "operator", "label": "运营", "pages": [
|
{"name": "operator", "label": "运营", "pages": [
|
||||||
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
||||||
"cps", "risk-monitor", "device-liveness", "price-reports", "feedbacks", "huawei-review",
|
"cps", "risk-monitor", "device-liveness", "price-reports", "feedbacks", "huawei-review",
|
||||||
|
"limit-whitelist",
|
||||||
]},
|
]},
|
||||||
{"name": "finance", "label": "财务", "pages": [
|
{"name": "finance", "label": "财务", "pages": [
|
||||||
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
"dashboard", "ad-revenue-report", "cps", "invite-withdraws", "withdraws",
|
||||||
]},
|
]},
|
||||||
{"name": "tech", "label": "技术", "pages": [
|
{"name": "tech", "label": "技术", "pages": [
|
||||||
"dashboard", "risk-monitor", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review",
|
"dashboard", "risk-monitor", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review",
|
||||||
"event-logs", "audit-logs",
|
"event-logs", "audit-logs", "limit-whitelist",
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -57,12 +57,11 @@ def _reward_video_rows(
|
|||||||
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
||||||
|
|
||||||
records = list(db.execute(stmt).scalars())
|
records = list(db.execute(stmt).scalars())
|
||||||
# S2S 发奖回调本身不携带实际填充的 ADN/底层 rit;按客户端在展示时上报的
|
# S2S 发奖回调不携带实际填充 ADN;用相同用户和 ad_session_id 的展示记录回填。
|
||||||
# ad_session_id 回填。这样“纯发奖”行也能在运营后台追溯到真实广告网络。
|
session_ids = {record.ad_session_id for record in records if record.ad_session_id}
|
||||||
session_ids = {rec.ad_session_id for rec in records if rec.ad_session_id}
|
|
||||||
impression_by_session = {
|
impression_by_session = {
|
||||||
(rec.user_id, rec.ad_session_id): rec
|
(record.user_id, record.ad_session_id): record
|
||||||
for rec in db.execute(
|
for record in db.execute(
|
||||||
select(AdEcpmRecord).where(AdEcpmRecord.ad_session_id.in_(session_ids))
|
select(AdEcpmRecord).where(AdEcpmRecord.ad_session_id.in_(session_ids))
|
||||||
).scalars()
|
).scalars()
|
||||||
} if session_ids else {}
|
} if session_ids else {}
|
||||||
@@ -172,7 +171,7 @@ def _nonblank(value: str | None) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _unique_ad_source(records: list[AdEcpmRecord]) -> tuple[str | None, str | None]:
|
def _unique_ad_source(records: list[AdEcpmRecord]) -> tuple[str | None, str | None]:
|
||||||
"""仅在候选展示记录指向唯一 ADN 时回填来源,绝不把一次多广告流程猜成某一个网络。"""
|
"""仅在候选展示记录指向唯一 ADN 时回填来源,避免错误归因。"""
|
||||||
adns = {_nonblank(record.adn) for record in records}
|
adns = {_nonblank(record.adn) for record in records}
|
||||||
adns.discard(None)
|
adns.discard(None)
|
||||||
if len(adns) != 1:
|
if len(adns) != 1:
|
||||||
@@ -185,14 +184,11 @@ def _unique_ad_source(records: list[AdEcpmRecord]) -> tuple[str | None, str | No
|
|||||||
|
|
||||||
def _feed_source_fallbacks(
|
def _feed_source_fallbacks(
|
||||||
db: Session, records: list[AdFeedRewardRecord]
|
db: Session, records: list[AdFeedRewardRecord]
|
||||||
) -> tuple[dict[tuple[int, str], tuple[str | None, str | None]], dict[tuple[int, str, str], tuple[str | None, str | None]]]:
|
) -> tuple[
|
||||||
"""构建信息流来源回填索引。
|
dict[tuple[int, str], tuple[str | None, str | None]],
|
||||||
|
dict[tuple[int, str, str], tuple[str | None, str | None]],
|
||||||
新客户端会把 ADN 直接随 feed-reward 上报;旧记录可能缺失。展示收益记录的
|
]:
|
||||||
``ad_session_id`` 是每条 impressionId,而发奖记录保留的是整场会话 ID,因此先按
|
"""为旧信息流发奖记录构建安全来源索引。"""
|
||||||
会话精确匹配;匹配不到时仅允许按 ``user + trace_id + 原始 eCPM`` 回填,且候选 ADN
|
|
||||||
必须唯一。trace 内存在多个网络时保持空值,避免错误归因。
|
|
||||||
"""
|
|
||||||
session_ids = {record.ad_session_id for record in records if record.ad_session_id}
|
session_ids = {record.ad_session_id for record in records if record.ad_session_id}
|
||||||
trace_ids = {record.trace_id for record in records if record.trace_id}
|
trace_ids = {record.trace_id for record in records if record.trace_id}
|
||||||
if not session_ids and not trace_ids:
|
if not session_ids and not trace_ids:
|
||||||
@@ -227,7 +223,7 @@ def _feed_source(
|
|||||||
by_session: dict[tuple[int, str], tuple[str | None, str | None]],
|
by_session: dict[tuple[int, str], tuple[str | None, str | None]],
|
||||||
by_trace_ecpm: dict[tuple[int, str, str], tuple[str | None, str | None]],
|
by_trace_ecpm: dict[tuple[int, str, str], tuple[str | None, str | None]],
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
"""取得本条发奖广告的真实来源;无唯一证据时返回原始空值。"""
|
"""返回本条发奖广告的来源;无唯一证据时保留原始空值。"""
|
||||||
adn, slot_id = _nonblank(record.adn), _nonblank(record.slot_id)
|
adn, slot_id = _nonblank(record.adn), _nonblank(record.slot_id)
|
||||||
if adn and slot_id:
|
if adn and slot_id:
|
||||||
return adn, slot_id
|
return adn, slot_id
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ report_date / reward_date 归日。
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime, time, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from datetime import date as _date
|
from datetime import date as _date
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -43,7 +43,7 @@ _KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"})
|
|||||||
_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"})
|
_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"})
|
||||||
|
|
||||||
|
|
||||||
def _business_code_ids(db: Session, app_env: str | None) -> set[str]:
|
def business_code_ids(db: Session, app_env: str | None) -> set[str]:
|
||||||
"""返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。"""
|
"""返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。"""
|
||||||
prod_config = app_config.get_ad_config(db)
|
prod_config = app_config.get_ad_config(db)
|
||||||
prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | {
|
prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | {
|
||||||
@@ -81,9 +81,9 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
|
|||||||
# ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。
|
# ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。
|
||||||
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
|
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
|
||||||
|
|
||||||
# GroMore 官方说明第三方 ADN 的 Reporting API 最晚约 13:50 更新。只有 D+1 14:00
|
# 激励视频未满足有效播放条件时不计客户端预估收益。客户端仍会在 onAdShow
|
||||||
# 之后完成的同步才标记为「API 同步窗口完成」;这不代表覆盖全部 ADN 或最终结算。
|
# 上报 eCPM,随后才在关闭时补报以下终态,因此必须在展示/发奖合并后修正收益。
|
||||||
_PANGLE_API_FINAL_SYNC_TIME = time(hour=14)
|
_ZERO_REVENUE_REWARD_VIDEO_STATUSES = frozenset({"closed_early", "too_short"})
|
||||||
|
|
||||||
|
|
||||||
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
|
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
|
||||||
@@ -96,34 +96,13 @@ _REWARD_DETAIL_KEYS = (
|
|||||||
|
|
||||||
def _reward_detail(row: dict) -> dict:
|
def _reward_detail(row: dict) -> dict:
|
||||||
"""从 audit 行抽出发奖复算明细(给前端展开行渲染因子1/因子2/份数/LT/应发实发)。"""
|
"""从 audit 行抽出发奖复算明细(给前端展开行渲染因子1/因子2/份数/LT/应发实发)。"""
|
||||||
detail = {k: row[k] for k in _REWARD_DETAIL_KEYS}
|
detail = {key: row[key] for key in _REWARD_DETAIL_KEYS}
|
||||||
# 发奖明细必须保留自己的广告网络,不能复用整场聚合父行的来源:
|
# 聚合父行可能包含多个 ADN,来源必须保留在每一条发奖明细上。
|
||||||
# 同一次比价/领券可能先后由不同 ADN 填充。
|
|
||||||
detail["adn"] = row.get("adn")
|
detail["adn"] = row.get("adn")
|
||||||
detail["slot_id"] = row.get("slot_id")
|
detail["slot_id"] = row.get("slot_id")
|
||||||
return detail
|
return detail
|
||||||
|
|
||||||
|
|
||||||
def _as_cn(dt: datetime) -> datetime:
|
|
||||||
"""数据库 synced_at → 北京时间;SQLite naive 值按 UTC 处理。"""
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
dt = dt.replace(tzinfo=UTC)
|
|
||||||
return dt.astimezone(rewards.CN_TZ)
|
|
||||||
|
|
||||||
|
|
||||||
def _pangle_api_day_complete(day: str, aggregate: dict) -> bool:
|
|
||||||
"""某天 API 收益是否已在 D+1 14:00 后同步(仅表示同步窗口完成)。"""
|
|
||||||
synced_at = aggregate.get("synced_at")
|
|
||||||
if aggregate.get("api_revenue_yuan") is None or synced_at is None:
|
|
||||||
return False
|
|
||||||
cutoff = datetime.combine(
|
|
||||||
_date.fromisoformat(day) + timedelta(days=1),
|
|
||||||
_PANGLE_API_FINAL_SYNC_TIME,
|
|
||||||
tzinfo=rewards.CN_TZ,
|
|
||||||
)
|
|
||||||
return _as_cn(synced_at) >= cutoff
|
|
||||||
|
|
||||||
|
|
||||||
def ad_revenue_report(
|
def ad_revenue_report(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
@@ -211,10 +190,12 @@ def ad_revenue_report(
|
|||||||
"has_impression": True,
|
"has_impression": True,
|
||||||
"impressions": 1,
|
"impressions": 1,
|
||||||
"ecpm": rec.ecpm_raw,
|
"ecpm": rec.ecpm_raw,
|
||||||
# 客户端 SDK 展示预估收益(元)= 后端留存 getEcpm 元/千次 ÷ 1000。
|
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次)。eCPM 先钳到 AD_ECPM_MAX_FEN(¥500 CPM)
|
||||||
# 这里不能复用发奖防作弊的 ¥500 CPM 钳顶:钳顶只限制金币成本,不改变广告已产生的
|
# 再折收益,与发奖口径 [rewards.calculate_ad_reward_coin] 一致(2026-06-29 修:原裸 parse_ecpm_yuan
|
||||||
# 收入估值。onAdShow 已发生即计展示收入,是否看满只影响发奖,不影响广告收入。
|
# 不钳,伪造/异常天价 eCPM 会把报表预估收益冲到任意大;金币侧已钳、收益侧漏钳)。
|
||||||
"revenue_yuan": round(rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0, 6),
|
"revenue_yuan": round(
|
||||||
|
min(rewards.parse_ecpm_yuan(rec.ecpm_raw), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0, 6,
|
||||||
|
),
|
||||||
"adn": rec.adn,
|
"adn": rec.adn,
|
||||||
"slot_id": rec.slot_id,
|
"slot_id": rec.slot_id,
|
||||||
"sub_rewards": [],
|
"sub_rewards": [],
|
||||||
@@ -229,6 +210,11 @@ def ad_revenue_report(
|
|||||||
"matched": bool(rwd["matched"]),
|
"matched": bool(rwd["matched"]),
|
||||||
"reward_detail": _reward_detail(rwd),
|
"reward_detail": _reward_detail(rwd),
|
||||||
})
|
})
|
||||||
|
if (
|
||||||
|
rec.ad_type == "reward_video"
|
||||||
|
and rwd["status"] in _ZERO_REVENUE_REWARD_VIDEO_STATUSES
|
||||||
|
):
|
||||||
|
ev["revenue_yuan"] = 0.0
|
||||||
else:
|
else:
|
||||||
# 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。
|
# 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。
|
||||||
ev.update({
|
ev.update({
|
||||||
@@ -289,10 +275,10 @@ def ad_revenue_report(
|
|||||||
# 父行 eCPM:组内各条 eCPM(分)均值(展示用,各条不同);无有效值则取代表条
|
# 父行 eCPM:组内各条 eCPM(分)均值(展示用,各条不同);无有效值则取代表条
|
||||||
ecpm_fens = [rewards.parse_ecpm_fen(g["ecpm"]) for g in group if g.get("ecpm")]
|
ecpm_fens = [rewards.parse_ecpm_fen(g["ecpm"]) for g in group if g.get("ecpm")]
|
||||||
avg_ecpm = str(round(sum(ecpm_fens) / len(ecpm_fens))) if ecpm_fens else rep.get("ecpm")
|
avg_ecpm = str(round(sum(ecpm_fens) / len(ecpm_fens))) if ecpm_fens else rep.get("ecpm")
|
||||||
# 主表逐行显示用:这次发奖广告的预估收益之和(发奖侧 eCPM 折算)。只放进
|
# 主表逐行显示用:这次发奖广告的预估收益之和(发奖侧 eCPM 折算,钳顶同展示侧)。只放进
|
||||||
# row_revenue_yuan 给主表逐行展示,不进 revenue_yuan/合计/趋势——避免与展示侧 total 重复计。
|
# row_revenue_yuan 给主表逐行展示,不进 revenue_yuan/合计/趋势——避免与展示侧 total 重复计。
|
||||||
row_revenue = round(sum(
|
row_revenue = round(sum(
|
||||||
rewards.parse_ecpm_yuan(g["ecpm"]) / 1000.0
|
min(rewards.parse_ecpm_yuan(g["ecpm"]), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0
|
||||||
for g in group if g.get("ecpm")
|
for g in group if g.get("ecpm")
|
||||||
), 6)
|
), 6)
|
||||||
events.append({
|
events.append({
|
||||||
@@ -334,10 +320,10 @@ def ad_revenue_report(
|
|||||||
|
|
||||||
# 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试
|
# 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试
|
||||||
# demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。
|
# demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。
|
||||||
business_code_ids: set[str] | None = None
|
business_ids: set[str] | None = None
|
||||||
if revenue_scope == "business":
|
if revenue_scope == "business":
|
||||||
business_code_ids = _business_code_ids(db, app_env)
|
business_ids = business_code_ids(db, app_env)
|
||||||
events = [e for e in events if e.get("our_code_id") in business_code_ids]
|
events = [e for e in events if e.get("our_code_id") in business_ids]
|
||||||
|
|
||||||
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
|
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
|
||||||
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
||||||
@@ -382,22 +368,20 @@ def ad_revenue_report(
|
|||||||
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||||
]
|
]
|
||||||
|
|
||||||
# GroMore 排序价预估 / ADN Reporting API 收益(T+1 入库):汇总 + 按天趋势级展示,
|
# 穿山甲后台收益(GroMore 数据 API,T+1 入库 ad_pangle_daily_revenue):汇总 + 按天趋势级展示,
|
||||||
# 与上面客户端自报 eCPM 折算的预估并列对照(看 gap)。穿山甲数据**无用户/场景/类型维度**,故仅在
|
# 与上面客户端自报 eCPM 折算的预估并列对照(看 gap)。穿山甲数据**无用户/场景/类型维度**,故仅在
|
||||||
# 「全量视图」(未按 user_id / ad_type / feed_scene 过滤)给值;一旦带这些过滤,穿山甲数无法对应口径
|
# 「全量视图」(未按 user_id / ad_type / feed_scene 过滤)给值;一旦带这些过滤,穿山甲数无法对应口径
|
||||||
# → 置 None,前端显示「-」并提示。逐条事件行不动(仍是客户端预估)。
|
# → 置 None,前端显示「-」并提示。逐条事件行不动(仍是客户端预估)。
|
||||||
pangle_filterable = user_id is None and ad_type is None and feed_scene is None
|
pangle_filterable = user_id is None and ad_type is None and feed_scene is None
|
||||||
total_pangle_revenue_yuan: float | None = None
|
total_pangle_revenue_yuan: float | None = None
|
||||||
total_pangle_api_revenue_yuan: float | None = None
|
total_pangle_api_revenue_yuan: float | None = None
|
||||||
pangle_api_revenue_complete = False
|
|
||||||
pangle_latest_synced_at: datetime | None = None
|
|
||||||
if pangle_filterable:
|
if pangle_filterable:
|
||||||
pangle_aggs = ad_pangle_revenue.aggregate_by_date(
|
pangle_aggs = ad_pangle_revenue.aggregate_by_date(
|
||||||
db,
|
db,
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
app_env=app_env,
|
app_env=app_env,
|
||||||
our_code_ids=business_code_ids,
|
our_code_ids=business_ids,
|
||||||
)
|
)
|
||||||
if pangle_aggs:
|
if pangle_aggs:
|
||||||
by_date = {a["date"]: a for a in pangle_aggs}
|
by_date = {a["date"]: a for a in pangle_aggs}
|
||||||
@@ -408,12 +392,6 @@ def ad_revenue_report(
|
|||||||
total_pangle_revenue_yuan = round(sum(a["revenue_yuan"] for a in pangle_aggs), 6)
|
total_pangle_revenue_yuan = round(sum(a["revenue_yuan"] for a in pangle_aggs), 6)
|
||||||
api_vals = [a["api_revenue_yuan"] for a in pangle_aggs if a["api_revenue_yuan"] is not None]
|
api_vals = [a["api_revenue_yuan"] for a in pangle_aggs if a["api_revenue_yuan"] is not None]
|
||||||
total_pangle_api_revenue_yuan = round(sum(api_vals), 6) if api_vals else None
|
total_pangle_api_revenue_yuan = round(sum(api_vals), 6) if api_vals else None
|
||||||
sync_times = [a["synced_at"] for a in pangle_aggs if a["synced_at"] is not None]
|
|
||||||
pangle_latest_synced_at = max(sync_times) if sync_times else None
|
|
||||||
pangle_api_revenue_complete = all(
|
|
||||||
day in by_date and _pangle_api_day_complete(day, by_date[day])
|
|
||||||
for day in _date_range(date_from, date_to)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 按小时汇总(全量,不受分页 limit/offset 影响):供前端按小时趋势图(单日 granularity=hour 时用)。
|
# 按小时汇总(全量,不受分页 limit/offset 影响):供前端按小时趋势图(单日 granularity=hour 时用)。
|
||||||
# 只在 by_hour 下聚合(此时每个 event 带 hour);否则空。前端按天趋势仍用 daily。
|
# 只在 by_hour 下聚合(此时每个 event 带 hour);否则空。前端按天趋势仍用 daily。
|
||||||
@@ -438,50 +416,73 @@ def ad_revenue_report(
|
|||||||
for hd in sorted(hour_map.values(), key=lambda x: x["hour"])
|
for hd in sorted(hour_map.values(), key=lambda x: x["hour"])
|
||||||
]
|
]
|
||||||
|
|
||||||
def _aggregate_stats(bucket_of) -> dict[str, dict]:
|
# 分广告类型小计(按 ad_type:展示条数 + 预估收益;eCPM 由前端用 收益÷展示×1000 算)。
|
||||||
"""按展示事件聚合收益 / 加权 SDK eCPM,避免前端漏合并历史类型。"""
|
# 基于全量(已按 feed_scene 过滤)events;前端只取 draw / reward_video 两类展示。
|
||||||
stat_map: dict[str, dict] = {}
|
type_map: dict[str, dict] = {}
|
||||||
for e in events:
|
for e in events:
|
||||||
bucket = bucket_of(e)
|
t = type_map.get(e["ad_type"])
|
||||||
if bucket is None:
|
if t is None:
|
||||||
continue
|
t = {"impressions": 0, "revenue_yuan": 0.0}
|
||||||
stat = stat_map.setdefault(bucket, {
|
type_map[e["ad_type"]] = t
|
||||||
"impressions": 0,
|
t["impressions"] += e["impressions"]
|
||||||
"revenue_yuan": 0.0,
|
t["revenue_yuan"] += e["revenue_yuan"]
|
||||||
"ecpm_fen_sum": 0.0,
|
type_stats = {
|
||||||
})
|
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
|
||||||
impressions = int(e["impressions"])
|
for k, v in type_map.items()
|
||||||
stat["impressions"] += impressions
|
}
|
||||||
stat["revenue_yuan"] += e["revenue_yuan"]
|
|
||||||
# eCPM 必须以每次真实展示为权重;纯发奖父行 impressions=0,不能参与分母或均值。
|
|
||||||
stat["ecpm_fen_sum"] += rewards.parse_ecpm_fen(e["ecpm"]) * impressions
|
|
||||||
return {
|
|
||||||
key: {
|
|
||||||
"impressions": value["impressions"],
|
|
||||||
"revenue_yuan": round(value["revenue_yuan"], 6),
|
|
||||||
"ecpm_yuan": round(
|
|
||||||
value["ecpm_fen_sum"] / value["impressions"] / 100.0,
|
|
||||||
6,
|
|
||||||
) if value["impressions"] else 0.0,
|
|
||||||
}
|
|
||||||
for key, value in stat_map.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
# 原始 ad_type 小计,供明细筛选和排查使用。
|
# 经营看板的两类 eCPM 必须按真实展示的 SDK eCPM 加权,不能用发奖状态修正后的
|
||||||
type_stats = _aggregate_stats(lambda e: e["ad_type"])
|
# 收益反推。Draw 包含新 draw 与历史 feed;看视频包含福利与提现视频。
|
||||||
# 经营看板使用的规范分类:Draw 包含历史 feed;看视频包含福利与提现视频。
|
category_map: dict[str, dict] = {}
|
||||||
# 这两个集合与筛选逻辑保持一致,避免只取 draw / reward_video 而漏算历史或提现数据。
|
for event in events:
|
||||||
category_stats = _aggregate_stats(
|
category = (
|
||||||
lambda e: (
|
"draw" if event["ad_type"] in {"draw", "feed"}
|
||||||
"draw" if e["ad_type"] in {"draw", "feed"}
|
else "video" if event["ad_type"] in {"reward_video", "withdrawal_video"}
|
||||||
else "video" if e["ad_type"] in {"reward_video", "withdrawal_video"}
|
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
)
|
if category is None:
|
||||||
|
continue
|
||||||
|
stat = category_map.setdefault(category, {
|
||||||
|
"impressions": 0,
|
||||||
|
"revenue_yuan": 0.0,
|
||||||
|
"ecpm_fen_sum": 0.0,
|
||||||
|
})
|
||||||
|
impressions = int(event["impressions"])
|
||||||
|
stat["impressions"] += impressions
|
||||||
|
stat["revenue_yuan"] += event["revenue_yuan"]
|
||||||
|
stat["ecpm_fen_sum"] += rewards.parse_ecpm_fen(event["ecpm"]) * impressions
|
||||||
|
category_stats = {
|
||||||
|
key: {
|
||||||
|
"impressions": value["impressions"],
|
||||||
|
"revenue_yuan": round(value["revenue_yuan"], 6),
|
||||||
|
"ecpm_yuan": round(
|
||||||
|
value["ecpm_fen_sum"] / value["impressions"] / 100.0,
|
||||||
|
6,
|
||||||
|
) if value["impressions"] else 0.0,
|
||||||
|
}
|
||||||
|
for key, value in category_map.items()
|
||||||
|
}
|
||||||
|
|
||||||
# 分场景小计,同 type_stats 基于全量 events,供数据大盘「领券广告 / 比价广告」卡使用。
|
# 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events——
|
||||||
# feed_scene 为空的激励视频 / 历史数据不计入任何场景桶。
|
# 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算,
|
||||||
scene_stats = _aggregate_stats(lambda e: e.get("feed_scene"))
|
# 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0;
|
||||||
|
# 改为服务端在全量上聚合下发(也顺带不受 limit 分页截断影响)。feed_scene 为空(激励视频 /
|
||||||
|
# 旧数据)不计入任何场景桶。
|
||||||
|
scene_map: dict[str, dict] = {}
|
||||||
|
for e in events:
|
||||||
|
sc = e.get("feed_scene")
|
||||||
|
if not sc:
|
||||||
|
continue
|
||||||
|
s = scene_map.get(sc)
|
||||||
|
if s is None:
|
||||||
|
s = {"impressions": 0, "revenue_yuan": 0.0}
|
||||||
|
scene_map[sc] = s
|
||||||
|
s["impressions"] += e["impressions"]
|
||||||
|
s["revenue_yuan"] += e["revenue_yuan"]
|
||||||
|
scene_stats = {
|
||||||
|
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
|
||||||
|
for k, v in scene_map.items()
|
||||||
|
}
|
||||||
|
|
||||||
# DAU:复用数据大盘活跃用户口径(登录 + 开始比价 + 开始领券,按用户去重),按所选日期区间
|
# DAU:复用数据大盘活跃用户口径(登录 + 开始比价 + 开始领券,按用户去重),按所选日期区间
|
||||||
# 统计(含今日),历史 / 多天区间同样有值。ARPU = 区间预估收益 ÷ 区间活跃用户。全局口径,
|
# 统计(含今日),历史 / 多天区间同样有值。ARPU = 区间预估收益 ÷ 区间活跃用户。全局口径,
|
||||||
@@ -506,11 +507,9 @@ def ad_revenue_report(
|
|||||||
"truncated": len(main_rows) > offset + limit,
|
"truncated": len(main_rows) > offset + limit,
|
||||||
"total_impressions": total_impressions,
|
"total_impressions": total_impressions,
|
||||||
"total_revenue_yuan": total_revenue_yuan,
|
"total_revenue_yuan": total_revenue_yuan,
|
||||||
# GroMore 排序价预估 + ADN Reporting API 收益;非全量视图或无数据为 None。
|
# 穿山甲后台收益合计(元):预估 revenue + 收益Api;非全量视图(带 user/类型/场景过滤)或无数据为 None。
|
||||||
"total_pangle_revenue_yuan": total_pangle_revenue_yuan,
|
"total_pangle_revenue_yuan": total_pangle_revenue_yuan,
|
||||||
"total_pangle_api_revenue_yuan": total_pangle_api_revenue_yuan,
|
"total_pangle_api_revenue_yuan": total_pangle_api_revenue_yuan,
|
||||||
"pangle_api_revenue_complete": pangle_api_revenue_complete,
|
|
||||||
"pangle_latest_synced_at": pangle_latest_synced_at,
|
|
||||||
"pangle_revenue_available": total_pangle_revenue_yuan is not None,
|
"pangle_revenue_available": total_pangle_revenue_yuan is not None,
|
||||||
"total_expected_coin": total_expected_coin,
|
"total_expected_coin": total_expected_coin,
|
||||||
"total_actual_coin": total_actual_coin,
|
"total_actual_coin": total_actual_coin,
|
||||||
|
|||||||
@@ -0,0 +1,583 @@
|
|||||||
|
"""CRUD and presentation helpers for limit policy overrides."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from hashlib import blake2b
|
||||||
|
|
||||||
|
from sqlalchemy import String, cast, func, or_, select, text
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core import limit_policy
|
||||||
|
from app.models.app_config import AppConfig
|
||||||
|
from app.models.comparison import ComparisonRecord
|
||||||
|
from app.models.limit_policy import LimitPolicyOverride
|
||||||
|
from app.models.risk import BehaviorEvent
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories import risk as risk_repo
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateOverrideError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def lock_subject(db: Session, *, subject_type: str, subject_value: str) -> None:
|
||||||
|
"""Serialize writes for one logical whitelist subject on PostgreSQL.
|
||||||
|
|
||||||
|
Row locks cannot protect a brand-new subject because there is no row to
|
||||||
|
lock yet. A transaction-scoped advisory lock closes that gap and also
|
||||||
|
serializes concurrent appends that touch different rule rows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
bind = db.get_bind()
|
||||||
|
if bind.dialect.name != "postgresql":
|
||||||
|
return
|
||||||
|
identity = f"limit-whitelist:{subject_type}:{subject_value}".encode()
|
||||||
|
lock_id = int.from_bytes(
|
||||||
|
blake2b(identity, digest_size=8).digest(),
|
||||||
|
byteorder="big",
|
||||||
|
signed=True,
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
text("SELECT pg_advisory_xact_lock(:lock_id)"),
|
||||||
|
{"lock_id": lock_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_EVENT_SOURCE_LABELS = {
|
||||||
|
risk_repo.EVENT_SMS_SEND: "短信验证码",
|
||||||
|
risk_repo.EVENT_SMS_LOGIN: "短信登录",
|
||||||
|
risk_repo.EVENT_ONECLICK_LOGIN: "一键登录",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_users(keyword: str):
|
||||||
|
pattern = f"%{keyword}%"
|
||||||
|
return select(User.id).where(
|
||||||
|
or_(
|
||||||
|
cast(User.id, String).ilike(pattern),
|
||||||
|
User.username.ilike(pattern),
|
||||||
|
User.phone.ilike(pattern),
|
||||||
|
User.nickname.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_maps(
|
||||||
|
db: Session, *, user_ids: set[int], phones: set[str]
|
||||||
|
) -> tuple[dict[int, User], dict[str, User]]:
|
||||||
|
conditions = []
|
||||||
|
if user_ids:
|
||||||
|
conditions.append(User.id.in_(user_ids))
|
||||||
|
if phones:
|
||||||
|
conditions.append(User.phone.in_(phones))
|
||||||
|
if not conditions:
|
||||||
|
return {}, {}
|
||||||
|
users = list(db.scalars(select(User).where(or_(*conditions))).all())
|
||||||
|
return {user.id: user for user in users}, {user.phone: user for user in users}
|
||||||
|
|
||||||
|
|
||||||
|
def _behavior_device_candidates(db: Session, *, keyword: str | None, limit: int) -> list[dict]:
|
||||||
|
conditions = [
|
||||||
|
BehaviorEvent.subject_type == limit_policy.SUBJECT_DEVICE,
|
||||||
|
BehaviorEvent.subject_id != "",
|
||||||
|
BehaviorEvent.subject_id.not_like(f"{limit_policy.LEGACY_IP_DEVICE_PREFIX}%"),
|
||||||
|
]
|
||||||
|
if keyword:
|
||||||
|
value = keyword.strip()
|
||||||
|
pattern = f"%{value}%"
|
||||||
|
matched_users = _matching_users(value)
|
||||||
|
conditions.append(
|
||||||
|
or_(
|
||||||
|
BehaviorEvent.subject_id.ilike(pattern),
|
||||||
|
BehaviorEvent.device_id.ilike(pattern),
|
||||||
|
BehaviorEvent.device_model.ilike(pattern),
|
||||||
|
BehaviorEvent.phone.ilike(pattern),
|
||||||
|
cast(BehaviorEvent.user_id, String).ilike(pattern),
|
||||||
|
BehaviorEvent.user_id.in_(matched_users),
|
||||||
|
BehaviorEvent.phone.in_(
|
||||||
|
select(User.phone).where(User.id.in_(_matching_users(value)))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ranked = (
|
||||||
|
select(
|
||||||
|
BehaviorEvent.subject_id.label("device_id"),
|
||||||
|
BehaviorEvent.event_type,
|
||||||
|
BehaviorEvent.user_id,
|
||||||
|
BehaviorEvent.phone,
|
||||||
|
BehaviorEvent.device_model,
|
||||||
|
BehaviorEvent.occurred_at.label("last_active_at"),
|
||||||
|
func.row_number()
|
||||||
|
.over(
|
||||||
|
partition_by=BehaviorEvent.subject_id,
|
||||||
|
order_by=(
|
||||||
|
BehaviorEvent.occurred_at.desc(),
|
||||||
|
BehaviorEvent.id.desc(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.label("rank"),
|
||||||
|
)
|
||||||
|
.where(*conditions)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
rows = db.execute(
|
||||||
|
select(ranked)
|
||||||
|
.where(ranked.c.rank == 1)
|
||||||
|
.order_by(ranked.c.last_active_at.desc(), ranked.c.device_id.asc())
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
by_id, by_phone = _user_maps(
|
||||||
|
db,
|
||||||
|
user_ids={row.user_id for row in rows if row.user_id is not None},
|
||||||
|
phones={row.phone for row in rows if row.phone},
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
user = by_id.get(row.user_id) if row.user_id is not None else None
|
||||||
|
user = user or (by_phone.get(row.phone) if row.phone else None)
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"device_id": row.device_id,
|
||||||
|
"source": row.event_type,
|
||||||
|
"source_label": _EVENT_SOURCE_LABELS.get(row.event_type, "登录/鉴权流水"),
|
||||||
|
"user_id": user.id if user else row.user_id,
|
||||||
|
"username": user.username if user else None,
|
||||||
|
"phone": user.phone if user else row.phone,
|
||||||
|
"nickname": user.nickname if user else None,
|
||||||
|
"device_model": row.device_model,
|
||||||
|
"last_active_at": row.last_active_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _comparison_device_candidates(db: Session, *, keyword: str | None, limit: int) -> list[dict]:
|
||||||
|
conditions = [
|
||||||
|
ComparisonRecord.device_id.is_not(None),
|
||||||
|
ComparisonRecord.device_id != "",
|
||||||
|
]
|
||||||
|
if keyword:
|
||||||
|
value = keyword.strip()
|
||||||
|
pattern = f"%{value}%"
|
||||||
|
conditions.append(
|
||||||
|
or_(
|
||||||
|
ComparisonRecord.device_id.ilike(pattern),
|
||||||
|
ComparisonRecord.device_model.ilike(pattern),
|
||||||
|
cast(ComparisonRecord.user_id, String).ilike(pattern),
|
||||||
|
ComparisonRecord.user_id.in_(_matching_users(value)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ranked = (
|
||||||
|
select(
|
||||||
|
ComparisonRecord.device_id,
|
||||||
|
ComparisonRecord.user_id,
|
||||||
|
ComparisonRecord.device_model,
|
||||||
|
ComparisonRecord.created_at.label("last_active_at"),
|
||||||
|
func.row_number()
|
||||||
|
.over(
|
||||||
|
partition_by=ComparisonRecord.device_id,
|
||||||
|
order_by=(
|
||||||
|
ComparisonRecord.created_at.desc(),
|
||||||
|
ComparisonRecord.id.desc(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.label("rank"),
|
||||||
|
)
|
||||||
|
.where(*conditions)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
rows = db.execute(
|
||||||
|
select(ranked)
|
||||||
|
.where(ranked.c.rank == 1)
|
||||||
|
.order_by(ranked.c.last_active_at.desc(), ranked.c.device_id.asc())
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
by_id, _ = _user_maps(
|
||||||
|
db,
|
||||||
|
user_ids={row.user_id for row in rows if row.user_id is not None},
|
||||||
|
phones=set(),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"device_id": row.device_id,
|
||||||
|
"source": "comparison_record",
|
||||||
|
"source_label": "比价记录",
|
||||||
|
"user_id": user.id if user else row.user_id,
|
||||||
|
"username": user.username if user else None,
|
||||||
|
"phone": user.phone if user else None,
|
||||||
|
"nickname": user.nickname if user else None,
|
||||||
|
"device_model": row.device_model,
|
||||||
|
"last_active_at": row.last_active_at,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
for user in [by_id.get(row.user_id) if row.user_id is not None else None]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def list_device_candidates(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
rule_code: str,
|
||||||
|
keyword: str | None = None,
|
||||||
|
limit: int = 30,
|
||||||
|
) -> list[dict]:
|
||||||
|
rule = limit_policy.get_rule(rule_code)
|
||||||
|
if limit_policy.SUBJECT_DEVICE not in rule.subject_types:
|
||||||
|
raise ValueError("当前限制项不支持设备白名单")
|
||||||
|
if limit_policy.device_source_scope(rule_code) == "comparison":
|
||||||
|
return _comparison_device_candidates(db, keyword=keyword, limit=limit)
|
||||||
|
return _behavior_device_candidates(db, keyword=keyword, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
|
||||||
|
|
||||||
|
|
||||||
|
def status_of(row: LimitPolicyOverride, *, now: datetime | None = None) -> str:
|
||||||
|
now = _aware(now) or datetime.now(UTC)
|
||||||
|
if not row.enabled:
|
||||||
|
return "disabled"
|
||||||
|
if _aware(row.starts_at) and _aware(row.starts_at) > now:
|
||||||
|
return "scheduled"
|
||||||
|
if _aware(row.expires_at) and _aware(row.expires_at) <= now:
|
||||||
|
return "expired"
|
||||||
|
return "active"
|
||||||
|
|
||||||
|
|
||||||
|
def to_dict(db: Session, row: LimitPolicyOverride) -> dict:
|
||||||
|
rule = limit_policy.get_rule(row.rule_code)
|
||||||
|
effective = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
row.rule_code,
|
||||||
|
phone=row.subject_value if row.subject_type == "phone" else None,
|
||||||
|
device=row.subject_value if row.subject_type == "device" else None,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"subject_type": row.subject_type,
|
||||||
|
"subject_value": row.subject_value,
|
||||||
|
"rule_code": row.rule_code,
|
||||||
|
"rule_label": rule.label,
|
||||||
|
"rule_group": rule.group,
|
||||||
|
"mode": row.mode,
|
||||||
|
"limit_value": row.limit_value,
|
||||||
|
"global_limit": effective.global_limit,
|
||||||
|
"effective_limit": effective.limit,
|
||||||
|
"enabled": row.enabled,
|
||||||
|
"starts_at": row.starts_at,
|
||||||
|
"expires_at": row.expires_at,
|
||||||
|
"reset_at": row.reset_at,
|
||||||
|
"reason": row.reason,
|
||||||
|
"status": status_of(row),
|
||||||
|
"created_by_admin_id": row.created_by_admin_id,
|
||||||
|
"created_at": row.created_at,
|
||||||
|
"updated_at": row.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_rows(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
subject_type: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
rule_code: str | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> tuple[list[LimitPolicyOverride], int]:
|
||||||
|
stmt = select(LimitPolicyOverride)
|
||||||
|
count_stmt = select(func.count(LimitPolicyOverride.id))
|
||||||
|
conditions = []
|
||||||
|
if subject_type:
|
||||||
|
conditions.append(LimitPolicyOverride.subject_type == subject_type)
|
||||||
|
if keyword:
|
||||||
|
conditions.append(LimitPolicyOverride.subject_value.ilike(f"%{keyword.strip()}%"))
|
||||||
|
if rule_code:
|
||||||
|
conditions.append(LimitPolicyOverride.rule_code == rule_code)
|
||||||
|
if conditions:
|
||||||
|
stmt = stmt.where(*conditions)
|
||||||
|
count_stmt = count_stmt.where(*conditions)
|
||||||
|
total = int(db.scalar(count_stmt) or 0)
|
||||||
|
rows = list(
|
||||||
|
db.execute(
|
||||||
|
stmt.order_by(
|
||||||
|
LimitPolicyOverride.created_at.desc(),
|
||||||
|
LimitPolicyOverride.id.desc(),
|
||||||
|
)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
return rows, total
|
||||||
|
|
||||||
|
|
||||||
|
def list_subject_rows(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
subject_type: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
rule_code: str | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> tuple[list[tuple[str, str, list[LimitPolicyOverride], datetime]], int]:
|
||||||
|
"""List whitelist entries grouped and paginated by subject.
|
||||||
|
|
||||||
|
The filter decides which subjects match. Once a subject matches, all of
|
||||||
|
its rules are returned so category counts and the edit form stay complete.
|
||||||
|
Ordering uses the original creation time, therefore editing a subject does
|
||||||
|
not unexpectedly move it to the first page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
conditions = [
|
||||||
|
LimitPolicyOverride.mode.in_(
|
||||||
|
[limit_policy.MODE_UNLIMITED, limit_policy.MODE_SUPPRESS_ALERT]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if subject_type:
|
||||||
|
conditions.append(LimitPolicyOverride.subject_type == subject_type)
|
||||||
|
if keyword:
|
||||||
|
conditions.append(LimitPolicyOverride.subject_value.ilike(f"%{keyword.strip()}%"))
|
||||||
|
if rule_code:
|
||||||
|
conditions.append(LimitPolicyOverride.rule_code == rule_code)
|
||||||
|
|
||||||
|
matched_subjects = (
|
||||||
|
select(
|
||||||
|
LimitPolicyOverride.subject_type.label("subject_type"),
|
||||||
|
LimitPolicyOverride.subject_value.label("subject_value"),
|
||||||
|
func.min(LimitPolicyOverride.created_at).label("subject_created_at"),
|
||||||
|
)
|
||||||
|
.where(*conditions)
|
||||||
|
.group_by(
|
||||||
|
LimitPolicyOverride.subject_type,
|
||||||
|
LimitPolicyOverride.subject_value,
|
||||||
|
)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
total = int(db.scalar(select(func.count()).select_from(matched_subjects)) or 0)
|
||||||
|
subjects = list(
|
||||||
|
db.execute(
|
||||||
|
select(
|
||||||
|
matched_subjects.c.subject_type,
|
||||||
|
matched_subjects.c.subject_value,
|
||||||
|
matched_subjects.c.subject_created_at,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
matched_subjects.c.subject_created_at.desc(),
|
||||||
|
matched_subjects.c.subject_type,
|
||||||
|
matched_subjects.c.subject_value,
|
||||||
|
)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
if not subjects:
|
||||||
|
return [], total
|
||||||
|
|
||||||
|
pair_conditions = [
|
||||||
|
(
|
||||||
|
(LimitPolicyOverride.subject_type == item.subject_type)
|
||||||
|
& (LimitPolicyOverride.subject_value == item.subject_value)
|
||||||
|
)
|
||||||
|
for item in subjects
|
||||||
|
]
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(LimitPolicyOverride)
|
||||||
|
.where(
|
||||||
|
or_(*pair_conditions),
|
||||||
|
LimitPolicyOverride.mode.in_(
|
||||||
|
[
|
||||||
|
limit_policy.MODE_UNLIMITED,
|
||||||
|
limit_policy.MODE_SUPPRESS_ALERT,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
LimitPolicyOverride.created_at.desc(),
|
||||||
|
LimitPolicyOverride.id.desc(),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
rows_by_subject: dict[tuple[str, str], list[LimitPolicyOverride]] = {}
|
||||||
|
for row in rows:
|
||||||
|
rows_by_subject.setdefault((row.subject_type, row.subject_value), []).append(row)
|
||||||
|
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
item.subject_type,
|
||||||
|
item.subject_value,
|
||||||
|
rows_by_subject.get((item.subject_type, item.subject_value), []),
|
||||||
|
item.subject_created_at,
|
||||||
|
)
|
||||||
|
for item in subjects
|
||||||
|
], total
|
||||||
|
|
||||||
|
|
||||||
|
def rows_for_subject(
|
||||||
|
db: Session, *, subject_type: str, subject_value: str
|
||||||
|
) -> list[LimitPolicyOverride]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(LimitPolicyOverride)
|
||||||
|
.where(
|
||||||
|
LimitPolicyOverride.subject_type == subject_type,
|
||||||
|
LimitPolicyOverride.subject_value == subject_value,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
LimitPolicyOverride.created_at.desc(),
|
||||||
|
LimitPolicyOverride.id.desc(),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
subject_type: str,
|
||||||
|
subject_value: str,
|
||||||
|
rule_code: str,
|
||||||
|
mode: str,
|
||||||
|
limit_value: int | None,
|
||||||
|
enabled: bool,
|
||||||
|
starts_at: datetime | None,
|
||||||
|
expires_at: datetime | None,
|
||||||
|
reason: str,
|
||||||
|
admin_id: int,
|
||||||
|
reactivate_inactive: bool = False,
|
||||||
|
) -> LimitPolicyOverride:
|
||||||
|
rule = limit_policy.get_rule(rule_code)
|
||||||
|
subject_value = limit_policy.validate_whitelist_subject(subject_type, subject_value)
|
||||||
|
limit_policy.validate_override(
|
||||||
|
rule,
|
||||||
|
subject_type=subject_type,
|
||||||
|
mode=mode,
|
||||||
|
limit_value=limit_value,
|
||||||
|
starts_at=starts_at,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
existing = db.execute(
|
||||||
|
select(LimitPolicyOverride).where(
|
||||||
|
LimitPolicyOverride.subject_type == subject_type,
|
||||||
|
LimitPolicyOverride.subject_value == subject_value,
|
||||||
|
LimitPolicyOverride.rule_code == rule_code,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
if not reactivate_inactive or status_of(existing) not in {"disabled", "expired"}:
|
||||||
|
raise DuplicateOverrideError
|
||||||
|
# “恢复全局”与自然过期都会保留原行供审计。再次加入白名单时
|
||||||
|
# 复用该行,避免唯一键让批量新增永久失败;完整变更仍写 admin 审计日志。
|
||||||
|
existing.mode = mode
|
||||||
|
existing.limit_value = limit_value if mode == limit_policy.MODE_OVERRIDE else None
|
||||||
|
existing.enabled = enabled
|
||||||
|
existing.starts_at = starts_at
|
||||||
|
existing.expires_at = expires_at
|
||||||
|
existing.reset_at = None
|
||||||
|
existing.reason = reason.strip() or None
|
||||||
|
db.flush()
|
||||||
|
return existing
|
||||||
|
|
||||||
|
row = LimitPolicyOverride(
|
||||||
|
subject_type=subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
rule_code=rule_code,
|
||||||
|
mode=mode,
|
||||||
|
limit_value=limit_value if mode == limit_policy.MODE_OVERRIDE else None,
|
||||||
|
enabled=enabled,
|
||||||
|
starts_at=starts_at,
|
||||||
|
expires_at=expires_at,
|
||||||
|
reason=reason.strip() or None,
|
||||||
|
created_by_admin_id=admin_id,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
try:
|
||||||
|
db.flush()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise DuplicateOverrideError from exc
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def update(
|
||||||
|
db: Session,
|
||||||
|
row: LimitPolicyOverride,
|
||||||
|
*,
|
||||||
|
enabled: bool | None,
|
||||||
|
starts_at: datetime | None,
|
||||||
|
expires_at: datetime | None,
|
||||||
|
reason: str | None,
|
||||||
|
fields_set: set[str],
|
||||||
|
) -> LimitPolicyOverride:
|
||||||
|
new_starts = starts_at if "starts_at" in fields_set else row.starts_at
|
||||||
|
new_expires = expires_at if "expires_at" in fields_set else row.expires_at
|
||||||
|
new_enabled = enabled if "enabled" in fields_set and enabled is not None else row.enabled
|
||||||
|
rule = limit_policy.get_rule(row.rule_code)
|
||||||
|
if row.mode == limit_policy.MODE_OVERRIDE and new_enabled:
|
||||||
|
raise ValueError("历史覆盖配置只能停用、恢复全局或删除")
|
||||||
|
if new_enabled:
|
||||||
|
limit_policy.validate_override(
|
||||||
|
rule,
|
||||||
|
subject_type=row.subject_type,
|
||||||
|
mode=row.mode,
|
||||||
|
limit_value=None,
|
||||||
|
starts_at=new_starts,
|
||||||
|
expires_at=new_expires,
|
||||||
|
)
|
||||||
|
if "enabled" in fields_set and enabled is not None:
|
||||||
|
row.enabled = enabled
|
||||||
|
if "starts_at" in fields_set:
|
||||||
|
row.starts_at = starts_at
|
||||||
|
if "expires_at" in fields_set:
|
||||||
|
row.expires_at = expires_at
|
||||||
|
if "reason" in fields_set:
|
||||||
|
row.reason = reason.strip() if reason else None
|
||||||
|
db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def update_global_limit(
|
||||||
|
db: Session, rule_code: str, value: int, *, admin_id: int
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
rule = limit_policy.get_rule(rule_code)
|
||||||
|
if not rule.min_value <= value <= rule.max_value:
|
||||||
|
raise ValueError(f"限制值必须在 {rule.min_value} 到 {rule.max_value} 之间")
|
||||||
|
before = limit_policy.resolve(db, rule_code).global_limit
|
||||||
|
limit_policy.set_global_limits(
|
||||||
|
db,
|
||||||
|
{rule.code: value},
|
||||||
|
admin_id=admin_id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
# 引导视频仍有一个旧的专用配置页。同步其结构化配置,保证两个入口读取
|
||||||
|
# 同一数值;业务判定仍统一走 limit_policy。
|
||||||
|
if rule.legacy_config_key and rule.legacy_json_field:
|
||||||
|
legacy = db.get(AppConfig, rule.legacy_config_key)
|
||||||
|
legacy_value = (
|
||||||
|
dict(legacy.value) if legacy is not None and isinstance(legacy.value, dict) else {}
|
||||||
|
)
|
||||||
|
legacy_value[rule.legacy_json_field] = value
|
||||||
|
if legacy is None:
|
||||||
|
legacy = AppConfig(
|
||||||
|
key=rule.legacy_config_key,
|
||||||
|
value=legacy_value,
|
||||||
|
updated_by_admin_id=admin_id,
|
||||||
|
)
|
||||||
|
db.add(legacy)
|
||||||
|
else:
|
||||||
|
legacy.value = legacy_value
|
||||||
|
legacy.updated_by_admin_id = admin_id
|
||||||
|
db.flush()
|
||||||
|
return before, value
|
||||||
|
|
||||||
|
|
||||||
|
def restore_global(row: LimitPolicyOverride) -> LimitPolicyOverride:
|
||||||
|
"""Stop applying the exception while retaining its history for audit."""
|
||||||
|
|
||||||
|
row.enabled = False
|
||||||
|
row.reset_at = None
|
||||||
|
return row
|
||||||
@@ -44,6 +44,25 @@ def set_user_debug_trace(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_risk(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
is_high_risk: bool,
|
||||||
|
note: str | None,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> User:
|
||||||
|
"""设置人工风险结论和备注,支持与审计日志共用同一事务。"""
|
||||||
|
user.is_high_risk = is_high_risk
|
||||||
|
user.high_risk_note = note.strip() if is_high_risk and note else None
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
else:
|
||||||
|
db.flush()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def update_feedback_status(
|
def update_feedback_status(
|
||||||
db: Session, feedback: Feedback, *, status: str, commit: bool = True
|
db: Session, feedback: Feedback, *, status: str, commit: bool = True
|
||||||
) -> Feedback:
|
) -> Feedback:
|
||||||
|
|||||||
+357
-183
@@ -9,11 +9,12 @@ from datetime import date, datetime, time, timedelta, timezone
|
|||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from sqlalchemy import Select, asc, case, desc, func, or_, select
|
from sqlalchemy import Select, and_, asc, case, desc, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core import rewards
|
from app.core import rewards
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.models.ad_ecpm import AdEcpmRecord
|
||||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||||
from app.models.ad_reward import AdRewardRecord
|
from app.models.ad_reward import AdRewardRecord
|
||||||
from app.models.admin import AdminAuditLog
|
from app.models.admin import AdminAuditLog
|
||||||
@@ -22,14 +23,15 @@ from app.models.comparison import ComparisonRecord
|
|||||||
from app.models.coupon_state import CouponPromptEngagement
|
from app.models.coupon_state import CouponPromptEngagement
|
||||||
from app.models.device import DeviceLiveness
|
from app.models.device import DeviceLiveness
|
||||||
from app.models.feedback import Feedback
|
from app.models.feedback import Feedback
|
||||||
|
from app.models.invite import InviteRelation
|
||||||
from app.models.onboarding import OnboardingCompletion
|
from app.models.onboarding import OnboardingCompletion
|
||||||
from app.models.price_report import PriceReport
|
from app.models.price_report import PriceReport
|
||||||
|
from app.models.savings import SavingsRecord
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.wallet import (
|
from app.models.wallet import (
|
||||||
CashTransaction,
|
CashTransaction,
|
||||||
CoinAccount,
|
CoinAccount,
|
||||||
CoinTransaction,
|
CoinTransaction,
|
||||||
InviteCashTransaction,
|
|
||||||
WithdrawOrder,
|
WithdrawOrder,
|
||||||
)
|
)
|
||||||
from app.repositories import activity, ad_ecpm
|
from app.repositories import activity, ad_ecpm
|
||||||
@@ -45,6 +47,106 @@ _FEED_SCENE_LABEL = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_DEVICE_MARKETING_NAMES = {
|
||||||
|
"23078RKD5C": "Redmi K60 至尊版",
|
||||||
|
"M2012K11AC": "Redmi K40",
|
||||||
|
"PJA110": "一加 Ace 2 Pro",
|
||||||
|
"PPG-AN00": "荣耀 GT Pro",
|
||||||
|
"V2166BA": "vivo Y77e",
|
||||||
|
"V2309A": "vivo X100",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _device_marketing_name(model: str | None) -> str | None:
|
||||||
|
"""把线上已知 Build.MODEL 编码转成用户可识别的商品名。"""
|
||||||
|
if not model:
|
||||||
|
return None
|
||||||
|
return _DEVICE_MARKETING_NAMES.get(model.strip().upper())
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_comparison_order_status(db: Session, items: list[ComparisonRecord]) -> None:
|
||||||
|
"""按 C 端既有口径给比价记录批量补充是否真实下单。"""
|
||||||
|
user_ids = {item.user_id for item in items if item.user_id is not None}
|
||||||
|
shop_names = {item.store_name for item in items if item.store_name}
|
||||||
|
ordered_pairs: set[tuple[int, str]] = set()
|
||||||
|
if user_ids and shop_names:
|
||||||
|
rows = db.execute(
|
||||||
|
select(SavingsRecord.user_id, SavingsRecord.shop_name)
|
||||||
|
.where(
|
||||||
|
SavingsRecord.user_id.in_(user_ids),
|
||||||
|
SavingsRecord.source == "compare",
|
||||||
|
SavingsRecord.shop_name.in_(shop_names),
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
).all()
|
||||||
|
ordered_pairs = {
|
||||||
|
(row.user_id, row.shop_name)
|
||||||
|
for row in rows
|
||||||
|
if row.shop_name is not None
|
||||||
|
}
|
||||||
|
for item in items:
|
||||||
|
item.ordered = bool(
|
||||||
|
item.user_id is not None
|
||||||
|
and item.store_name
|
||||||
|
and (item.user_id, item.store_name) in ordered_pairs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_comparison_device_details(items: list[ComparisonRecord]) -> None:
|
||||||
|
"""给比价记录补充可读机型名,同时保留原始设备编码。"""
|
||||||
|
for item in items:
|
||||||
|
item.device_model_name = _device_marketing_name(item.device_model)
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_feedback_device_details(db: Session, feedbacks: list[Feedback]) -> None:
|
||||||
|
"""按同一用户、同一设备编码及提交时间补齐厂商和 ROM 大版本。"""
|
||||||
|
candidates = [
|
||||||
|
item for item in feedbacks if item.device_model and item.device_model.strip()
|
||||||
|
]
|
||||||
|
for item in candidates:
|
||||||
|
item.device_model_name = _device_marketing_name(item.device_model)
|
||||||
|
item.device_manufacturer = None
|
||||||
|
item.rom_version = None
|
||||||
|
if not candidates:
|
||||||
|
return
|
||||||
|
|
||||||
|
ranked = (
|
||||||
|
select(
|
||||||
|
Feedback.id.label("feedback_id"),
|
||||||
|
ComparisonRecord.device_manufacturer.label("device_manufacturer"),
|
||||||
|
ComparisonRecord.rom_version.label("rom_version"),
|
||||||
|
func.row_number()
|
||||||
|
.over(
|
||||||
|
partition_by=Feedback.id,
|
||||||
|
order_by=(
|
||||||
|
ComparisonRecord.created_at.desc(),
|
||||||
|
ComparisonRecord.id.desc(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.label("row_num"),
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
ComparisonRecord,
|
||||||
|
and_(
|
||||||
|
ComparisonRecord.user_id == Feedback.user_id,
|
||||||
|
ComparisonRecord.device_model == Feedback.device_model,
|
||||||
|
ComparisonRecord.created_at <= Feedback.created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(Feedback.id.in_([item.id for item in candidates]))
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
details = {
|
||||||
|
row.feedback_id: row
|
||||||
|
for row in db.execute(select(ranked).where(ranked.c.row_num == 1)).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in candidates:
|
||||||
|
detail = details.get(item.id)
|
||||||
|
item.device_manufacturer = detail.device_manufacturer if detail else None
|
||||||
|
item.rom_version = detail.rom_version if detail else None
|
||||||
|
|
||||||
|
|
||||||
def cursor_paginate(
|
def cursor_paginate(
|
||||||
db: Session, stmt: Select, id_col, *, limit: int, cursor: int | None
|
db: Session, stmt: Select, id_col, *, limit: int, cursor: int | None
|
||||||
) -> tuple[list, int | None]:
|
) -> tuple[list, int | None]:
|
||||||
@@ -277,6 +379,8 @@ def list_comparison_records(
|
|||||||
limit=limit, cursor=cursor,
|
limit=limit, cursor=cursor,
|
||||||
)
|
)
|
||||||
_attach_user_info(db, items)
|
_attach_user_info(db, items)
|
||||||
|
_attach_comparison_order_status(db, items)
|
||||||
|
_attach_comparison_device_details(items)
|
||||||
# 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。
|
# 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。
|
||||||
# ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。
|
# ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。
|
||||||
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
|
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
|
||||||
@@ -423,6 +527,8 @@ def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | Non
|
|||||||
rec = db.get(ComparisonRecord, record_id)
|
rec = db.get(ComparisonRecord, record_id)
|
||||||
if rec is not None:
|
if rec is not None:
|
||||||
_attach_user_info(db, [rec])
|
_attach_user_info(db, [rec])
|
||||||
|
_attach_comparison_order_status(db, [rec])
|
||||||
|
_attach_comparison_device_details([rec])
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
@@ -710,16 +816,7 @@ def list_all_withdraw_orders(
|
|||||||
elif quick_filter == "today":
|
elif quick_filter == "today":
|
||||||
stmt = stmt.where(WithdrawOrder.created_at >= today_start)
|
stmt = stmt.where(WithdrawOrder.created_at >= today_start)
|
||||||
elif quick_filter == "high_risk":
|
elif quick_filter == "high_risk":
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(User.is_high_risk.is_(True))
|
||||||
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 = {
|
sort_cols = {
|
||||||
"id": WithdrawOrder.id,
|
"id": WithdrawOrder.id,
|
||||||
@@ -745,7 +842,9 @@ def _as_utc(value: datetime) -> datetime:
|
|||||||
return value.astimezone(timezone.utc)
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def withdraw_list_enrichment(db: Session, user_ids: list[int]) -> dict[int, dict]:
|
def withdraw_list_enrichment(
|
||||||
|
db: Session, user_ids: list[int], *, source: str | None = None
|
||||||
|
) -> dict[int, dict]:
|
||||||
"""批量富化提现单列表:按本页 user_id 取 手机号/昵称 + 各自累计成功提现金额(分)。
|
"""批量富化提现单列表:按本页 user_id 取 手机号/昵称 + 各自累计成功提现金额(分)。
|
||||||
|
|
||||||
两条聚合查询搞定(避免逐行 N+1)。累计口径与 get_user_overview 的 withdraw_success_cents
|
两条聚合查询搞定(避免逐行 N+1)。累计口径与 get_user_overview 的 withdraw_success_cents
|
||||||
@@ -755,25 +854,32 @@ def withdraw_list_enrichment(db: Session, user_ids: list[int]) -> dict[int, dict
|
|||||||
uniq = list(set(user_ids))
|
uniq = list(set(user_ids))
|
||||||
if not uniq:
|
if not uniq:
|
||||||
return {}
|
return {}
|
||||||
success_rows = db.execute(
|
success_stmt = select(
|
||||||
select(
|
WithdrawOrder.user_id,
|
||||||
WithdrawOrder.user_id,
|
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
|
||||||
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
|
).where(WithdrawOrder.user_id.in_(uniq), WithdrawOrder.status == "success")
|
||||||
)
|
if source:
|
||||||
.where(WithdrawOrder.user_id.in_(uniq), WithdrawOrder.status == "success")
|
success_stmt = success_stmt.where(WithdrawOrder.source == source)
|
||||||
.group_by(WithdrawOrder.user_id)
|
success_rows = db.execute(success_stmt.group_by(WithdrawOrder.user_id)).all()
|
||||||
).all()
|
|
||||||
success_map = {uid: int(total) for uid, total in success_rows}
|
success_map = {uid: int(total) for uid, total in success_rows}
|
||||||
users = db.execute(
|
users = db.execute(
|
||||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uniq))
|
select(
|
||||||
|
User.id,
|
||||||
|
User.phone,
|
||||||
|
User.nickname,
|
||||||
|
User.is_high_risk,
|
||||||
|
User.high_risk_note,
|
||||||
|
).where(User.id.in_(uniq))
|
||||||
).all()
|
).all()
|
||||||
return {
|
return {
|
||||||
uid: {
|
uid: {
|
||||||
"phone": phone,
|
"phone": phone,
|
||||||
"nickname": nickname,
|
"nickname": nickname,
|
||||||
|
"is_high_risk": is_high_risk,
|
||||||
|
"high_risk_note": high_risk_note,
|
||||||
"cumulative_success_cents": success_map.get(uid, 0),
|
"cumulative_success_cents": success_map.get(uid, 0),
|
||||||
}
|
}
|
||||||
for uid, phone, nickname in users
|
for uid, phone, nickname, is_high_risk, high_risk_note in users
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -817,6 +923,7 @@ def list_feedbacks(
|
|||||||
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
||||||
)
|
)
|
||||||
_attach_user_info(db, items) # 列表展示完整手机号(点手机号查该用户全部反馈)
|
_attach_user_info(db, items) # 列表展示完整手机号(点手机号查该用户全部反馈)
|
||||||
|
_attach_feedback_device_details(db, items)
|
||||||
return items, next_cursor, total
|
return items, next_cursor, total
|
||||||
|
|
||||||
|
|
||||||
@@ -879,15 +986,16 @@ def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
def withdraw_summary(db: Session) -> dict:
|
def withdraw_summary(db: Session, *, source: str | None = None) -> dict:
|
||||||
"""提现审核台顶部统计。金额单位:分。"""
|
"""提现审核台顶部统计。金额单位:分;口径与列表的 source 筛选一致。"""
|
||||||
rows = db.execute(
|
summary_stmt = select(
|
||||||
select(
|
WithdrawOrder.status,
|
||||||
WithdrawOrder.status,
|
func.count(WithdrawOrder.id),
|
||||||
func.count(WithdrawOrder.id),
|
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
|
||||||
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
|
)
|
||||||
).group_by(WithdrawOrder.status)
|
if source:
|
||||||
).all()
|
summary_stmt = summary_stmt.where(WithdrawOrder.source == source)
|
||||||
|
rows = db.execute(summary_stmt.group_by(WithdrawOrder.status)).all()
|
||||||
by_status = {
|
by_status = {
|
||||||
status: {"count": int(count), "amount_cents": int(amount_cents)}
|
status: {"count": int(count), "amount_cents": int(amount_cents)}
|
||||||
for status, count, amount_cents in rows
|
for status, count, amount_cents in rows
|
||||||
@@ -900,31 +1008,154 @@ def withdraw_summary(db: Session) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _today_count(status: str) -> int:
|
def _today_count(status: str) -> int:
|
||||||
return db.execute(
|
stmt = select(func.count(WithdrawOrder.id)).where(
|
||||||
select(func.count(WithdrawOrder.id)).where(
|
WithdrawOrder.status == status,
|
||||||
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,
|
WithdrawOrder.updated_at >= today_start,
|
||||||
)
|
)
|
||||||
).scalar_one()
|
if source:
|
||||||
|
stmt = stmt.where(WithdrawOrder.source == source)
|
||||||
|
return db.execute(stmt).scalar_one()
|
||||||
|
|
||||||
|
today_success_amount_stmt = select(
|
||||||
|
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)
|
||||||
|
).where(
|
||||||
|
WithdrawOrder.status == "success",
|
||||||
|
WithdrawOrder.updated_at >= today_start,
|
||||||
|
)
|
||||||
|
if source:
|
||||||
|
today_success_amount_stmt = today_success_amount_stmt.where(
|
||||||
|
WithdrawOrder.source == source
|
||||||
|
)
|
||||||
|
today_success_amount = db.execute(today_success_amount_stmt).scalar_one()
|
||||||
|
|
||||||
|
def _status_count(status: str) -> int:
|
||||||
|
return by_status.get(status, {}).get("count", 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"reviewing_count": by_status.get("reviewing", {}).get("count", 0),
|
"reviewing_count": _status_count("reviewing"),
|
||||||
"reviewing_amount_cents": by_status.get("reviewing", {}).get("amount_cents", 0),
|
"reviewing_amount_cents": by_status.get("reviewing", {}).get("amount_cents", 0),
|
||||||
"pending_count": by_status.get("pending", {}).get("count", 0),
|
"pending_count": _status_count("pending"),
|
||||||
"failed_count": by_status.get("failed", {}).get("count", 0),
|
"success_count": _status_count("success"),
|
||||||
|
"rejected_count": _status_count("rejected"),
|
||||||
|
"failed_count": _status_count("failed"),
|
||||||
|
"total_count": sum(item["count"] for item in by_status.values()),
|
||||||
"today_success_count": _today_count("success"),
|
"today_success_count": _today_count("success"),
|
||||||
"today_success_amount_cents": int(today_success_amount),
|
"today_success_amount_cents": int(today_success_amount),
|
||||||
"today_rejected_count": _today_count("rejected"),
|
"today_rejected_count": _today_count("rejected"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def invite_overview(
|
||||||
|
db: Session,
|
||||||
|
inviter_user_id: int,
|
||||||
|
*,
|
||||||
|
date_from: datetime | None = None,
|
||||||
|
date_to: datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""邀请提现详情:邀请关系统计及受邀用户首次比价/首单信息。
|
||||||
|
|
||||||
|
首次记录按自增 id 取最早一条,批量查询避免按受邀用户逐行查询。
|
||||||
|
邀请成功口径为完成首次比价并已发邀请奖励(compare_reward_granted)。
|
||||||
|
"""
|
||||||
|
relation_stmt = (
|
||||||
|
select(InviteRelation, User)
|
||||||
|
.join(User, User.id == InviteRelation.invitee_user_id)
|
||||||
|
.where(InviteRelation.inviter_user_id == inviter_user_id)
|
||||||
|
.order_by(InviteRelation.created_at.asc(), InviteRelation.id.asc())
|
||||||
|
)
|
||||||
|
if date_from is not None:
|
||||||
|
relation_stmt = relation_stmt.where(User.created_at >= _as_utc_naive(date_from))
|
||||||
|
if date_to is not None:
|
||||||
|
relation_stmt = relation_stmt.where(User.created_at <= _as_utc_naive(date_to))
|
||||||
|
relation_rows = db.execute(relation_stmt).all()
|
||||||
|
invitee_ids = [relation.invitee_user_id for relation, _ in relation_rows]
|
||||||
|
if not invitee_ids:
|
||||||
|
return {"invite_total": 0, "invite_success_total": 0, "items": []}
|
||||||
|
|
||||||
|
first_compare_ids = list(
|
||||||
|
db.execute(
|
||||||
|
select(func.min(ComparisonRecord.id))
|
||||||
|
.where(
|
||||||
|
ComparisonRecord.user_id.in_(invitee_ids),
|
||||||
|
ComparisonRecord.status == "success",
|
||||||
|
)
|
||||||
|
.group_by(ComparisonRecord.user_id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
comparisons = (
|
||||||
|
db.execute(
|
||||||
|
select(
|
||||||
|
ComparisonRecord.user_id,
|
||||||
|
ComparisonRecord.store_name,
|
||||||
|
ComparisonRecord.product_names,
|
||||||
|
).where(ComparisonRecord.id.in_(first_compare_ids))
|
||||||
|
).all()
|
||||||
|
if first_compare_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
comparison_map = {
|
||||||
|
user_id: (store_name, product_names)
|
||||||
|
for user_id, store_name, product_names in comparisons
|
||||||
|
}
|
||||||
|
|
||||||
|
first_order_ids = list(
|
||||||
|
db.execute(
|
||||||
|
select(func.min(SavingsRecord.id))
|
||||||
|
.where(
|
||||||
|
SavingsRecord.user_id.in_(invitee_ids),
|
||||||
|
SavingsRecord.source == "compare",
|
||||||
|
)
|
||||||
|
.group_by(SavingsRecord.user_id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
orders = (
|
||||||
|
db.execute(
|
||||||
|
select(
|
||||||
|
SavingsRecord.user_id,
|
||||||
|
SavingsRecord.shop_name,
|
||||||
|
SavingsRecord.title,
|
||||||
|
SavingsRecord.dishes,
|
||||||
|
SavingsRecord.order_amount_cents,
|
||||||
|
).where(SavingsRecord.id.in_(first_order_ids))
|
||||||
|
).all()
|
||||||
|
if first_order_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
order_map = {
|
||||||
|
user_id: (
|
||||||
|
shop_name,
|
||||||
|
title or "、".join(dishes or []),
|
||||||
|
order_amount_cents,
|
||||||
|
)
|
||||||
|
for user_id, shop_name, title, dishes, order_amount_cents in orders
|
||||||
|
}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for relation, user in relation_rows:
|
||||||
|
compare_store, compare_products = comparison_map.get(user.id, (None, None))
|
||||||
|
order_store, order_products, order_amount = order_map.get(
|
||||||
|
user.id, (None, None, None)
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"user_id": user.id,
|
||||||
|
"phone": user.phone,
|
||||||
|
"registered_at": user.created_at,
|
||||||
|
"invite_success": bool(relation.compare_reward_granted),
|
||||||
|
"first_compare_store": compare_store,
|
||||||
|
"first_compare_products": compare_products,
|
||||||
|
"first_order_store": order_store,
|
||||||
|
"first_order_products": order_products,
|
||||||
|
"first_order_amount_cents": order_amount,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"invite_total": len(items),
|
||||||
|
"invite_success_total": sum(1 for item in items if item["invite_success"]),
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_withdraw_audit_logs(
|
def list_withdraw_audit_logs(
|
||||||
db: Session, out_bill_no: str, *, limit: int = 20
|
db: Session, out_bill_no: str, *, limit: int = 20
|
||||||
) -> list[AdminAuditLog]:
|
) -> list[AdminAuditLog]:
|
||||||
@@ -945,8 +1176,6 @@ def withdraw_risk_flags(
|
|||||||
cash_balance_cents: int,
|
cash_balance_cents: int,
|
||||||
) -> tuple[list[str], int]:
|
) -> tuple[list[str], int]:
|
||||||
flags: list[str] = []
|
flags: list[str] = []
|
||||||
if not order.user_name:
|
|
||||||
flags.append("缺少提现实名")
|
|
||||||
if user and user.status != "active":
|
if user and user.status != "active":
|
||||||
flags.append(f"账号状态:{user.status}")
|
flags.append(f"账号状态:{user.status}")
|
||||||
if user and user.created_at:
|
if user and user.created_at:
|
||||||
@@ -969,125 +1198,6 @@ def withdraw_risk_flags(
|
|||||||
return flags, score
|
return flags, score
|
||||||
|
|
||||||
|
|
||||||
def _check_withdraw_ledger_side(
|
|
||||||
orders: list[WithdrawOrder], txns: list, *, withdraw_biz: str, refund_biz: str
|
|
||||||
) -> dict:
|
|
||||||
"""对某一本账(普通现金 / 邀请奖励金)做提现单 ↔ 流水的交叉校验。
|
|
||||||
|
|
||||||
orders 已按 source 过滤到本账;txns 是本账流水表里 withdraw_biz/refund_biz 两类流水。
|
|
||||||
规则:每单发起应有一条扣款流水(ref_id=out_bill_no);失败/拒绝单应有且仅一条退款流水;
|
|
||||||
非退款终态不应出现退款流水。四个计数全为 0 即本账自洽。
|
|
||||||
"""
|
|
||||||
withdraw_refs = {txn.ref_id for txn in txns if txn.biz_type == withdraw_biz}
|
|
||||||
refund_counts: dict[str, int] = {}
|
|
||||||
for txn in txns:
|
|
||||||
if txn.biz_type == refund_biz 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
|
|
||||||
|
|
||||||
return {
|
|
||||||
"missing_withdraw": missing_withdraw,
|
|
||||||
"missing_refund": missing_refund,
|
|
||||||
"duplicate_refund": sum(1 for count in refund_counts.values() if count > 1),
|
|
||||||
"refund_on_non_terminal": refund_on_non_terminal,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def withdraw_ledger_check(db: Session) -> dict:
|
|
||||||
"""现金账本校验:两本物理隔离的账各自对账(产品红线:coin_cash / invite_cash 不串)。
|
|
||||||
|
|
||||||
普通现金:CoinAccount.cash_balance_cents ↔ cash_transaction(withdraw/withdraw_refund);
|
|
||||||
邀请奖励金:CoinAccount.invite_cash_balance_cents ↔ invite_cash_transaction
|
|
||||||
(invite_withdraw/invite_withdraw_refund)。
|
|
||||||
提现单按 source 分流到对应账核对——邀请提现的流水写在 invite_cash_transaction 表,
|
|
||||||
绝不能拿去和普通现金流水比(否则每笔邀请提现单都会被误报「缺扣款/缺退款流水」)。
|
|
||||||
分流口径与 create_withdraw 一致:仅 source==invite_cash 走邀请账,其余(含历史空值)归普通现金。
|
|
||||||
"""
|
|
||||||
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
|
|
||||||
coin_orders = [o for o in orders if o.source != "invite_cash"]
|
|
||||||
invite_orders = [o for o in orders if o.source == "invite_cash"]
|
|
||||||
|
|
||||||
# —— 普通现金账(coin_cash) ——
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
cash_txns = list(
|
|
||||||
db.execute(
|
|
||||||
select(CashTransaction).where(
|
|
||||||
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
)
|
|
||||||
coin = _check_withdraw_ledger_side(
|
|
||||||
coin_orders, cash_txns, withdraw_biz="withdraw", refund_biz="withdraw_refund"
|
|
||||||
)
|
|
||||||
cash_diff = cash_balance_total - cash_txn_total
|
|
||||||
|
|
||||||
# —— 邀请奖励金账(invite_cash,独立账户 + 独立流水表) ——
|
|
||||||
invite_balance_total = int(
|
|
||||||
db.execute(
|
|
||||||
select(func.coalesce(func.sum(CoinAccount.invite_cash_balance_cents), 0))
|
|
||||||
).scalar_one()
|
|
||||||
)
|
|
||||||
invite_txn_total = int(
|
|
||||||
db.execute(
|
|
||||||
select(func.coalesce(func.sum(InviteCashTransaction.amount_cents), 0))
|
|
||||||
).scalar_one()
|
|
||||||
)
|
|
||||||
invite_txns = list(
|
|
||||||
db.execute(
|
|
||||||
select(InviteCashTransaction).where(
|
|
||||||
InviteCashTransaction.biz_type.in_(("invite_withdraw", "invite_withdraw_refund"))
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
)
|
|
||||||
invite = _check_withdraw_ledger_side(
|
|
||||||
invite_orders, invite_txns,
|
|
||||||
withdraw_biz="invite_withdraw", refund_biz="invite_withdraw_refund",
|
|
||||||
)
|
|
||||||
invite_diff = invite_balance_total - invite_txn_total
|
|
||||||
|
|
||||||
ok = (
|
|
||||||
cash_diff == 0
|
|
||||||
and invite_diff == 0
|
|
||||||
and all(v == 0 for v in coin.values())
|
|
||||||
and all(v == 0 for v in invite.values())
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"ok": ok,
|
|
||||||
# 普通现金账(coin_cash:金币兑换的现金)
|
|
||||||
"cash_balance_total_cents": cash_balance_total,
|
|
||||||
"cash_transaction_total_cents": cash_txn_total,
|
|
||||||
"balance_diff_cents": cash_diff,
|
|
||||||
"missing_withdraw_txn_count": coin["missing_withdraw"],
|
|
||||||
"missing_refund_txn_count": coin["missing_refund"],
|
|
||||||
"duplicate_refund_txn_count": coin["duplicate_refund"],
|
|
||||||
"refund_txn_on_non_terminal_count": coin["refund_on_non_terminal"],
|
|
||||||
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)
|
|
||||||
"invite_cash_balance_total_cents": invite_balance_total,
|
|
||||||
"invite_cash_transaction_total_cents": invite_txn_total,
|
|
||||||
"invite_balance_diff_cents": invite_diff,
|
|
||||||
"invite_missing_withdraw_txn_count": invite["missing_withdraw"],
|
|
||||||
"invite_missing_refund_txn_count": invite["missing_refund"],
|
|
||||||
"invite_duplicate_refund_txn_count": invite["duplicate_refund"],
|
|
||||||
"invite_refund_txn_on_non_terminal_count": invite["refund_on_non_terminal"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_overview(db: Session, user_id: int) -> dict | None:
|
def get_user_overview(db: Session, user_id: int) -> dict | None:
|
||||||
"""用户 360 概览:基础资料 + 钱包余额 + 各项 count。历史明细走各自分页接口(带 user_id 过滤)。"""
|
"""用户 360 概览:基础资料 + 钱包余额 + 各项 count。历史明细走各自分页接口(带 user_id 过滤)。"""
|
||||||
user = db.get(User, user_id)
|
user = db.get(User, user_id)
|
||||||
@@ -1147,56 +1257,112 @@ def user_reward_stats(
|
|||||||
*,
|
*,
|
||||||
date_from: datetime | None = None,
|
date_from: datetime | None = None,
|
||||||
date_to: datetime | None = None,
|
date_to: datetime | None = None,
|
||||||
|
withdraw_source: str | None = None,
|
||||||
|
app_env: str | None = None,
|
||||||
|
revenue_scope: str = "all",
|
||||||
|
feed_scene: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
|
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
|
||||||
|
|
||||||
口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加);
|
口径:激励视频/信息流奖励数量只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加)。
|
||||||
平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。
|
平均 Draw eCPM 与广告收益报表一致:基于 ad_ecpm_record 的全部 draw/feed 展示记录计算,
|
||||||
|
不以是否发奖为筛选条件。各「提现」= 该来源累计金币折现。
|
||||||
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
|
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
|
||||||
"""
|
"""
|
||||||
|
withdraw_source_conds = (
|
||||||
|
[WithdrawOrder.source == withdraw_source] if withdraw_source else []
|
||||||
|
)
|
||||||
wd_success = db.execute(
|
wd_success = db.execute(
|
||||||
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
|
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
|
||||||
WithdrawOrder.user_id == user_id,
|
WithdrawOrder.user_id == user_id,
|
||||||
WithdrawOrder.status == "success",
|
WithdrawOrder.status == "success",
|
||||||
|
*withdraw_source_conds,
|
||||||
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
|
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
|
||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
wd_total = db.execute(
|
wd_total = db.execute(
|
||||||
select(func.count(WithdrawOrder.id)).where(
|
select(func.count(WithdrawOrder.id)).where(
|
||||||
WithdrawOrder.user_id == user_id,
|
WithdrawOrder.user_id == user_id,
|
||||||
|
*withdraw_source_conds,
|
||||||
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
|
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
|
||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口
|
acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口
|
||||||
cash_balance = acc.cash_balance_cents if acc else 0
|
cash_balance = (
|
||||||
|
(
|
||||||
|
acc.invite_cash_balance_cents
|
||||||
|
if withdraw_source == "invite_cash"
|
||||||
|
else acc.cash_balance_cents
|
||||||
|
)
|
||||||
|
if acc
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
# 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时,
|
# 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时,
|
||||||
# SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。
|
# SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。
|
||||||
|
business_ids: set[str] | None = None
|
||||||
|
if revenue_scope == "business":
|
||||||
|
# 与广告收益报表共用正式/测试业务代码位集合,避免两个页面随配置切换后再次漂移。
|
||||||
|
from app.admin.repositories.ad_revenue import business_code_ids
|
||||||
|
|
||||||
|
business_ids = business_code_ids(db, app_env)
|
||||||
|
|
||||||
|
rv_conds = [
|
||||||
|
AdRewardRecord.user_id == user_id,
|
||||||
|
AdRewardRecord.reward_scene == "reward_video",
|
||||||
|
AdRewardRecord.status == "granted",
|
||||||
|
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
||||||
|
]
|
||||||
|
if app_env is not None:
|
||||||
|
rv_conds.append(AdRewardRecord.app_env == app_env)
|
||||||
|
if business_ids is not None:
|
||||||
|
rv_conds.append(AdRewardRecord.our_code_id.in_(business_ids))
|
||||||
rv = db.execute(
|
rv = db.execute(
|
||||||
select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where(
|
select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where(
|
||||||
AdRewardRecord.user_id == user_id,
|
*rv_conds,
|
||||||
AdRewardRecord.reward_scene == "reward_video",
|
|
||||||
AdRewardRecord.status == "granted",
|
|
||||||
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
|
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
|
||||||
rv_coins = sum(r.coin for r in rv)
|
rv_coins = sum(r.coin for r in rv)
|
||||||
|
|
||||||
feed = db.execute(
|
feed_reward_conds = [
|
||||||
|
AdFeedRewardRecord.user_id == user_id,
|
||||||
|
AdFeedRewardRecord.status == "granted",
|
||||||
|
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
||||||
|
]
|
||||||
|
if app_env is not None:
|
||||||
|
feed_reward_conds.append(AdFeedRewardRecord.app_env == app_env)
|
||||||
|
if feed_scene is not None:
|
||||||
|
feed_reward_conds.append(AdFeedRewardRecord.feed_scene == feed_scene)
|
||||||
|
if business_ids is not None:
|
||||||
|
feed_reward_conds.append(AdFeedRewardRecord.our_code_id.in_(business_ids))
|
||||||
|
feed_rewards = db.execute(
|
||||||
select(
|
select(
|
||||||
AdFeedRewardRecord.unit_count,
|
AdFeedRewardRecord.unit_count,
|
||||||
AdFeedRewardRecord.ecpm_raw,
|
|
||||||
AdFeedRewardRecord.coin,
|
AdFeedRewardRecord.coin,
|
||||||
).where(
|
).where(
|
||||||
AdFeedRewardRecord.user_id == user_id,
|
*feed_reward_conds,
|
||||||
AdFeedRewardRecord.status == "granted",
|
|
||||||
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
|
feed_coins = sum(f.coin for f in feed_rewards)
|
||||||
feed_coins = sum(f.coin for f in feed)
|
|
||||||
|
feed_impression_conds = [
|
||||||
|
AdEcpmRecord.user_id == user_id,
|
||||||
|
AdEcpmRecord.ad_type.in_(("draw", "feed")),
|
||||||
|
*_window_conds(AdEcpmRecord.created_at, date_from, date_to),
|
||||||
|
]
|
||||||
|
if app_env is not None:
|
||||||
|
feed_impression_conds.append(AdEcpmRecord.app_env == app_env)
|
||||||
|
if feed_scene is not None:
|
||||||
|
feed_impression_conds.append(AdEcpmRecord.feed_scene == feed_scene)
|
||||||
|
if business_ids is not None:
|
||||||
|
feed_impression_conds.append(AdEcpmRecord.our_code_id.in_(business_ids))
|
||||||
|
feed_impressions = db.execute(
|
||||||
|
select(AdEcpmRecord.ecpm_raw).where(*feed_impression_conds)
|
||||||
|
).all()
|
||||||
|
# 与 ad_revenue.category_stats 相同:每次展示权重相同,非法原值按 parse_ecpm_fen 记 0。
|
||||||
|
feed_ecpms = [rewards.parse_ecpm_fen(row.ecpm_raw) for row in feed_impressions]
|
||||||
|
|
||||||
trad_coins = db.execute(
|
trad_coins = db.execute(
|
||||||
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
|
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
|
||||||
@@ -1215,7 +1381,7 @@ def user_reward_stats(
|
|||||||
"reward_video_count": len(rv),
|
"reward_video_count": len(rv),
|
||||||
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
|
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
|
||||||
"reward_video_cash_cents": _coins_to_cents(rv_coins),
|
"reward_video_cash_cents": _coins_to_cents(rv_coins),
|
||||||
"feed_count": int(sum(f.unit_count for f in feed)),
|
"feed_count": int(sum(f.unit_count for f in feed_rewards)),
|
||||||
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
|
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
|
||||||
"feed_cash_cents": _coins_to_cents(feed_coins),
|
"feed_cash_cents": _coins_to_cents(feed_coins),
|
||||||
}
|
}
|
||||||
@@ -1228,6 +1394,11 @@ def _cn_wall_to_utc(dt: datetime) -> datetime:
|
|||||||
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
|
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _coin_record_sort_key(row: dict) -> datetime:
|
||||||
|
"""金币明细跨数据源排序键:兼容 SQLite naive 与 PostgreSQL aware 时间。"""
|
||||||
|
return _as_utc(row["created_at"])
|
||||||
|
|
||||||
|
|
||||||
def user_coin_records(
|
def user_coin_records(
|
||||||
db: Session,
|
db: Session,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -1319,7 +1490,10 @@ def user_coin_records(
|
|||||||
"coin": rec.amount,
|
"coin": rec.amount,
|
||||||
})
|
})
|
||||||
|
|
||||||
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
# SQLite 常返回 naive datetime,PostgreSQL timestamptz 返回 aware datetime;
|
||||||
|
# 统一成 aware UTC 排序,避免线上合并广告记录与签到记录时抛
|
||||||
|
# “can't compare offset-naive and offset-aware datetimes”。
|
||||||
|
rows.sort(key=_coin_record_sort_key, reverse=True)
|
||||||
has_more = len(rows) > offset + limit
|
has_more = len(rows) > offset + limit
|
||||||
|
|
||||||
# 总数 = 三源在窗口内 granted 计数之和(供前端页码分页渲染页码/共 N 条)
|
# 总数 = 三源在窗口内 granted 计数之和(供前端页码分页渲染页码/共 N 条)
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ def get_ad_revenue_report(
|
|||||||
daily=[AdRevenueDaily(**d) for d in result["daily"]],
|
daily=[AdRevenueDaily(**d) for d in result["daily"]],
|
||||||
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
|
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
|
||||||
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
|
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
|
||||||
category_stats={k: AdRevenueTypeStat(**v) for k, v in result["category_stats"].items()},
|
|
||||||
scene_stats={k: AdRevenueTypeStat(**v) for k, v in result["scene_stats"].items()},
|
scene_stats={k: AdRevenueTypeStat(**v) for k, v in result["scene_stats"].items()},
|
||||||
dau=result["dau"],
|
dau=result["dau"],
|
||||||
total=result["total"],
|
total=result["total"],
|
||||||
@@ -108,8 +107,6 @@ def get_ad_revenue_report(
|
|||||||
total_revenue_yuan=result["total_revenue_yuan"],
|
total_revenue_yuan=result["total_revenue_yuan"],
|
||||||
total_pangle_revenue_yuan=result["total_pangle_revenue_yuan"],
|
total_pangle_revenue_yuan=result["total_pangle_revenue_yuan"],
|
||||||
total_pangle_api_revenue_yuan=result["total_pangle_api_revenue_yuan"],
|
total_pangle_api_revenue_yuan=result["total_pangle_api_revenue_yuan"],
|
||||||
pangle_api_revenue_complete=result["pangle_api_revenue_complete"],
|
|
||||||
pangle_latest_synced_at=result["pangle_latest_synced_at"],
|
|
||||||
pangle_revenue_available=result["pangle_revenue_available"],
|
pangle_revenue_available=result["pangle_revenue_available"],
|
||||||
total_expected_coin=result["total_expected_coin"],
|
total_expected_coin=result["total_expected_coin"],
|
||||||
total_actual_coin=result["total_actual_coin"],
|
total_actual_coin=result["total_actual_coin"],
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||||||
from app.admin.audit import write_audit
|
from app.admin.audit import write_audit
|
||||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||||
from app.admin.schemas.config import ConfigItemOut, ConfigUpdateRequest
|
from app.admin.schemas.config import ConfigItemOut, ConfigUpdateRequest
|
||||||
|
from app.core import limit_policy
|
||||||
from app.core.config_schema import CONFIG_DEFS
|
from app.core.config_schema import CONFIG_DEFS
|
||||||
from app.core.rewards import SIGNIN_CYCLE_LEN
|
from app.core.rewards import SIGNIN_CYCLE_LEN
|
||||||
from app.models.admin import AdminUser
|
from app.models.admin import AdminUser
|
||||||
|
from app.models.app_config import AppConfig
|
||||||
from app.repositories import app_config
|
from app.repositories import app_config
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -54,7 +56,35 @@ def _validate(key: str, value: Any) -> None:
|
|||||||
raise ValueError("需为布尔值")
|
raise ValueError("需为布尔值")
|
||||||
|
|
||||||
|
|
||||||
|
def _limit_item(
|
||||||
|
key: str,
|
||||||
|
values: dict[str, int],
|
||||||
|
bundle: AppConfig | None,
|
||||||
|
) -> ConfigItemOut:
|
||||||
|
definition = CONFIG_DEFS[key]
|
||||||
|
rule_code = limit_policy.RULE_CODE_BY_CONFIG_KEY[key]
|
||||||
|
return ConfigItemOut(
|
||||||
|
key=key,
|
||||||
|
value=values[rule_code],
|
||||||
|
label=definition["label"],
|
||||||
|
group=definition["group"],
|
||||||
|
type=definition["type"],
|
||||||
|
help=definition.get("help"),
|
||||||
|
default=definition["default"],
|
||||||
|
overridden=bundle is not None,
|
||||||
|
updated_at=(
|
||||||
|
bundle.updated_at.isoformat() if bundle is not None else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _item(db, key: str) -> ConfigItemOut:
|
def _item(db, key: str) -> ConfigItemOut:
|
||||||
|
if key in limit_policy.RULE_CODE_BY_CONFIG_KEY:
|
||||||
|
return _limit_item(
|
||||||
|
key,
|
||||||
|
limit_policy.get_global_limits(db),
|
||||||
|
db.get(AppConfig, limit_policy.LIMIT_POLICY_GLOBAL_KEY),
|
||||||
|
)
|
||||||
for item in app_config.list_all(db):
|
for item in app_config.list_all(db):
|
||||||
if item["key"] == key:
|
if item["key"] == key:
|
||||||
return ConfigItemOut(**item)
|
return ConfigItemOut(**item)
|
||||||
@@ -64,11 +94,20 @@ def _item(db, key: str) -> ConfigItemOut:
|
|||||||
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)")
|
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)")
|
||||||
def list_config(db: AdminDb) -> list[ConfigItemOut]:
|
def list_config(db: AdminDb) -> list[ConfigItemOut]:
|
||||||
# hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。
|
# hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。
|
||||||
return [
|
legacy_items = {
|
||||||
ConfigItemOut(**item)
|
item["key"]: item for item in app_config.list_all(db)
|
||||||
for item in app_config.list_all(db)
|
}
|
||||||
if not CONFIG_DEFS[item["key"]].get("hidden")
|
values = limit_policy.get_global_limits(db)
|
||||||
]
|
bundle = db.get(AppConfig, limit_policy.LIMIT_POLICY_GLOBAL_KEY)
|
||||||
|
out: list[ConfigItemOut] = []
|
||||||
|
for key, definition in CONFIG_DEFS.items():
|
||||||
|
if definition.get("hidden"):
|
||||||
|
continue
|
||||||
|
if key in limit_policy.RULE_CODE_BY_CONFIG_KEY:
|
||||||
|
out.append(_limit_item(key, values, bundle))
|
||||||
|
else:
|
||||||
|
out.append(ConfigItemOut(**legacy_items[key]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)")
|
@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)")
|
||||||
@@ -83,11 +122,39 @@ def update_config(
|
|||||||
raise HTTPException(status_code=404, detail="未知配置项")
|
raise HTTPException(status_code=404, detail="未知配置项")
|
||||||
try:
|
try:
|
||||||
_validate(key, body.value)
|
_validate(key, body.value)
|
||||||
|
rule_code = limit_policy.RULE_CODE_BY_CONFIG_KEY.get(key)
|
||||||
|
if rule_code is not None:
|
||||||
|
before = limit_policy.get_global_limits(db)[rule_code]
|
||||||
|
limit_policy.set_global_limits(
|
||||||
|
db,
|
||||||
|
{rule_code: body.value},
|
||||||
|
admin_id=admin.id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
before = app_config.get_value(db, key)
|
||||||
|
app_config.set_value(
|
||||||
|
db,
|
||||||
|
key,
|
||||||
|
body.value,
|
||||||
|
admin_id=admin.id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
if key == "ad_daily_limit":
|
||||||
|
# 旧系统配置接口过去只有一个广告日上限。仍有人直接调用时,同时同步
|
||||||
|
# 新的激励视频/Draw 两项,避免旧入口写入后业务实际值不变。
|
||||||
|
limit_policy.set_global_limits(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
"ad.reward_video.daily": body.value,
|
||||||
|
"ad.feed.daily": body.value,
|
||||||
|
},
|
||||||
|
admin_id=admin.id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
db.rollback()
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
|
||||||
before = app_config.get_value(db, key)
|
|
||||||
app_config.set_value(db, key, body.value, admin_id=admin.id, commit=False)
|
|
||||||
write_audit(
|
write_audit(
|
||||||
db, admin, action="config.set", target_type="config", target_id=key,
|
db, admin, action="config.set", target_type="config", target_id=key,
|
||||||
detail={"before": before, "after": body.value}, ip=get_client_ip(request), commit=False,
|
detail={"before": before, "after": body.value}, ip=get_client_ip(request), commit=False,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。"""
|
"""admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ from app.models.feedback import Feedback
|
|||||||
from app.repositories import wallet as wallet_repo
|
from app.repositories import wallet as wallet_repo
|
||||||
from app.services import notification_events
|
from app.services import notification_events
|
||||||
|
|
||||||
|
logger = logging.getLogger("shagua.admin.feedback")
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/admin/api/feedbacks",
|
prefix="/admin/api/feedbacks",
|
||||||
tags=["admin-feedback"],
|
tags=["admin-feedback"],
|
||||||
@@ -46,10 +49,24 @@ def _approve_feedback(
|
|||||||
*,
|
*,
|
||||||
bulk: bool = False,
|
bulk: bool = False,
|
||||||
) -> FeedbackOut:
|
) -> FeedbackOut:
|
||||||
|
logger.info(
|
||||||
|
"feedback approve started feedback_id=%s admin_id=%s bulk=%s",
|
||||||
|
feedback_id, admin.id, bulk,
|
||||||
|
)
|
||||||
fb = db.get(Feedback, feedback_id, with_for_update=True)
|
fb = db.get(Feedback, feedback_id, with_for_update=True)
|
||||||
if fb is None:
|
if fb is None:
|
||||||
|
logger.warning(
|
||||||
|
"feedback approve rejected not found feedback_id=%s admin_id=%s bulk=%s",
|
||||||
|
feedback_id, admin.id, bulk,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||||
_ensure_pending(fb)
|
if fb.status not in {"pending", "new"}:
|
||||||
|
logger.warning(
|
||||||
|
"feedback approve rejected invalid status feedback_id=%s user_id=%s "
|
||||||
|
"admin_id=%s status=%s bulk=%s",
|
||||||
|
feedback_id, fb.user_id, admin.id, fb.status, bulk,
|
||||||
|
)
|
||||||
|
_ensure_pending(fb)
|
||||||
|
|
||||||
before = fb.status
|
before = fb.status
|
||||||
mutations.review_feedback(
|
mutations.review_feedback(
|
||||||
@@ -91,8 +108,18 @@ def _approve_feedback(
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(fb)
|
db.refresh(fb)
|
||||||
|
logger.info(
|
||||||
|
"feedback approve committed feedback_id=%s user_id=%s admin_id=%s "
|
||||||
|
"before=%s after=%s reward_coins=%s bulk=%s",
|
||||||
|
feedback_id, fb.user_id, admin.id, before, fb.status, payload.reward_coins, bulk,
|
||||||
|
)
|
||||||
out = FeedbackOut.model_validate(fb)
|
out = FeedbackOut.model_validate(fb)
|
||||||
notification_events.notify_feedback_reward(db, fb)
|
notification_events.notify_feedback_reward(db, fb)
|
||||||
|
logger.info(
|
||||||
|
"feedback approve notification dispatch returned feedback_id=%s user_id=%s "
|
||||||
|
"admin_id=%s notification_type=feedback_reward bulk=%s",
|
||||||
|
feedback_id, fb.user_id, admin.id, bulk,
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -105,10 +132,24 @@ def _reject_feedback(
|
|||||||
*,
|
*,
|
||||||
bulk: bool = False,
|
bulk: bool = False,
|
||||||
) -> FeedbackOut:
|
) -> FeedbackOut:
|
||||||
|
logger.info(
|
||||||
|
"feedback reject started feedback_id=%s admin_id=%s bulk=%s",
|
||||||
|
feedback_id, admin.id, bulk,
|
||||||
|
)
|
||||||
fb = db.get(Feedback, feedback_id, with_for_update=True)
|
fb = db.get(Feedback, feedback_id, with_for_update=True)
|
||||||
if fb is None:
|
if fb is None:
|
||||||
|
logger.warning(
|
||||||
|
"feedback reject rejected not found feedback_id=%s admin_id=%s bulk=%s",
|
||||||
|
feedback_id, admin.id, bulk,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||||
_ensure_pending(fb)
|
if fb.status not in {"pending", "new"}:
|
||||||
|
logger.warning(
|
||||||
|
"feedback reject rejected invalid status feedback_id=%s user_id=%s "
|
||||||
|
"admin_id=%s status=%s bulk=%s",
|
||||||
|
feedback_id, fb.user_id, admin.id, fb.status, bulk,
|
||||||
|
)
|
||||||
|
_ensure_pending(fb)
|
||||||
|
|
||||||
before = fb.status
|
before = fb.status
|
||||||
mutations.review_feedback(
|
mutations.review_feedback(
|
||||||
@@ -142,8 +183,18 @@ def _reject_feedback(
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(fb)
|
db.refresh(fb)
|
||||||
|
logger.info(
|
||||||
|
"feedback reject committed feedback_id=%s user_id=%s admin_id=%s "
|
||||||
|
"before=%s after=%s bulk=%s",
|
||||||
|
feedback_id, fb.user_id, admin.id, before, fb.status, bulk,
|
||||||
|
)
|
||||||
out = FeedbackOut.model_validate(fb)
|
out = FeedbackOut.model_validate(fb)
|
||||||
notification_events.notify_feedback_reply(db, fb)
|
notification_events.notify_feedback_reply(db, fb)
|
||||||
|
logger.info(
|
||||||
|
"feedback reject notification dispatch returned feedback_id=%s user_id=%s "
|
||||||
|
"admin_id=%s notification_type=feedback_reply bulk=%s",
|
||||||
|
feedback_id, fb.user_id, admin.id, bulk,
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -201,6 +252,10 @@ def bulk_approve_feedbacks(
|
|||||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
) -> FeedbackBulkResult:
|
) -> FeedbackBulkResult:
|
||||||
|
logger.info(
|
||||||
|
"feedback bulk approve started admin_id=%s item_count=%s",
|
||||||
|
admin.id, len(body.ids),
|
||||||
|
)
|
||||||
results: list[FeedbackBulkItemResult] = []
|
results: list[FeedbackBulkItemResult] = []
|
||||||
ip = get_client_ip(request)
|
ip = get_client_ip(request)
|
||||||
for feedback_id in body.ids:
|
for feedback_id in body.ids:
|
||||||
@@ -209,11 +264,24 @@ def bulk_approve_feedbacks(
|
|||||||
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
|
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
logger.warning(
|
||||||
|
"feedback bulk approve item failed feedback_id=%s admin_id=%s error=%s",
|
||||||
|
feedback_id, admin.id, exc.detail,
|
||||||
|
)
|
||||||
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
|
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
|
||||||
except Exception: # noqa: BLE001 - 单笔失败不打断整批
|
except Exception: # noqa: BLE001 - 单笔失败不打断整批
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
logger.exception(
|
||||||
|
"feedback bulk approve item failed feedback_id=%s admin_id=%s",
|
||||||
|
feedback_id, admin.id,
|
||||||
|
)
|
||||||
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
|
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
|
||||||
return _bulk_result(results)
|
result = _bulk_result(results)
|
||||||
|
logger.info(
|
||||||
|
"feedback bulk approve completed admin_id=%s total=%s success=%s failed=%s",
|
||||||
|
admin.id, result.total, result.success, result.failed,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bulk/reject", response_model=FeedbackBulkResult, summary="批量拒绝采纳反馈")
|
@router.post("/bulk/reject", response_model=FeedbackBulkResult, summary="批量拒绝采纳反馈")
|
||||||
@@ -223,6 +291,10 @@ def bulk_reject_feedbacks(
|
|||||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
) -> FeedbackBulkResult:
|
) -> FeedbackBulkResult:
|
||||||
|
logger.info(
|
||||||
|
"feedback bulk reject started admin_id=%s item_count=%s",
|
||||||
|
admin.id, len(body.ids),
|
||||||
|
)
|
||||||
results: list[FeedbackBulkItemResult] = []
|
results: list[FeedbackBulkItemResult] = []
|
||||||
ip = get_client_ip(request)
|
ip = get_client_ip(request)
|
||||||
for feedback_id in body.ids:
|
for feedback_id in body.ids:
|
||||||
@@ -231,11 +303,24 @@ def bulk_reject_feedbacks(
|
|||||||
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
|
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
logger.warning(
|
||||||
|
"feedback bulk reject item failed feedback_id=%s admin_id=%s error=%s",
|
||||||
|
feedback_id, admin.id, exc.detail,
|
||||||
|
)
|
||||||
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
|
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
|
||||||
except Exception: # noqa: BLE001 - 单笔失败不打断整批
|
except Exception: # noqa: BLE001 - 单笔失败不打断整批
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
logger.exception(
|
||||||
|
"feedback bulk reject item failed feedback_id=%s admin_id=%s",
|
||||||
|
feedback_id, admin.id,
|
||||||
|
)
|
||||||
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
|
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
|
||||||
return _bulk_result(results)
|
result = _bulk_result(results)
|
||||||
|
logger.info(
|
||||||
|
"feedback bulk reject completed admin_id=%s total=%s success=%s failed=%s",
|
||||||
|
admin.id, result.total, result.success, result.failed,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
||||||
@@ -258,7 +343,16 @@ def approve_feedback(
|
|||||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
) -> FeedbackOut:
|
) -> FeedbackOut:
|
||||||
return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request))
|
try:
|
||||||
|
return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"feedback approve failed feedback_id=%s admin_id=%s",
|
||||||
|
feedback_id, admin.id,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
|
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
|
||||||
@@ -269,4 +363,13 @@ def reject_feedback(
|
|||||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
) -> FeedbackOut:
|
) -> FeedbackOut:
|
||||||
return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request))
|
try:
|
||||||
|
return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"feedback reject failed feedback_id=%s admin_id=%s",
|
||||||
|
feedback_id, admin.id,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ client_max_body_size,见 shaguabijia-admin-web/deploy/nginx/admin.shaguabijia.co
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||||
|
|
||||||
@@ -27,14 +27,16 @@ router = APIRouter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _out(db: AdminDb) -> GuideVideoConfigOut:
|
GuideScene = Literal["coupon", "comparison"]
|
||||||
"""配置 + 播放统计合成响应(四个写接口都以最新状态返回,前端一次同步到位)。"""
|
|
||||||
return GuideVideoConfigOut(**guide_video.get_config(db), **guide_video.play_stats(db))
|
|
||||||
|
def _out(db: AdminDb, scene: GuideScene) -> GuideVideoConfigOut:
|
||||||
|
return GuideVideoConfigOut(scene=scene, **guide_video.get_config(db, scene))
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=GuideVideoConfigOut, summary="新手引导视频配置(领券浮层)")
|
@router.get("", response_model=GuideVideoConfigOut, summary="新手引导视频配置(领券浮层)")
|
||||||
def get_config(db: AdminDb) -> GuideVideoConfigOut:
|
def get_config(db: AdminDb, scene: GuideScene = "coupon") -> GuideVideoConfigOut:
|
||||||
return _out(db)
|
return _out(db, scene)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("", response_model=GuideVideoConfigOut, summary="改开关/次数/金币(带审计)")
|
@router.patch("", response_model=GuideVideoConfigOut, summary="改开关/次数/金币(带审计)")
|
||||||
@@ -43,21 +45,26 @@ def update_config(
|
|||||||
request: Request,
|
request: Request,
|
||||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
|
scene: GuideScene = "coupon",
|
||||||
) -> GuideVideoConfigOut:
|
) -> GuideVideoConfigOut:
|
||||||
before, after = guide_video.update_config(
|
try:
|
||||||
db,
|
before, after = guide_video.update_config(
|
||||||
enabled=body.enabled,
|
db,
|
||||||
max_plays=body.max_plays,
|
enabled=body.enabled,
|
||||||
reward_coin=body.reward_coin,
|
max_plays=body.max_plays,
|
||||||
admin_id=admin.id,
|
reward_coin=body.reward_coin,
|
||||||
commit=False,
|
scene=scene,
|
||||||
)
|
admin_id=admin.id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
write_audit(
|
write_audit(
|
||||||
db, admin, action="guide_video.update", target_type="guide_video", target_id=None,
|
db, admin, action="guide_video.update", target_type="guide_video", target_id=None,
|
||||||
detail={"before": before, "after": after}, ip=get_client_ip(request), commit=False,
|
detail={"scene": scene, "before": before, "after": after}, ip=get_client_ip(request), commit=False,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return _out(db)
|
return _out(db, scene)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/video", response_model=GuideVideoConfigOut, summary="上传新手引导视频(MP4,带审计)")
|
@router.post("/video", response_model=GuideVideoConfigOut, summary="上传新手引导视频(MP4,带审计)")
|
||||||
@@ -65,23 +72,40 @@ async def upload_video(
|
|||||||
request: Request,
|
request: Request,
|
||||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
file: UploadFile = File(...),
|
file: Annotated[UploadFile, File()],
|
||||||
|
scene: GuideScene = "coupon",
|
||||||
) -> GuideVideoConfigOut:
|
) -> GuideVideoConfigOut:
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
|
url: str | None = None
|
||||||
try:
|
try:
|
||||||
url = media.save_guide_video(data)
|
url, analysis = media.save_guide_video(data)
|
||||||
except media.MediaError as e:
|
except media.MediaError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
before, after = guide_video.set_video(db, url, admin_id=admin.id, commit=False)
|
try:
|
||||||
write_audit(
|
before, after = guide_video.set_video(
|
||||||
db, admin, action="guide_video.set_video", target_type="guide_video", target_id=None,
|
db, url, analysis=analysis, scene=scene, admin_id=admin.id, commit=False
|
||||||
detail={"before": before.get("video_url"), "after": url, "bytes": len(data)},
|
)
|
||||||
ip=get_client_ip(request), commit=False,
|
write_audit(
|
||||||
)
|
db, admin, action="guide_video.set_video", target_type="guide_video", target_id=None,
|
||||||
db.commit()
|
detail={
|
||||||
# 提交成功后再删旧片,避免新片没落库就把旧片丢了
|
"scene": scene,
|
||||||
media.delete_guide_video(before.get("video_url"))
|
"before": before.get("video_url"),
|
||||||
return _out(db)
|
"after": url,
|
||||||
|
"bytes": len(data),
|
||||||
|
"duration_ms": analysis["duration_ms"],
|
||||||
|
"video_codec": analysis["video_codec"],
|
||||||
|
"audio_codec": analysis["audio_codec"],
|
||||||
|
},
|
||||||
|
ip=get_client_ip(request), commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
media.delete_guide_video(url)
|
||||||
|
raise
|
||||||
|
# 旧文件仍可能被 prepare/已起播快照引用,不能在这里立即删除。
|
||||||
|
guide_video.cleanup_old_videos(db)
|
||||||
|
return _out(db, scene)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/video", response_model=GuideVideoConfigOut, summary="移除新手引导视频(带审计)")
|
@router.delete("/video", response_model=GuideVideoConfigOut, summary="移除新手引导视频(带审计)")
|
||||||
@@ -89,13 +113,17 @@ def delete_video(
|
|||||||
request: Request,
|
request: Request,
|
||||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
|
scene: GuideScene = "coupon",
|
||||||
) -> GuideVideoConfigOut:
|
) -> GuideVideoConfigOut:
|
||||||
"""移除后 /guide-video/start 一律返回 should_play=false,领券浮层回到「只放广告」。"""
|
"""移除后 /guide-video/start 一律返回 should_play=false,领券浮层回到「只放广告」。"""
|
||||||
before, after = guide_video.set_video(db, None, admin_id=admin.id, commit=False)
|
before, after = guide_video.set_video(
|
||||||
|
db, None, scene=scene, admin_id=admin.id, commit=False
|
||||||
|
)
|
||||||
write_audit(
|
write_audit(
|
||||||
db, admin, action="guide_video.delete_video", target_type="guide_video", target_id=None,
|
db, admin, action="guide_video.delete_video", target_type="guide_video", target_id=None,
|
||||||
detail={"before": before.get("video_url")}, ip=get_client_ip(request), commit=False,
|
detail={"scene": scene, "before": before.get("video_url")}, ip=get_client_ip(request), commit=False,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
media.delete_guide_video(before.get("video_url"))
|
# 旧文件由保护期清理任务处理,避免破坏有效计划与已起播快照。
|
||||||
return _out(db)
|
guide_video.cleanup_old_videos(db)
|
||||||
|
return _out(db, scene)
|
||||||
|
|||||||
@@ -0,0 +1,838 @@
|
|||||||
|
"""Unified limit rules and per-phone/device whitelist overrides."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
|
||||||
|
from app.admin.audit import write_audit
|
||||||
|
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_page
|
||||||
|
from app.admin.repositories import limit_whitelist as repo
|
||||||
|
from app.admin.schemas.limit_whitelist import (
|
||||||
|
DeviceCandidateOut,
|
||||||
|
GlobalLimitUpdate,
|
||||||
|
LimitOverrideBulkWrite,
|
||||||
|
LimitOverrideList,
|
||||||
|
LimitOverrideOut,
|
||||||
|
LimitOverridePatch,
|
||||||
|
LimitOverrideWrite,
|
||||||
|
LimitRuleOut,
|
||||||
|
LimitSubjectEnabledPatch,
|
||||||
|
LimitSubjectList,
|
||||||
|
LimitSubjectOut,
|
||||||
|
)
|
||||||
|
from app.core import limit_policy
|
||||||
|
from app.models.limit_policy import LimitPolicyOverride
|
||||||
|
from app.repositories import risk as risk_repo
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/admin/api/limit-whitelist",
|
||||||
|
tags=["admin-limit-whitelist"],
|
||||||
|
dependencies=[Depends(require_page("limit-whitelist"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
_WHITELIST_MODES = {
|
||||||
|
limit_policy.MODE_UNLIMITED,
|
||||||
|
limit_policy.MODE_SUPPRESS_ALERT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_whitelist_rows(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
subject_type: str,
|
||||||
|
subject_value: str,
|
||||||
|
) -> list[LimitPolicyOverride]:
|
||||||
|
return [
|
||||||
|
row
|
||||||
|
for row in repo.rows_for_subject(
|
||||||
|
db,
|
||||||
|
subject_type=subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
if row.mode in _WHITELIST_MODES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_risk_rule(db, rule_code: str) -> None:
|
||||||
|
now = risk_repo.utcnow()
|
||||||
|
if rule_code == "risk.sms.hourly":
|
||||||
|
risk_repo.reconcile_behavior_rule(
|
||||||
|
db,
|
||||||
|
rule_code=risk_repo.RULE_SMS_HOURLY,
|
||||||
|
at=now,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
elif rule_code == "risk.oneclick.daily":
|
||||||
|
risk_repo.reconcile_behavior_rule(
|
||||||
|
db,
|
||||||
|
rule_code=risk_repo.RULE_ONECLICK_DAILY,
|
||||||
|
at=now,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
elif rule_code == "risk.compare.daily":
|
||||||
|
risk_repo.reconcile_compare_rule(db, at=now, commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_or_404(db, override_id: int) -> LimitPolicyOverride:
|
||||||
|
row = db.get(LimitPolicyOverride, override_id, populate_existing=True)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="白名单配置不存在")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _out(db, row: LimitPolicyOverride) -> LimitOverrideOut:
|
||||||
|
return LimitOverrideOut(**repo.to_dict(db, row))
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_payload(value: LimitOverrideOut) -> dict:
|
||||||
|
return value.model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_out(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
subject_type: str,
|
||||||
|
subject_value: str,
|
||||||
|
rows: list[LimitPolicyOverride],
|
||||||
|
created_at,
|
||||||
|
) -> LimitSubjectOut:
|
||||||
|
items = [_out(db, row) for row in rows]
|
||||||
|
group_counts: dict[str, int] = {}
|
||||||
|
for item in items:
|
||||||
|
group_counts[item.rule_group] = group_counts.get(item.rule_group, 0) + 1
|
||||||
|
return LimitSubjectOut(
|
||||||
|
subject_type=subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
group_counts=group_counts,
|
||||||
|
total_rules=len(items),
|
||||||
|
items=items,
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=max(
|
||||||
|
(item.updated_at for item in items),
|
||||||
|
default=created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rules", response_model=list[LimitRuleOut], summary="读取所有限制规则")
|
||||||
|
def list_rules(db: AdminDb) -> list[LimitRuleOut]:
|
||||||
|
return [LimitRuleOut(**item) for item in limit_policy.rule_catalog(db)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/rules/{rule_code}",
|
||||||
|
response_model=LimitRuleOut,
|
||||||
|
summary="修改规则的全局限制值",
|
||||||
|
)
|
||||||
|
def update_rule(
|
||||||
|
rule_code: str,
|
||||||
|
body: GlobalLimitUpdate,
|
||||||
|
request: Request,
|
||||||
|
admin: CurrentAdmin,
|
||||||
|
db: AdminDb,
|
||||||
|
) -> LimitRuleOut:
|
||||||
|
try:
|
||||||
|
before, after = repo.update_global_limit(db, rule_code, body.value, admin_id=admin.id)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="未知限制规则") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
_reconcile_risk_rule(db, rule_code)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="limit.rule.update",
|
||||||
|
target_type="limit_rule",
|
||||||
|
target_id=rule_code,
|
||||||
|
detail={"before": before, "after": after},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
item = next(item for item in limit_policy.rule_catalog(db) if item["code"] == rule_code)
|
||||||
|
return LimitRuleOut(**item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/device-candidates",
|
||||||
|
response_model=list[DeviceCandidateOut],
|
||||||
|
summary="按限制项搜索可加入白名单的设备",
|
||||||
|
)
|
||||||
|
def list_device_candidates(
|
||||||
|
db: AdminDb,
|
||||||
|
rule_code: str = Query(..., min_length=1, max_length=64),
|
||||||
|
keyword: str | None = Query(None, max_length=128),
|
||||||
|
limit: int = Query(30, ge=1, le=50),
|
||||||
|
) -> list[DeviceCandidateOut]:
|
||||||
|
try:
|
||||||
|
rows = repo.list_device_candidates(
|
||||||
|
db,
|
||||||
|
rule_code=rule_code,
|
||||||
|
keyword=keyword,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="未知限制规则") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return [DeviceCandidateOut(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=LimitOverrideList, summary="读取白名单配置")
|
||||||
|
def list_overrides(
|
||||||
|
db: AdminDb,
|
||||||
|
subject_type: str | None = Query(None, pattern="^(phone|device)$"),
|
||||||
|
keyword: str | None = Query(None, max_length=128),
|
||||||
|
rule_code: str | None = Query(None, max_length=64),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
) -> LimitOverrideList:
|
||||||
|
rows, total = repo.list_rows(
|
||||||
|
db,
|
||||||
|
subject_type=subject_type,
|
||||||
|
keyword=keyword,
|
||||||
|
rule_code=rule_code,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return LimitOverrideList(items=[_out(db, row) for row in rows], total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/subjects",
|
||||||
|
response_model=LimitSubjectList,
|
||||||
|
summary="按手机号或设备聚合读取白名单配置",
|
||||||
|
)
|
||||||
|
def list_override_subjects(
|
||||||
|
db: AdminDb,
|
||||||
|
subject_type: str | None = Query(None, pattern="^(phone|device)$"),
|
||||||
|
keyword: str | None = Query(None, max_length=128),
|
||||||
|
rule_code: str | None = Query(None, max_length=64),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(10, ge=1, le=100),
|
||||||
|
) -> LimitSubjectList:
|
||||||
|
subjects, total = repo.list_subject_rows(
|
||||||
|
db,
|
||||||
|
subject_type=subject_type,
|
||||||
|
keyword=keyword,
|
||||||
|
rule_code=rule_code,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return LimitSubjectList(
|
||||||
|
items=[
|
||||||
|
_subject_out(
|
||||||
|
db,
|
||||||
|
subject_type=item_subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
rows=rows,
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
for item_subject_type, subject_value, rows, created_at in subjects
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/subjects/enabled",
|
||||||
|
response_model=LimitSubjectOut,
|
||||||
|
summary="整体启用或停用一个手机号或设备的白名单",
|
||||||
|
)
|
||||||
|
def set_override_subject_enabled(
|
||||||
|
body: LimitSubjectEnabledPatch,
|
||||||
|
request: Request,
|
||||||
|
admin: CurrentAdmin,
|
||||||
|
db: AdminDb,
|
||||||
|
) -> LimitSubjectOut:
|
||||||
|
try:
|
||||||
|
subject_value = limit_policy.validate_whitelist_subject(
|
||||||
|
body.subject_type, body.subject_value
|
||||||
|
)
|
||||||
|
repo.lock_subject(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
rows = _subject_whitelist_rows(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="白名单主体不存在")
|
||||||
|
before = [_audit_payload(_out(db, row)) for row in rows]
|
||||||
|
for row in rows:
|
||||||
|
repo.update(
|
||||||
|
db,
|
||||||
|
row,
|
||||||
|
enabled=body.enabled,
|
||||||
|
starts_at=None,
|
||||||
|
expires_at=None,
|
||||||
|
reason=None,
|
||||||
|
fields_set={"enabled"},
|
||||||
|
)
|
||||||
|
for rule_code in {row.rule_code for row in rows}:
|
||||||
|
_reconcile_risk_rule(db, rule_code)
|
||||||
|
after = [_audit_payload(_out(db, row)) for row in rows]
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="limit.override.subject_enabled",
|
||||||
|
target_type="limit_override_subject",
|
||||||
|
target_id=f"{body.subject_type}:{subject_value}",
|
||||||
|
detail={"before": before, "after": after},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
row
|
||||||
|
for row in repo.rows_for_subject(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
if row.mode
|
||||||
|
in {
|
||||||
|
limit_policy.MODE_UNLIMITED,
|
||||||
|
limit_policy.MODE_SUPPRESS_ALERT,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return _subject_out(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
rows=rows,
|
||||||
|
created_at=min(row.created_at for row in rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/subjects",
|
||||||
|
response_model=LimitSubjectOut,
|
||||||
|
summary="整体更新一个手机号或设备的白名单限制项",
|
||||||
|
)
|
||||||
|
def replace_override_subject(
|
||||||
|
body: LimitOverrideBulkWrite,
|
||||||
|
request: Request,
|
||||||
|
admin: CurrentAdmin,
|
||||||
|
db: AdminDb,
|
||||||
|
) -> LimitSubjectOut:
|
||||||
|
current_rule_code = ""
|
||||||
|
try:
|
||||||
|
subject_value = limit_policy.validate_whitelist_subject(
|
||||||
|
body.subject_type, body.subject_value
|
||||||
|
)
|
||||||
|
repo.lock_subject(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
existing_rows = repo.rows_for_subject(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
subject_created_at = min(
|
||||||
|
(row.created_at for row in existing_rows),
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
before = [_audit_payload(_out(db, row)) for row in existing_rows]
|
||||||
|
existing_by_rule = {row.rule_code: row for row in existing_rows}
|
||||||
|
selected_codes = set(body.rule_codes)
|
||||||
|
touched_rule_codes = set(selected_codes)
|
||||||
|
|
||||||
|
for current_rule_code in body.rule_codes:
|
||||||
|
rule = limit_policy.get_rule(current_rule_code)
|
||||||
|
mode = (
|
||||||
|
limit_policy.MODE_SUPPRESS_ALERT if rule.alert_only else limit_policy.MODE_UNLIMITED
|
||||||
|
)
|
||||||
|
row = existing_by_rule.get(current_rule_code)
|
||||||
|
if row is None:
|
||||||
|
row = repo.create(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
rule_code=current_rule_code,
|
||||||
|
mode=mode,
|
||||||
|
limit_value=None,
|
||||||
|
enabled=body.enabled,
|
||||||
|
starts_at=body.starts_at,
|
||||||
|
expires_at=body.expires_at,
|
||||||
|
reason=body.reason,
|
||||||
|
admin_id=admin.id,
|
||||||
|
)
|
||||||
|
# The table is ordered by the subject's original creation
|
||||||
|
# time. If an edit replaces every rule, carry that timestamp
|
||||||
|
# to the new rows so the subject does not jump to the top.
|
||||||
|
if subject_created_at is not None:
|
||||||
|
row.created_at = subject_created_at
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Historical custom-value rows are converted to the only supported
|
||||||
|
# product modes when the administrator selects that rule again.
|
||||||
|
row.mode = mode
|
||||||
|
row.limit_value = None
|
||||||
|
row.reset_at = None
|
||||||
|
repo.update(
|
||||||
|
db,
|
||||||
|
row,
|
||||||
|
enabled=body.enabled,
|
||||||
|
starts_at=body.starts_at,
|
||||||
|
expires_at=body.expires_at,
|
||||||
|
reason=body.reason,
|
||||||
|
fields_set={"enabled", "starts_at", "expires_at", "reason"},
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in existing_rows:
|
||||||
|
if row.rule_code not in selected_codes and row.mode in {
|
||||||
|
limit_policy.MODE_UNLIMITED,
|
||||||
|
limit_policy.MODE_SUPPRESS_ALERT,
|
||||||
|
}:
|
||||||
|
touched_rule_codes.add(row.rule_code)
|
||||||
|
db.delete(row)
|
||||||
|
db.flush()
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="未知限制规则") from exc
|
||||||
|
except repo.DuplicateOverrideError as exc:
|
||||||
|
rule = limit_policy.get_rule(current_rule_code)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"“{rule.label}”已有白名单配置,请刷新后重试",
|
||||||
|
) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
for rule_code in touched_rule_codes:
|
||||||
|
_reconcile_risk_rule(db, rule_code)
|
||||||
|
rows = [
|
||||||
|
row
|
||||||
|
for row in repo.rows_for_subject(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
if row.mode
|
||||||
|
in {
|
||||||
|
limit_policy.MODE_UNLIMITED,
|
||||||
|
limit_policy.MODE_SUPPRESS_ALERT,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
after = [_audit_payload(_out(db, row)) for row in rows]
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="limit.override.subject_replace",
|
||||||
|
target_type="limit_override_subject",
|
||||||
|
target_id=f"{body.subject_type}:{subject_value}",
|
||||||
|
detail={"before": before, "after": after},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
rows = [
|
||||||
|
row
|
||||||
|
for row in repo.rows_for_subject(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
if row.mode
|
||||||
|
in {
|
||||||
|
limit_policy.MODE_UNLIMITED,
|
||||||
|
limit_policy.MODE_SUPPRESS_ALERT,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return _subject_out(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
rows=rows,
|
||||||
|
created_at=min(row.created_at for row in rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=LimitOverrideOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="新增白名单配置",
|
||||||
|
)
|
||||||
|
def create_override(
|
||||||
|
body: LimitOverrideWrite,
|
||||||
|
request: Request,
|
||||||
|
admin: CurrentAdmin,
|
||||||
|
db: AdminDb,
|
||||||
|
) -> LimitOverrideOut:
|
||||||
|
before: list[dict] = []
|
||||||
|
touched_rows: list[LimitPolicyOverride] = []
|
||||||
|
try:
|
||||||
|
subject_value = limit_policy.validate_whitelist_subject(
|
||||||
|
body.subject_type, body.subject_value
|
||||||
|
)
|
||||||
|
repo.lock_subject(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
existing_rows = _subject_whitelist_rows(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
before = [_audit_payload(_out(db, item)) for item in existing_rows]
|
||||||
|
row = repo.create(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
rule_code=body.rule_code,
|
||||||
|
mode=body.mode,
|
||||||
|
enabled=body.enabled,
|
||||||
|
starts_at=body.starts_at,
|
||||||
|
expires_at=body.expires_at,
|
||||||
|
reason=body.reason,
|
||||||
|
limit_value=None,
|
||||||
|
admin_id=admin.id,
|
||||||
|
)
|
||||||
|
touched_rows = [*existing_rows, row]
|
||||||
|
if row.mode in _WHITELIST_MODES:
|
||||||
|
# Keep the legacy single-rule endpoint compatible without letting
|
||||||
|
# it create a second validity period for the same logical subject.
|
||||||
|
for existing in existing_rows:
|
||||||
|
repo.update(
|
||||||
|
db,
|
||||||
|
existing,
|
||||||
|
enabled=body.enabled,
|
||||||
|
starts_at=body.starts_at,
|
||||||
|
expires_at=body.expires_at,
|
||||||
|
reason=None,
|
||||||
|
fields_set={"enabled", "starts_at", "expires_at"},
|
||||||
|
)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="未知限制规则") from exc
|
||||||
|
except repo.DuplicateOverrideError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="该手机号或设备已配置此规则,请编辑现有配置",
|
||||||
|
) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
for rule_code in {item.rule_code for item in touched_rows}:
|
||||||
|
_reconcile_risk_rule(db, rule_code)
|
||||||
|
after_rows = _subject_whitelist_rows(
|
||||||
|
db,
|
||||||
|
subject_type=row.subject_type,
|
||||||
|
subject_value=row.subject_value,
|
||||||
|
)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="limit.override.create",
|
||||||
|
target_type="limit_override_subject",
|
||||||
|
target_id=f"{row.subject_type}:{row.subject_value}",
|
||||||
|
detail={
|
||||||
|
"before": before,
|
||||||
|
"after": [_audit_payload(_out(db, item)) for item in after_rows],
|
||||||
|
},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _out(db, row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/bulk",
|
||||||
|
response_model=list[LimitOverrideOut],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="批量新增临时不限或免告警白名单",
|
||||||
|
)
|
||||||
|
def create_overrides_bulk(
|
||||||
|
body: LimitOverrideBulkWrite,
|
||||||
|
request: Request,
|
||||||
|
admin: CurrentAdmin,
|
||||||
|
db: AdminDb,
|
||||||
|
) -> list[LimitOverrideOut]:
|
||||||
|
rows: list[LimitPolicyOverride] = []
|
||||||
|
before: list[dict] = []
|
||||||
|
current_rule_code = ""
|
||||||
|
try:
|
||||||
|
subject_value = limit_policy.validate_whitelist_subject(
|
||||||
|
body.subject_type, body.subject_value
|
||||||
|
)
|
||||||
|
repo.lock_subject(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
existing_rows = repo.rows_for_subject(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
whitelist_modes = {
|
||||||
|
limit_policy.MODE_UNLIMITED,
|
||||||
|
limit_policy.MODE_SUPPRESS_ALERT,
|
||||||
|
}
|
||||||
|
existing_whitelist_rows = [row for row in existing_rows if row.mode in whitelist_modes]
|
||||||
|
before = [_audit_payload(_out(db, row)) for row in existing_whitelist_rows]
|
||||||
|
existing_by_rule = {row.rule_code: row for row in existing_rows}
|
||||||
|
selected_codes = set(body.rule_codes)
|
||||||
|
for current_rule_code in body.rule_codes:
|
||||||
|
rule = limit_policy.get_rule(current_rule_code)
|
||||||
|
mode = (
|
||||||
|
limit_policy.MODE_SUPPRESS_ALERT if rule.alert_only else limit_policy.MODE_UNLIMITED
|
||||||
|
)
|
||||||
|
row = existing_by_rule.get(current_rule_code)
|
||||||
|
if row is None:
|
||||||
|
row = repo.create(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
rule_code=current_rule_code,
|
||||||
|
mode=mode,
|
||||||
|
limit_value=None,
|
||||||
|
enabled=body.enabled,
|
||||||
|
starts_at=body.starts_at,
|
||||||
|
expires_at=body.expires_at,
|
||||||
|
reason=body.reason,
|
||||||
|
admin_id=admin.id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.mode = mode
|
||||||
|
row.limit_value = None
|
||||||
|
row.reset_at = None
|
||||||
|
repo.update(
|
||||||
|
db,
|
||||||
|
row,
|
||||||
|
enabled=body.enabled,
|
||||||
|
starts_at=body.starts_at,
|
||||||
|
expires_at=body.expires_at,
|
||||||
|
reason=body.reason,
|
||||||
|
fields_set={
|
||||||
|
"enabled",
|
||||||
|
"starts_at",
|
||||||
|
"expires_at",
|
||||||
|
"reason",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 同一手机号或设备在产品上是一条白名单。追加限制项时保留原规则,
|
||||||
|
# 但统一使用最后一次配置的启用状态与有效期,避免一个主体出现多套时间。
|
||||||
|
rows = [
|
||||||
|
row
|
||||||
|
for row in repo.rows_for_subject(
|
||||||
|
db,
|
||||||
|
subject_type=body.subject_type,
|
||||||
|
subject_value=subject_value,
|
||||||
|
)
|
||||||
|
if row.mode in whitelist_modes
|
||||||
|
]
|
||||||
|
for row in rows:
|
||||||
|
if row.rule_code in selected_codes:
|
||||||
|
continue
|
||||||
|
repo.update(
|
||||||
|
db,
|
||||||
|
row,
|
||||||
|
enabled=body.enabled,
|
||||||
|
starts_at=body.starts_at,
|
||||||
|
expires_at=body.expires_at,
|
||||||
|
reason=None,
|
||||||
|
fields_set={"enabled", "starts_at", "expires_at"},
|
||||||
|
)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="未知限制规则") from exc
|
||||||
|
except repo.DuplicateOverrideError as exc:
|
||||||
|
rule = limit_policy.get_rule(current_rule_code)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"“{rule.label}”白名单配置发生并发更新,请刷新后重试",
|
||||||
|
) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
_reconcile_risk_rule(db, row.rule_code)
|
||||||
|
results = [_out(db, row) for row in rows]
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="limit.override.bulk_create",
|
||||||
|
target_type="limit_override",
|
||||||
|
target_id=",".join(str(row.id) for row in rows),
|
||||||
|
detail={
|
||||||
|
"before": before,
|
||||||
|
"after": [_audit_payload(item) for item in results],
|
||||||
|
},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return [_out(db, row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{override_id}",
|
||||||
|
response_model=LimitOverrideOut,
|
||||||
|
summary="编辑白名单配置",
|
||||||
|
)
|
||||||
|
def update_override(
|
||||||
|
override_id: int,
|
||||||
|
body: LimitOverridePatch,
|
||||||
|
request: Request,
|
||||||
|
admin: CurrentAdmin,
|
||||||
|
db: AdminDb,
|
||||||
|
) -> LimitOverrideOut:
|
||||||
|
row = _row_or_404(db, override_id)
|
||||||
|
repo.lock_subject(
|
||||||
|
db,
|
||||||
|
subject_type=row.subject_type,
|
||||||
|
subject_value=row.subject_value,
|
||||||
|
)
|
||||||
|
row = _row_or_404(db, override_id)
|
||||||
|
subject_rows = (
|
||||||
|
_subject_whitelist_rows(
|
||||||
|
db,
|
||||||
|
subject_type=row.subject_type,
|
||||||
|
subject_value=row.subject_value,
|
||||||
|
)
|
||||||
|
if row.mode in _WHITELIST_MODES
|
||||||
|
else [row]
|
||||||
|
)
|
||||||
|
before = [_audit_payload(_out(db, item)) for item in subject_rows]
|
||||||
|
fields_set = set(body.model_fields_set)
|
||||||
|
try:
|
||||||
|
period_fields = {"enabled", "starts_at", "expires_at"}
|
||||||
|
if row.mode in _WHITELIST_MODES and fields_set & period_fields:
|
||||||
|
new_enabled = body.enabled if "enabled" in fields_set else row.enabled
|
||||||
|
new_starts = body.starts_at if "starts_at" in fields_set else row.starts_at
|
||||||
|
new_expires = body.expires_at if "expires_at" in fields_set else row.expires_at
|
||||||
|
for item in subject_rows:
|
||||||
|
item_fields = set(period_fields)
|
||||||
|
if item.id == row.id and "reason" in fields_set:
|
||||||
|
item_fields.add("reason")
|
||||||
|
repo.update(
|
||||||
|
db,
|
||||||
|
item,
|
||||||
|
enabled=new_enabled,
|
||||||
|
starts_at=new_starts,
|
||||||
|
expires_at=new_expires,
|
||||||
|
reason=body.reason if item.id == row.id else None,
|
||||||
|
fields_set=item_fields,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
repo.update(
|
||||||
|
db,
|
||||||
|
row,
|
||||||
|
**body.model_dump(),
|
||||||
|
fields_set=fields_set,
|
||||||
|
)
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
for rule_code in {item.rule_code for item in subject_rows}:
|
||||||
|
_reconcile_risk_rule(db, rule_code)
|
||||||
|
after_rows = [_audit_payload(_out(db, item)) for item in subject_rows]
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="limit.override.update",
|
||||||
|
target_type="limit_override_subject",
|
||||||
|
target_id=f"{row.subject_type}:{row.subject_value}",
|
||||||
|
detail={"before": before, "after": after_rows},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _out(db, row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{override_id}/reset",
|
||||||
|
response_model=LimitOverrideOut,
|
||||||
|
summary="停用例外配置并恢复全局策略",
|
||||||
|
)
|
||||||
|
def restore_global_policy(
|
||||||
|
override_id: int,
|
||||||
|
request: Request,
|
||||||
|
admin: CurrentAdmin,
|
||||||
|
db: AdminDb,
|
||||||
|
) -> LimitOverrideOut:
|
||||||
|
row = _row_or_404(db, override_id)
|
||||||
|
repo.lock_subject(
|
||||||
|
db,
|
||||||
|
subject_type=row.subject_type,
|
||||||
|
subject_value=row.subject_value,
|
||||||
|
)
|
||||||
|
row = _row_or_404(db, override_id)
|
||||||
|
subject_rows = (
|
||||||
|
_subject_whitelist_rows(
|
||||||
|
db,
|
||||||
|
subject_type=row.subject_type,
|
||||||
|
subject_value=row.subject_value,
|
||||||
|
)
|
||||||
|
if row.mode in _WHITELIST_MODES
|
||||||
|
else [row]
|
||||||
|
)
|
||||||
|
before = [_audit_payload(_out(db, item)) for item in subject_rows]
|
||||||
|
for item in subject_rows:
|
||||||
|
repo.restore_global(item)
|
||||||
|
for rule_code in {item.rule_code for item in subject_rows}:
|
||||||
|
_reconcile_risk_rule(db, rule_code)
|
||||||
|
after = [_audit_payload(_out(db, item)) for item in subject_rows]
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="limit.override.restore_global",
|
||||||
|
target_type="limit_override_subject",
|
||||||
|
target_id=f"{row.subject_type}:{row.subject_value}",
|
||||||
|
detail={"before": before, "after": after},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _out(db, row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{override_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="删除白名单配置",
|
||||||
|
)
|
||||||
|
def delete_override(
|
||||||
|
override_id: int,
|
||||||
|
request: Request,
|
||||||
|
admin: CurrentAdmin,
|
||||||
|
db: AdminDb,
|
||||||
|
) -> None:
|
||||||
|
row = _row_or_404(db, override_id)
|
||||||
|
repo.lock_subject(
|
||||||
|
db,
|
||||||
|
subject_type=row.subject_type,
|
||||||
|
subject_value=row.subject_value,
|
||||||
|
)
|
||||||
|
row = _row_or_404(db, override_id)
|
||||||
|
before = _audit_payload(_out(db, row))
|
||||||
|
target_id = str(row.id)
|
||||||
|
rule_code = row.rule_code
|
||||||
|
db.delete(row)
|
||||||
|
db.flush()
|
||||||
|
_reconcile_risk_rule(db, rule_code)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="limit.override.delete",
|
||||||
|
target_type="limit_override",
|
||||||
|
target_id=target_id,
|
||||||
|
detail={"before": before},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
@@ -23,13 +23,8 @@ from app.admin.schemas.risk_monitor import (
|
|||||||
RiskResetResponse,
|
RiskResetResponse,
|
||||||
RiskRuleConfig,
|
RiskRuleConfig,
|
||||||
)
|
)
|
||||||
from app.core.config_schema import (
|
from app.core import limit_policy
|
||||||
RISK_COMPARE_DAILY_THRESHOLD_KEY,
|
|
||||||
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
|
||||||
RISK_SMS_HOURLY_THRESHOLD_KEY,
|
|
||||||
)
|
|
||||||
from app.models.risk import RiskIncident, SubjectRestriction
|
from app.models.risk import RiskIncident, SubjectRestriction
|
||||||
from app.repositories import app_config
|
|
||||||
from app.repositories import risk as risk_repo
|
from app.repositories import risk as risk_repo
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -83,15 +78,16 @@ def update_rules(
|
|||||||
) -> RiskRuleConfig:
|
) -> RiskRuleConfig:
|
||||||
before = _rule_config(db).model_dump()
|
before = _rule_config(db).model_dump()
|
||||||
after = body.model_dump()
|
after = body.model_dump()
|
||||||
values = (
|
limit_policy.set_global_limits(
|
||||||
(RISK_SMS_HOURLY_THRESHOLD_KEY, body.sms_hourly_threshold),
|
db,
|
||||||
(RISK_ONECLICK_DAILY_THRESHOLD_KEY, body.oneclick_daily_threshold),
|
{
|
||||||
(RISK_COMPARE_DAILY_THRESHOLD_KEY, body.compare_daily_threshold),
|
"risk.sms.hourly": body.sms_hourly_threshold,
|
||||||
|
"risk.oneclick.daily": body.oneclick_daily_threshold,
|
||||||
|
"risk.compare.daily": body.compare_daily_threshold,
|
||||||
|
},
|
||||||
|
admin_id=admin.id,
|
||||||
|
commit=False,
|
||||||
)
|
)
|
||||||
for key, value in values:
|
|
||||||
app_config.set_value(
|
|
||||||
db, key, value, admin_id=admin.id, commit=False
|
|
||||||
)
|
|
||||||
|
|
||||||
now = risk_repo.utcnow()
|
now = risk_repo.utcnow()
|
||||||
risk_repo.reconcile_behavior_rule(
|
risk_repo.reconcile_behavior_rule(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.admin.schemas.user import (
|
|||||||
GrantCashRequest,
|
GrantCashRequest,
|
||||||
GrantCoinsRequest,
|
GrantCoinsRequest,
|
||||||
SetDebugTraceRequest,
|
SetDebugTraceRequest,
|
||||||
|
SetUserRiskRequest,
|
||||||
SetUserStatusRequest,
|
SetUserStatusRequest,
|
||||||
UserCoinRecord,
|
UserCoinRecord,
|
||||||
UserRewardStats,
|
UserRewardStats,
|
||||||
@@ -84,12 +85,29 @@ def get_user_reward_stats(
|
|||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
date_from: Annotated[datetime | None, Query()] = None,
|
date_from: Annotated[datetime | None, Query()] = None,
|
||||||
date_to: Annotated[datetime | None, Query()] = None,
|
date_to: Annotated[datetime | None, Query()] = None,
|
||||||
|
withdraw_source: Annotated[
|
||||||
|
str | None, Query(pattern="^(coin_cash|invite_cash)$")
|
||||||
|
] = None,
|
||||||
|
app_env: Annotated[str | None, Query(pattern="^(prod|test)$")] = None,
|
||||||
|
revenue_scope: Annotated[str, Query(pattern="^(business|all)$")] = "all",
|
||||||
|
feed_scene: Annotated[
|
||||||
|
str | None, Query(pattern="^(comparison|coupon|welfare)$")
|
||||||
|
] = None,
|
||||||
) -> UserRewardStats:
|
) -> UserRewardStats:
|
||||||
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
|
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
|
||||||
if user_repo.get_user_by_id(db, user_id) is None:
|
if not user_repo.user_exists(db, user_id):
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
return UserRewardStats(
|
return UserRewardStats(
|
||||||
**queries.user_reward_stats(db, user_id, date_from=date_from, date_to=date_to)
|
**queries.user_reward_stats(
|
||||||
|
db,
|
||||||
|
user_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
withdraw_source=withdraw_source,
|
||||||
|
app_env=app_env,
|
||||||
|
revenue_scope=revenue_scope,
|
||||||
|
feed_scene=feed_scene,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -138,6 +156,50 @@ def set_user_status(
|
|||||||
return OkResponse()
|
return OkResponse()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/risk", response_model=OkResponse, summary="设置人工高风险标记与备注")
|
||||||
|
def set_user_risk(
|
||||||
|
user_id: int,
|
||||||
|
body: SetUserRiskRequest,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[
|
||||||
|
AdminUser, Depends(require_role("operator", "finance"))
|
||||||
|
],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> OkResponse:
|
||||||
|
user = user_repo.get_user_by_id(db, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
before = {
|
||||||
|
"is_high_risk": user.is_high_risk,
|
||||||
|
"high_risk_note": user.high_risk_note,
|
||||||
|
}
|
||||||
|
mutations.set_user_risk(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
is_high_risk=body.is_high_risk,
|
||||||
|
note=body.note,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="user.risk.set",
|
||||||
|
target_type="user",
|
||||||
|
target_id=user_id,
|
||||||
|
detail={
|
||||||
|
"before": before,
|
||||||
|
"after": {
|
||||||
|
"is_high_risk": user.is_high_risk,
|
||||||
|
"high_risk_note": user.high_risk_note,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return OkResponse()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限")
|
@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限")
|
||||||
def set_user_debug_trace(
|
def set_user_debug_trace(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""admin 提现:列表(读)+ 单笔重试查单 + 批量对账(写,带审计)。
|
"""admin 提现:列表(读)+ 单笔/批量查单与审核操作(写,带审计)。
|
||||||
|
|
||||||
提现的钱逻辑(查微信/退款/撤单/幂等)全部复用 app.repositories.wallet,admin 只触发 + 记审计。
|
提现的钱逻辑(查微信/退款/撤单/幂等)全部复用 app.repositories.wallet,admin 只触发 + 记审计。
|
||||||
这些 wallet 函数内部各自 commit(涉及微信调用),审计在其后单独 commit:操作本身幂等,
|
这些 wallet 函数内部各自 commit(涉及微信调用),审计在其后单独 commit:操作本身幂等,
|
||||||
@@ -14,17 +14,16 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|||||||
from app.admin.audit import write_audit
|
from app.admin.audit import write_audit
|
||||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||||
from app.admin.repositories import queries
|
from app.admin.repositories import queries
|
||||||
from app.admin.schemas.common import CursorPage
|
|
||||||
from app.admin.schemas.admin import AdminAuditLogOut
|
from app.admin.schemas.admin import AdminAuditLogOut
|
||||||
|
from app.admin.schemas.common import CursorPage
|
||||||
from app.admin.schemas.wallet import (
|
from app.admin.schemas.wallet import (
|
||||||
CashTxnOut,
|
CashTxnOut,
|
||||||
ReconcileResult,
|
InviteOverviewOut,
|
||||||
|
WithdrawBulkItemResult,
|
||||||
WithdrawBulkRejectRequest,
|
WithdrawBulkRejectRequest,
|
||||||
WithdrawBulkRequest,
|
WithdrawBulkRequest,
|
||||||
WithdrawBulkResult,
|
WithdrawBulkResult,
|
||||||
WithdrawBulkItemResult,
|
|
||||||
WithdrawDetailOut,
|
WithdrawDetailOut,
|
||||||
WithdrawLedgerCheckOut,
|
|
||||||
WithdrawListItemOut,
|
WithdrawListItemOut,
|
||||||
WithdrawOrderOut,
|
WithdrawOrderOut,
|
||||||
WithdrawRejectRequest,
|
WithdrawRejectRequest,
|
||||||
@@ -83,8 +82,16 @@ def list_withdraws(
|
|||||||
cursor=cursor,
|
cursor=cursor,
|
||||||
)
|
)
|
||||||
# 联表带出手机号/昵称 + 累计成功提现(本页 user_id 批量富化,2 条聚合查询,无 N+1)。
|
# 联表带出手机号/昵称 + 累计成功提现(本页 user_id 批量富化,2 条聚合查询,无 N+1)。
|
||||||
enrichment = queries.withdraw_list_enrichment(db, [o.user_id for o in items])
|
enrichment = queries.withdraw_list_enrichment(
|
||||||
_empty = {"phone": None, "nickname": None, "cumulative_success_cents": 0}
|
db, [o.user_id for o in items], source=source
|
||||||
|
)
|
||||||
|
_empty = {
|
||||||
|
"phone": None,
|
||||||
|
"nickname": None,
|
||||||
|
"is_high_risk": False,
|
||||||
|
"high_risk_note": None,
|
||||||
|
"cumulative_success_cents": 0,
|
||||||
|
}
|
||||||
out_items = [
|
out_items = [
|
||||||
WithdrawListItemOut.model_validate(o).model_copy(
|
WithdrawListItemOut.model_validate(o).model_copy(
|
||||||
update=enrichment.get(o.user_id, _empty)
|
update=enrichment.get(o.user_id, _empty)
|
||||||
@@ -95,8 +102,13 @@ def list_withdraws(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
|
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
|
||||||
def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut:
|
def withdraws_summary(
|
||||||
return WithdrawSummaryOut(**queries.withdraw_summary(db))
|
db: AdminDb,
|
||||||
|
source: Annotated[
|
||||||
|
str | None, Query(pattern="^(coin_cash|invite_cash)$")
|
||||||
|
] = None,
|
||||||
|
) -> WithdrawSummaryOut:
|
||||||
|
return WithdrawSummaryOut(**queries.withdraw_summary(db, source=source))
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -153,13 +165,13 @@ def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@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="提现单详情")
|
@router.get("/{out_bill_no}", response_model=WithdrawDetailOut, summary="提现单详情")
|
||||||
def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
|
def withdraw_detail(
|
||||||
|
out_bill_no: str,
|
||||||
|
db: AdminDb,
|
||||||
|
date_from: Annotated[datetime | None, Query()] = None,
|
||||||
|
date_to: Annotated[datetime | None, Query()] = None,
|
||||||
|
) -> WithdrawDetailOut:
|
||||||
order = queries.get_withdraw_by_out_bill_no(db, out_bill_no)
|
order = queries.get_withdraw_by_out_bill_no(db, out_bill_no)
|
||||||
if order is None:
|
if order is None:
|
||||||
raise HTTPException(status_code=404, detail="提现单不存在")
|
raise HTTPException(status_code=404, detail="提现单不存在")
|
||||||
@@ -172,6 +184,8 @@ def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
|
|||||||
id=user.id,
|
id=user.id,
|
||||||
phone=user.phone,
|
phone=user.phone,
|
||||||
nickname=user.nickname,
|
nickname=user.nickname,
|
||||||
|
is_high_risk=user.is_high_risk,
|
||||||
|
high_risk_note=user.high_risk_note,
|
||||||
status=user.status,
|
status=user.status,
|
||||||
wechat_nickname=user.wechat_nickname,
|
wechat_nickname=user.wechat_nickname,
|
||||||
wechat_avatar_url=user.wechat_avatar_url,
|
wechat_avatar_url=user.wechat_avatar_url,
|
||||||
@@ -195,37 +209,36 @@ def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
|
|||||||
recent_withdraws,
|
recent_withdraws,
|
||||||
overview["cash_balance_cents"] if overview else 0,
|
overview["cash_balance_cents"] if overview else 0,
|
||||||
)
|
)
|
||||||
|
detail_enrichment = queries.withdraw_list_enrichment(
|
||||||
|
db, [order.user_id], source=order.source
|
||||||
|
).get(order.user_id, {})
|
||||||
|
|
||||||
return WithdrawDetailOut(
|
return WithdrawDetailOut(
|
||||||
order=WithdrawOrderOut.model_validate(order),
|
order=WithdrawOrderOut.model_validate(order),
|
||||||
user=user_snapshot,
|
user=user_snapshot,
|
||||||
|
cumulative_success_cents=int(
|
||||||
|
detail_enrichment.get("cumulative_success_cents", 0)
|
||||||
|
),
|
||||||
risk_flags=risk_flags,
|
risk_flags=risk_flags,
|
||||||
risk_score=risk_score,
|
risk_score=risk_score,
|
||||||
recent_withdraws=[WithdrawOrderOut.model_validate(o) for o in recent_withdraws],
|
recent_withdraws=[WithdrawOrderOut.model_validate(o) for o in recent_withdraws],
|
||||||
recent_cash_transactions=[CashTxnOut.model_validate(t) for t in recent_cash_transactions],
|
recent_cash_transactions=[CashTxnOut.model_validate(t) for t in recent_cash_transactions],
|
||||||
audit_logs=[AdminAuditLogOut.model_validate(log) for log in audit_logs],
|
audit_logs=[AdminAuditLogOut.model_validate(log) for log in audit_logs],
|
||||||
|
invite_overview=(
|
||||||
|
InviteOverviewOut(
|
||||||
|
**queries.invite_overview(
|
||||||
|
db,
|
||||||
|
order.user_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if order.source == "invite_cash"
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# 注意:/reconcile 必须在 /{out_bill_no}/refresh 之前声明(静态路径优先于路径参数)
|
|
||||||
@router.post("/reconcile", response_model=ReconcileResult, summary="批量对账(扫超时 pending 单)")
|
|
||||||
def reconcile(
|
|
||||||
request: Request,
|
|
||||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
|
||||||
db: AdminDb,
|
|
||||||
older_than_minutes: Annotated[int, Query(ge=0)] = 15,
|
|
||||||
) -> ReconcileResult:
|
|
||||||
try:
|
|
||||||
result = wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes)
|
|
||||||
except wxpay.WxPayNotConfiguredError as e:
|
|
||||||
raise HTTPException(status_code=503, detail="微信支付未配置") from e
|
|
||||||
write_audit(
|
|
||||||
db, admin, action="withdraw.reconcile", target_type="withdraw", target_id=None,
|
|
||||||
detail=result, ip=get_client_ip(request), commit=True,
|
|
||||||
)
|
|
||||||
return ReconcileResult(**result)
|
|
||||||
|
|
||||||
|
|
||||||
def _bulk_result(items: list[WithdrawBulkItemResult]) -> WithdrawBulkResult:
|
def _bulk_result(items: list[WithdrawBulkItemResult]) -> WithdrawBulkResult:
|
||||||
success = sum(1 for item in items if item.ok)
|
success = sum(1 for item in items if item.ok)
|
||||||
return WithdrawBulkResult(
|
return WithdrawBulkResult(
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ class AdRevenueDaily(BaseModel):
|
|||||||
|
|
||||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||||
impressions: int = Field(..., description="当天展示条数合计")
|
impressions: int = Field(..., description="当天展示条数合计")
|
||||||
revenue_yuan: float = Field(..., description="当天客户端 SDK 展示预估合计(元;后端留存 eCPM 折算)")
|
revenue_yuan: float = Field(..., description="当天客户端有效预估收益合计(元;eCPM 折算)")
|
||||||
pangle_revenue_yuan: float | None = Field(
|
pangle_revenue_yuan: float | None = Field(
|
||||||
None, description="当天 GroMore 排序价预估(元;revenue,非结算收入);非全量视图/无数据为空"
|
None, description="当天穿山甲后台预估收益(元;GroMore revenue);非全量视图/无数据为空"
|
||||||
)
|
)
|
||||||
pangle_api_revenue_yuan: float | None = Field(
|
pangle_api_revenue_yuan: float | None = Field(
|
||||||
None, description="当天 ADN Reporting API 收益(元;GroMore api_revenue);未配/当天/无数据为空"
|
None, description="当天穿山甲收益Api(元;GroMore api_revenue,更接近结算);未配/当天/无数据为空"
|
||||||
)
|
)
|
||||||
expected_coin: int = Field(..., description="当天应发金币合计")
|
expected_coin: int = Field(..., description="当天应发金币合计")
|
||||||
actual_coin: int = Field(..., description="当天实发金币合计")
|
actual_coin: int = Field(..., description="当天实发金币合计")
|
||||||
@@ -71,11 +71,11 @@ class AdRevenueHourly(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AdRevenueTypeStat(BaseModel):
|
class AdRevenueTypeStat(BaseModel):
|
||||||
"""展示条数、SDK 展示预估收益与按展示次数加权的 SDK eCPM。"""
|
"""按广告类型(ad_type)的小计:展示条数 + 预估收益(eCPM 由前端用 收益÷展示×1000 算)。"""
|
||||||
|
|
||||||
impressions: int = Field(..., description="该类型展示条数合计")
|
impressions: int = Field(..., description="该类型展示条数合计")
|
||||||
revenue_yuan: float = Field(..., description="该类型预估收益合计(元)")
|
revenue_yuan: float = Field(..., description="该类型预估收益合计(元)")
|
||||||
ecpm_yuan: float = Field(..., description="按展示次数加权的 SDK eCPM(元/千次)")
|
ecpm_yuan: float | None = Field(None, description="按真实展示次数加权的 SDK eCPM(元/千次)")
|
||||||
|
|
||||||
|
|
||||||
class AdRevenueRow(BaseModel):
|
class AdRevenueRow(BaseModel):
|
||||||
@@ -101,15 +101,15 @@ class AdRevenueRow(BaseModel):
|
|||||||
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
|
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
|
||||||
revenue_yuan: float = Field(
|
revenue_yuan: float = Field(
|
||||||
...,
|
...,
|
||||||
description="本次 SDK 展示预估收益(元)=后端留存 eCPM 元 ÷ 1000;是否满足发奖条件不改变展示收入预估",
|
description="本次有效展示预估收益(元)= eCPM元 ÷ 1000;纯发奖、激励视频提前关闭/时长不足=0",
|
||||||
)
|
)
|
||||||
row_revenue_yuan: float | None = Field(
|
row_revenue_yuan: float | None = Field(
|
||||||
None,
|
None,
|
||||||
description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;"
|
description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;"
|
||||||
"其它行为空(前端回退取 revenue_yuan)。不进合计/趋势,避免与展示侧重复计",
|
"其它行为空(前端回退取 revenue_yuan)。不进合计/趋势,避免与展示侧重复计",
|
||||||
)
|
)
|
||||||
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);历史或未上报展示来源为空")
|
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);纯发奖行为空")
|
||||||
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);历史或未上报展示来源为空")
|
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空")
|
||||||
# ── 发奖侧 ──
|
# ── 发奖侧 ──
|
||||||
has_reward: bool = Field(..., description="是否有发奖记录(激励视频合并行 / 信息流整场发奖行=True;纯展示=False)")
|
has_reward: bool = Field(..., description="是否有发奖记录(激励视频合并行 / 信息流整场发奖行=True;纯展示=False)")
|
||||||
status: str | None = Field(None, description="发奖状态 granted/closed_early/too_short/…;纯展示为空")
|
status: str | None = Field(None, description="发奖状态 granted/closed_early/too_short/…;纯展示为空")
|
||||||
@@ -143,7 +143,7 @@ class AdRevenueReportOut(BaseModel):
|
|||||||
)
|
)
|
||||||
type_stats: dict[str, AdRevenueTypeStat] = Field(
|
type_stats: dict[str, AdRevenueTypeStat] = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="按原始广告类型(ad_type)小计,供筛选与排查使用",
|
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
|
||||||
)
|
)
|
||||||
category_stats: dict[str, AdRevenueTypeStat] = Field(
|
category_stats: dict[str, AdRevenueTypeStat] = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
@@ -163,28 +163,20 @@ class AdRevenueReportOut(BaseModel):
|
|||||||
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
||||||
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
||||||
total_impressions: int = Field(..., description="全量展示条数合计")
|
total_impressions: int = Field(..., description="全量展示条数合计")
|
||||||
total_revenue_yuan: float = Field(..., description="全量客户端 SDK 展示预估合计(元;后端留存 eCPM 折算)")
|
total_revenue_yuan: float = Field(..., description="全量客户端有效预估收益合计(元;eCPM 折算)")
|
||||||
total_pangle_revenue_yuan: float | None = Field(
|
total_pangle_revenue_yuan: float | None = Field(
|
||||||
None,
|
None,
|
||||||
description="全量 GroMore 排序价预估合计(元;revenue,非结算收入)。GroMore 无用户/类型/场景维度,"
|
description="全量穿山甲后台预估收益合计(元;GroMore revenue)。穿山甲无用户/类型/场景维度,"
|
||||||
"仅「全量视图」(未按 user_id/ad_type/feed_scene 过滤)时有值,否则为 null",
|
"仅「全量视图」(未按 user_id/ad_type/feed_scene 过滤)时有值,否则为 null",
|
||||||
)
|
)
|
||||||
total_pangle_api_revenue_yuan: float | None = Field(
|
total_pangle_api_revenue_yuan: float | None = Field(
|
||||||
None,
|
None,
|
||||||
description="全量 ADN Reporting API 收益合计(元;GroMore api_revenue,仅已配置回传的 ADN);"
|
description="全量穿山甲收益Api合计(元;GroMore api_revenue,各 ADN 回传、更接近结算);"
|
||||||
"未配 Reporting / 查当天 / 非全量视图 时为 null",
|
"未配 Reporting / 查当天 / 非全量视图 时为 null",
|
||||||
)
|
)
|
||||||
pangle_api_revenue_complete: bool = Field(
|
|
||||||
False,
|
|
||||||
description="所选每一天是否都已在 D+1 14:00 后完成 API 同步窗口;不代表覆盖全部 ADN 或最终结算",
|
|
||||||
)
|
|
||||||
pangle_latest_synced_at: datetime | None = Field(
|
|
||||||
None,
|
|
||||||
description="所选范围穿山甲/GroMore 日报最近同步时间",
|
|
||||||
)
|
|
||||||
pangle_revenue_available: bool = Field(
|
pangle_revenue_available: bool = Field(
|
||||||
False,
|
False,
|
||||||
description="本次结果是否带 GroMore/ADN 收益(=全量视图且已同步到数据)。false 时前端显示「-」",
|
description="本次结果是否带穿山甲后台收益(=全量视图且已同步到数据)。false 时前端「穿山甲收益」显示「-」",
|
||||||
)
|
)
|
||||||
total_expected_coin: int = Field(..., description="全量应发金币合计")
|
total_expected_coin: int = Field(..., description="全量应发金币合计")
|
||||||
total_actual_coin: int = Field(..., description="全量实发金币合计")
|
total_actual_coin: int = Field(..., description="全量实发金币合计")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class AdminComparisonListItem(BaseModel):
|
|||||||
source_price_cents: int | None = None
|
source_price_cents: int | None = None
|
||||||
best_price_cents: int | None = None
|
best_price_cents: int | None = None
|
||||||
saved_amount_cents: int | None = None
|
saved_amount_cents: int | None = None
|
||||||
|
ordered: bool = False
|
||||||
# debug 概览
|
# debug 概览
|
||||||
total_ms: int | None = None
|
total_ms: int | None = None
|
||||||
step_count: int | None = None
|
step_count: int | None = None
|
||||||
@@ -39,8 +40,10 @@ class AdminComparisonListItem(BaseModel):
|
|||||||
# 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。
|
# 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。
|
||||||
llm_cost_yuan: float | None = None
|
llm_cost_yuan: float | None = None
|
||||||
device_model: str | None = None
|
device_model: str | None = None
|
||||||
|
device_model_name: str | None = None
|
||||||
rom_vendor: str | None = None
|
rom_vendor: str | None = None
|
||||||
rom_name: str | None = None
|
rom_name: str | None = None
|
||||||
|
rom_version: int | None = None
|
||||||
android_version: str | None = None
|
android_version: str | None = None
|
||||||
app_version: str | None = None
|
app_version: str | None = None
|
||||||
ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
|
ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
|
||||||
@@ -84,7 +87,6 @@ class AdminComparisonDetail(AdminComparisonListItem):
|
|||||||
skipped_dish_names: list = []
|
skipped_dish_names: list = []
|
||||||
# 全量环境
|
# 全量环境
|
||||||
device_manufacturer: str | None = None
|
device_manufacturer: str | None = None
|
||||||
rom_version: int | None = None
|
|
||||||
android_sdk: int | None = None
|
android_sdk: int | None = None
|
||||||
app_version_code: int | None = None
|
app_version_code: int | None = None
|
||||||
source_app_version: str | None = None
|
source_app_version: str | None = None
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ class FeedbackOut(BaseModel):
|
|||||||
# 提交端环境快照(feedback 表列):提交版本号 / 机型OS版本;改版前的历史反馈为 None
|
# 提交端环境快照(feedback 表列):提交版本号 / 机型OS版本;改版前的历史反馈为 None
|
||||||
app_version: str | None = None
|
app_version: str | None = None
|
||||||
device_model: str | None = None
|
device_model: str | None = None
|
||||||
|
device_model_name: str | None = None
|
||||||
|
device_manufacturer: str | None = None
|
||||||
rom_name: str | None = None
|
rom_name: str | None = None
|
||||||
|
rom_version: int | None = None
|
||||||
android_version: str | None = None
|
android_version: str | None = None
|
||||||
# 联表瞬态字段(queries._attach_user_info 挂):列表展示完整手机号,点手机号查该用户全部反馈
|
# 联表瞬态字段(queries._attach_user_info 挂):列表展示完整手机号,点手机号查该用户全部反馈
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
|
|||||||
@@ -1,25 +1,44 @@
|
|||||||
"""admin 新手引导视频配置 schemas(开关 / 视频地址 / 前几次 / 每次金币)。"""
|
"""后台引导视频配置协议。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
from app.repositories.guide_video import MAX_PLAYS_LIMIT, REWARD_COIN_LIMIT
|
from app.repositories.guide_video import (
|
||||||
|
MAX_PLAYS_LIMIT,
|
||||||
|
MIN_PLAYS,
|
||||||
|
MIN_REWARD_COIN,
|
||||||
|
REWARD_COIN_LIMIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GuideVideoConfigOut(BaseModel):
|
class GuideVideoConfigOut(BaseModel):
|
||||||
|
scene: str
|
||||||
enabled: bool
|
enabled: bool
|
||||||
video_url: str | None = None # 相对地址 /media/guide_video/xxx.mp4;未配片 = None
|
video_url: str | None = None
|
||||||
max_plays: int
|
max_plays: int
|
||||||
reward_coin: int
|
reward_coin: int
|
||||||
|
duration_ms: int | None = None
|
||||||
|
circle_count: int = 10
|
||||||
|
circle_duration_ms: float | None = None
|
||||||
|
reward_per_circle: int
|
||||||
|
video_codec: str | None = None
|
||||||
|
audio_codec: str | None = None
|
||||||
|
analysis_status: str
|
||||||
|
analysis_error: str | None = None
|
||||||
|
config_version: int
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
# 只读统计,后台展示用:已有多少次播放、其中已发币多少次。
|
|
||||||
total_plays: int = 0
|
|
||||||
granted_plays: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class GuideVideoConfigUpdate(BaseModel):
|
class GuideVideoConfigUpdate(BaseModel):
|
||||||
"""部分更新:只改传入(非 None)字段。视频文件走 /video 上传接口。"""
|
|
||||||
|
|
||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
max_plays: int | None = Field(default=None, ge=0, le=MAX_PLAYS_LIMIT)
|
max_plays: int | None = Field(default=None, ge=MIN_PLAYS, le=MAX_PLAYS_LIMIT)
|
||||||
reward_coin: int | None = Field(default=None, ge=0, le=REWARD_COIN_LIMIT)
|
reward_coin: int | None = Field(
|
||||||
|
default=None, ge=MIN_REWARD_COIN, le=REWARD_COIN_LIMIT
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("reward_coin")
|
||||||
|
@classmethod
|
||||||
|
def reward_must_be_multiple_of_ten(cls, value: int | None) -> int | None:
|
||||||
|
if value is not None and value % 10:
|
||||||
|
raise ValueError("金币总价必须是 10 的倍数")
|
||||||
|
return value
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Admin contracts for global limit rules and per-subject overrides."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
SubjectType = Literal["phone", "device"]
|
||||||
|
PolicyMode = Literal["unlimited", "suppress_alert"]
|
||||||
|
|
||||||
|
|
||||||
|
class LimitRuleOut(BaseModel):
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
group: str
|
||||||
|
global_limit: int
|
||||||
|
default_limit: int
|
||||||
|
window_label: str
|
||||||
|
subject_types: list[str]
|
||||||
|
allowed_modes: list[str]
|
||||||
|
min_value: int
|
||||||
|
max_value: int
|
||||||
|
supports_reset: bool
|
||||||
|
alert_only: bool
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalLimitUpdate(BaseModel):
|
||||||
|
value: int = Field(ge=0, le=1_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCandidateOut(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
source: str
|
||||||
|
source_label: str
|
||||||
|
user_id: int | None = None
|
||||||
|
username: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
nickname: str | None = None
|
||||||
|
device_model: str | None = None
|
||||||
|
last_active_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class LimitOverrideWrite(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
subject_type: SubjectType
|
||||||
|
subject_value: str = Field(min_length=1, max_length=128)
|
||||||
|
rule_code: str = Field(min_length=1, max_length=64)
|
||||||
|
mode: PolicyMode
|
||||||
|
enabled: bool = True
|
||||||
|
starts_at: datetime | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
reason: str = Field("", max_length=256)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_time_range(self):
|
||||||
|
if self.starts_at and self.expires_at and self.expires_at <= self.starts_at:
|
||||||
|
raise ValueError("失效时间必须晚于生效时间")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class LimitOverrideBulkWrite(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
subject_type: SubjectType
|
||||||
|
subject_value: str = Field(min_length=1, max_length=128)
|
||||||
|
rule_codes: list[str] = Field(min_length=1, max_length=32)
|
||||||
|
enabled: bool = True
|
||||||
|
starts_at: datetime | None = None
|
||||||
|
expires_at: datetime
|
||||||
|
reason: str = Field("", max_length=256)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_bulk_request(self):
|
||||||
|
self.rule_codes = list(dict.fromkeys(self.rule_codes))
|
||||||
|
starts_at = (
|
||||||
|
self.starts_at.replace(tzinfo=UTC)
|
||||||
|
if self.starts_at and self.starts_at.tzinfo is None
|
||||||
|
else self.starts_at
|
||||||
|
)
|
||||||
|
expires_at = (
|
||||||
|
self.expires_at.replace(tzinfo=UTC)
|
||||||
|
if self.expires_at.tzinfo is None
|
||||||
|
else self.expires_at
|
||||||
|
)
|
||||||
|
if starts_at and expires_at <= starts_at:
|
||||||
|
raise ValueError("失效时间必须晚于生效时间")
|
||||||
|
if expires_at <= datetime.now(UTC):
|
||||||
|
raise ValueError("失效时间必须晚于当前时间")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class LimitOverridePatch(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
enabled: bool | None = None
|
||||||
|
starts_at: datetime | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
reason: str | None = Field(None, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class LimitSubjectEnabledPatch(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
subject_type: SubjectType
|
||||||
|
subject_value: str = Field(min_length=1, max_length=128)
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class LimitOverrideOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
subject_type: str
|
||||||
|
subject_value: str
|
||||||
|
rule_code: str
|
||||||
|
rule_label: str
|
||||||
|
rule_group: str
|
||||||
|
mode: str
|
||||||
|
limit_value: int | None
|
||||||
|
global_limit: int
|
||||||
|
effective_limit: int | None
|
||||||
|
enabled: bool
|
||||||
|
starts_at: datetime | None
|
||||||
|
expires_at: datetime | None
|
||||||
|
reset_at: datetime | None
|
||||||
|
reason: str | None
|
||||||
|
status: str
|
||||||
|
created_by_admin_id: int | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class LimitOverrideList(BaseModel):
|
||||||
|
items: list[LimitOverrideOut]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class LimitSubjectOut(BaseModel):
|
||||||
|
subject_type: str
|
||||||
|
subject_value: str
|
||||||
|
group_counts: dict[str, int]
|
||||||
|
total_rules: int
|
||||||
|
items: list[LimitOverrideOut]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class LimitSubjectList(BaseModel):
|
||||||
|
items: list[LimitSubjectOut]
|
||||||
|
total: int
|
||||||
@@ -21,9 +21,9 @@ class RiskMonitorSummary(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class RiskRuleConfig(BaseModel):
|
class RiskRuleConfig(BaseModel):
|
||||||
sms_hourly_threshold: int = Field(ge=1, le=5)
|
sms_hourly_threshold: int = Field(ge=1, le=100_000)
|
||||||
oneclick_daily_threshold: int = Field(ge=1, le=100_000)
|
oneclick_daily_threshold: int = Field(ge=1, le=100_000)
|
||||||
compare_daily_threshold: int = Field(ge=1, le=100)
|
compare_daily_threshold: int = Field(ge=1, le=100_000)
|
||||||
|
|
||||||
|
|
||||||
class RiskIncidentItem(BaseModel):
|
class RiskIncidentItem(BaseModel):
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class AdminUserListItem(BaseModel):
|
|||||||
register_channel: str
|
register_channel: str
|
||||||
status: str
|
status: str
|
||||||
debug_trace_enabled: bool = False
|
debug_trace_enabled: bool = False
|
||||||
|
is_high_risk: bool = False
|
||||||
|
high_risk_note: str | None = None
|
||||||
wechat_openid: str | None = None
|
wechat_openid: str | None = None
|
||||||
wechat_nickname: str | None = None
|
wechat_nickname: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -58,7 +60,7 @@ class UserRewardStats(BaseModel):
|
|||||||
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
|
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
|
||||||
reward_video_cash_cents: int # 激励视频提现(金币折现)
|
reward_video_cash_cents: int # 激励视频提现(金币折现)
|
||||||
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
|
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
|
||||||
feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次)
|
feed_avg_ecpm: float # 全部 Draw/feed 实际展示的平均 eCPM(分/千次,含未发奖展示)
|
||||||
feed_cash_cents: int # 信息流广告提现(金币折现)
|
feed_cash_cents: int # 信息流广告提现(金币折现)
|
||||||
|
|
||||||
|
|
||||||
@@ -118,3 +120,19 @@ class SetUserStatusRequest(BaseModel):
|
|||||||
|
|
||||||
class SetDebugTraceRequest(BaseModel):
|
class SetDebugTraceRequest(BaseModel):
|
||||||
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
|
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
|
||||||
|
|
||||||
|
|
||||||
|
class SetUserRiskRequest(BaseModel):
|
||||||
|
is_high_risk: bool = Field(..., description="是否标记为高风险用户")
|
||||||
|
note: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=500,
|
||||||
|
description="高风险备注;标记高风险时必填,解除后清空",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("note")
|
||||||
|
@classmethod
|
||||||
|
def validate_note(cls, value: str | None, info):
|
||||||
|
if info.data.get("is_high_risk") and not (value or "").strip():
|
||||||
|
raise ValueError("标记高风险时必须填写原因")
|
||||||
|
return value.strip() if value and value.strip() else None
|
||||||
|
|||||||
+27
-25
@@ -60,6 +60,8 @@ class WithdrawListItemOut(WithdrawOrderOut):
|
|||||||
|
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
nickname: str | None = None
|
nickname: str | None = None
|
||||||
|
is_high_risk: bool = False
|
||||||
|
high_risk_note: str | None = None
|
||||||
cumulative_success_cents: int = 0 # 累计成功提现 = SUM(amount_cents) WHERE status='success'
|
cumulative_success_cents: int = 0 # 累计成功提现 = SUM(amount_cents) WHERE status='success'
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +69,10 @@ class WithdrawSummaryOut(BaseModel):
|
|||||||
reviewing_count: int
|
reviewing_count: int
|
||||||
reviewing_amount_cents: int
|
reviewing_amount_cents: int
|
||||||
pending_count: int
|
pending_count: int
|
||||||
|
success_count: int
|
||||||
|
rejected_count: int
|
||||||
failed_count: int
|
failed_count: int
|
||||||
|
total_count: int
|
||||||
today_success_count: int
|
today_success_count: int
|
||||||
today_success_amount_cents: int
|
today_success_amount_cents: int
|
||||||
today_rejected_count: int
|
today_rejected_count: int
|
||||||
@@ -77,6 +82,8 @@ class WithdrawUserSnapshot(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
phone: str
|
phone: str
|
||||||
nickname: str | None = None
|
nickname: str | None = None
|
||||||
|
is_high_risk: bool = False
|
||||||
|
high_risk_note: str | None = None
|
||||||
status: str
|
status: str
|
||||||
wechat_nickname: str | None = None
|
wechat_nickname: str | None = None
|
||||||
wechat_avatar_url: str | None = None
|
wechat_avatar_url: str | None = None
|
||||||
@@ -87,19 +94,34 @@ class WithdrawUserSnapshot(BaseModel):
|
|||||||
withdraw_success_cents: int
|
withdraw_success_cents: int
|
||||||
|
|
||||||
|
|
||||||
|
class InviteeDetailOut(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
phone: str
|
||||||
|
registered_at: datetime
|
||||||
|
invite_success: bool
|
||||||
|
first_compare_store: str | None = None
|
||||||
|
first_compare_products: str | None = None
|
||||||
|
first_order_store: str | None = None
|
||||||
|
first_order_products: str | None = None
|
||||||
|
first_order_amount_cents: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InviteOverviewOut(BaseModel):
|
||||||
|
invite_total: int
|
||||||
|
invite_success_total: int
|
||||||
|
items: list[InviteeDetailOut]
|
||||||
|
|
||||||
|
|
||||||
class WithdrawDetailOut(BaseModel):
|
class WithdrawDetailOut(BaseModel):
|
||||||
order: WithdrawOrderOut
|
order: WithdrawOrderOut
|
||||||
user: WithdrawUserSnapshot | None = None
|
user: WithdrawUserSnapshot | None = None
|
||||||
|
cumulative_success_cents: int = 0
|
||||||
risk_flags: list[str]
|
risk_flags: list[str]
|
||||||
risk_score: int
|
risk_score: int
|
||||||
recent_withdraws: list[WithdrawOrderOut]
|
recent_withdraws: list[WithdrawOrderOut]
|
||||||
recent_cash_transactions: list[CashTxnOut]
|
recent_cash_transactions: list[CashTxnOut]
|
||||||
audit_logs: list[AdminAuditLogOut]
|
audit_logs: list[AdminAuditLogOut]
|
||||||
|
invite_overview: InviteOverviewOut | None = None
|
||||||
|
|
||||||
class ReconcileResult(BaseModel):
|
|
||||||
checked: int
|
|
||||||
resolved: int
|
|
||||||
|
|
||||||
|
|
||||||
class WithdrawBulkRequest(BaseModel):
|
class WithdrawBulkRequest(BaseModel):
|
||||||
@@ -128,26 +150,6 @@ class WithdrawBulkResult(BaseModel):
|
|||||||
items: list[WithdrawBulkItemResult]
|
items: list[WithdrawBulkItemResult]
|
||||||
|
|
||||||
|
|
||||||
class WithdrawLedgerCheckOut(BaseModel):
|
|
||||||
ok: bool
|
|
||||||
# 普通现金账(coin_cash:金币兑换的现金)
|
|
||||||
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
|
|
||||||
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)。默认 0 向后兼容。
|
|
||||||
invite_cash_balance_total_cents: int = 0
|
|
||||||
invite_cash_transaction_total_cents: int = 0
|
|
||||||
invite_balance_diff_cents: int = 0
|
|
||||||
invite_missing_withdraw_txn_count: int = 0
|
|
||||||
invite_missing_refund_txn_count: int = 0
|
|
||||||
invite_duplicate_refund_txn_count: int = 0
|
|
||||||
invite_refund_txn_on_non_terminal_count: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class WxpayHealthCheckOut(BaseModel):
|
class WxpayHealthCheckOut(BaseModel):
|
||||||
ok: bool
|
ok: bool
|
||||||
wxpay_configured: bool
|
wxpay_configured: bool
|
||||||
|
|||||||
+13
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -77,6 +77,18 @@ def get_current_user_optional(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
"""客户端真实 IP:生产经 nginx 反代优先 X-Forwarded-For 首段,否则直连 IP。
|
||||||
|
|
||||||
|
路由层取 IP 的规范实现(analytics / applog 等共用);core 层(ratelimit)因不能
|
||||||
|
反向依赖 app.api,自留一份私有副本。
|
||||||
|
"""
|
||||||
|
xff = request.headers.get("x-forwarded-for")
|
||||||
|
if xff:
|
||||||
|
return xff.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else ""
|
||||||
|
|
||||||
|
|
||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
OptionalUser = Annotated[User | None, Depends(get_current_user_optional)]
|
OptionalUser = Annotated[User | None, Depends(get_current_user_optional)]
|
||||||
DbSession = Annotated[Session, Depends(get_db)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|||||||
+14
-4
@@ -18,7 +18,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, DbSession
|
from app.api.deps import CurrentUser, DbSession
|
||||||
from app.core import rewards
|
from app.core import limit_policy, rewards
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.ratelimit import rate_limit
|
from app.core.ratelimit import rate_limit
|
||||||
from app.integrations import pangle
|
from app.integrations import pangle
|
||||||
@@ -289,8 +289,7 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
|
|||||||
"""客户端在广告展示后(onAdShow 读 getShowEcpm)上报 eCPM,落库做内部收益统计/对账。
|
"""客户端在广告展示后(onAdShow 读 getShowEcpm)上报 eCPM,落库做内部收益统计/对账。
|
||||||
|
|
||||||
Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget,
|
Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget,
|
||||||
丢一两条不影响发奖业务(收入另由 ADN Reporting API 对账)。eCPM 与发奖(S2S)是两条独立流,
|
丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。
|
||||||
不逐条关联。
|
|
||||||
"""
|
"""
|
||||||
attributed_trace_id = crud_ecpm.attributable_trace_id(
|
attributed_trace_id = crud_ecpm.attributable_trace_id(
|
||||||
db,
|
db,
|
||||||
@@ -414,12 +413,23 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
|
|||||||
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
|
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
|
||||||
user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin,
|
user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin,
|
||||||
)
|
)
|
||||||
|
feed_policy = limit_policy.resolve_for_user(db, "ad.feed.daily", user.id)
|
||||||
|
feed_limit = (
|
||||||
|
rewards.get_ad_daily_limit(db)
|
||||||
|
if feed_policy.override_id is None
|
||||||
|
and feed_policy.bucket_version == "default"
|
||||||
|
else feed_policy.limit
|
||||||
|
)
|
||||||
return FeedRewardOut(
|
return FeedRewardOut(
|
||||||
granted=(rec.status == "granted"),
|
granted=(rec.status == "granted"),
|
||||||
status=rec.status,
|
status=rec.status,
|
||||||
coin=rec.coin,
|
coin=rec.coin,
|
||||||
unit_count=rec.unit_count,
|
unit_count=rec.unit_count,
|
||||||
daily_limit=rewards.get_ad_daily_limit(db),
|
daily_limit=(
|
||||||
|
feed_limit
|
||||||
|
if feed_limit is not None
|
||||||
|
else limit_policy.get_rule("ad.feed.daily").max_value
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -10,7 +10,7 @@ import logging
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
from app.api.deps import DbSession
|
from app.api.deps import DbSession, get_client_ip
|
||||||
from app.repositories import analytics as analytics_repo
|
from app.repositories import analytics as analytics_repo
|
||||||
from app.repositories import analytics_selfstat as selfstat_repo
|
from app.repositories import analytics_selfstat as selfstat_repo
|
||||||
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
|
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
|
||||||
@@ -20,19 +20,11 @@ router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
|
|||||||
logger = logging.getLogger("shagua.analytics")
|
logger = logging.getLogger("shagua.analytics")
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str:
|
|
||||||
"""取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 第一段,否则直连 IP(同 admin get_client_ip)。"""
|
|
||||||
xff = request.headers.get("x-forwarded-for")
|
|
||||||
if xff:
|
|
||||||
return xff.split(",")[0].strip()
|
|
||||||
return request.client.host if request.client else ""
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/events", response_model=AnalyticsIngestOut, summary="批量上报埋点事件")
|
@router.post("/events", response_model=AnalyticsIngestOut, summary="批量上报埋点事件")
|
||||||
def ingest_events(
|
def ingest_events(
|
||||||
batch: AnalyticsBatchIn, request: Request, db: DbSession
|
batch: AnalyticsBatchIn, request: Request, db: DbSession
|
||||||
) -> AnalyticsIngestOut:
|
) -> AnalyticsIngestOut:
|
||||||
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
|
n = analytics_repo.record_batch(db, batch, client_ip=get_client_ip(request))
|
||||||
return AnalyticsIngestOut(received=n)
|
return AnalyticsIngestOut(received=n)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""客户端运行日志批量上报接口。
|
||||||
|
|
||||||
|
POST /api/v1/applog/batch — 批量接收客户端运行日志,逐条写专用滚动文件 logs/app-client.log
|
||||||
|
(供 Logtail 采进独立 SLS logstore)。鉴权同 analytics(不强制登录,user_id 可选在 body)。
|
||||||
|
fire-and-forget:写失败也不 500(避免客户端重试风暴);超批 422、超体积 413、msg 超限截断。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
|
from app.api.deps import get_client_ip
|
||||||
|
from app.core.client_log import write_records
|
||||||
|
from app.core.ratelimit import rate_limit
|
||||||
|
from app.schemas.applog import AppLogBatchIn, AppLogIngestOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/applog", tags=["applog"])
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_body_limit(request: Request) -> None:
|
||||||
|
"""依赖:body 声明过大直接 413(在 body 校验前拦截)。缺 Content-Length 由 nginx 兜底。"""
|
||||||
|
max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024 * 2)))
|
||||||
|
cl = request.headers.get("content-length")
|
||||||
|
if cl is not None and cl.isdigit() and int(cl) > max_bytes:
|
||||||
|
raise HTTPException(status_code=413, detail="日志批量过大")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/batch",
|
||||||
|
response_model=AppLogIngestOut,
|
||||||
|
summary="批量上报客户端运行日志",
|
||||||
|
dependencies=[
|
||||||
|
Depends(rate_limit(120, 60, "applog-batch")),
|
||||||
|
Depends(_enforce_body_limit),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def ingest_logs(batch: AppLogBatchIn, request: Request) -> AppLogIngestOut:
|
||||||
|
received, dropped = write_records(
|
||||||
|
batch.logs,
|
||||||
|
meta={
|
||||||
|
"device_id": batch.device_id,
|
||||||
|
"user_id": batch.user_id,
|
||||||
|
"app_ver": batch.app_ver,
|
||||||
|
"platform": batch.platform,
|
||||||
|
"sent_at": batch.sent_at,
|
||||||
|
},
|
||||||
|
client_ip=get_client_ip(request),
|
||||||
|
)
|
||||||
|
return AppLogIngestOut(received=received, dropped=dropped)
|
||||||
+199
-28
@@ -16,7 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, DbSession
|
from app.api.deps import CurrentUser, DbSession
|
||||||
from app.core import test_account
|
from app.core import limit_policy, test_account
|
||||||
from app.core.ratelimit import (
|
from app.core.ratelimit import (
|
||||||
RateLimitRule,
|
RateLimitRule,
|
||||||
check_rate_limits,
|
check_rate_limits,
|
||||||
@@ -69,6 +69,7 @@ SMS_LOGIN_MAX_PER_HOUR = 5
|
|||||||
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
|
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
|
||||||
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限
|
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限
|
||||||
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸)
|
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸)
|
||||||
|
UNLIMITED_VERIFY_ATTEMPTS = 2_147_483_647
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str:
|
def _client_ip(request: Request) -> str:
|
||||||
@@ -198,16 +199,54 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes
|
|||||||
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
|
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
|
||||||
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
|
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
|
||||||
# (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
|
# (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
|
||||||
|
hourly_policy = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
"sms.send.hourly",
|
||||||
|
phone=req.phone,
|
||||||
|
device=subject_id,
|
||||||
|
)
|
||||||
|
daily_policy = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
"sms.send.daily",
|
||||||
|
phone=req.phone,
|
||||||
|
device=subject_id,
|
||||||
|
)
|
||||||
|
cooldown_policy = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
"sms.phone.cooldown",
|
||||||
|
phone=req.phone,
|
||||||
|
)
|
||||||
|
hourly_limit = (
|
||||||
|
SMS_SEND_MAX_PER_HOUR_PER_DEVICE
|
||||||
|
if hourly_policy.override_id is None
|
||||||
|
and hourly_policy.bucket_version == "default"
|
||||||
|
else hourly_policy.limit
|
||||||
|
)
|
||||||
|
daily_limit = (
|
||||||
|
SMS_SEND_MAX_PER_DAY_PER_DEVICE
|
||||||
|
if daily_policy.override_id is None
|
||||||
|
and daily_policy.bucket_version == "default"
|
||||||
|
else daily_policy.limit
|
||||||
|
)
|
||||||
send_rules = [
|
send_rules = [
|
||||||
RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600,
|
RateLimitRule("sms-send-device", hourly_limit, 3600,
|
||||||
"操作过于频繁,请稍后再试"),
|
"操作过于频繁,请稍后再试", hourly_policy.bucket_version),
|
||||||
RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400,
|
RateLimitRule("sms-send-device-daily", daily_limit, 86400,
|
||||||
"今日验证码发送次数过多,请明天再试"),
|
"今日验证码发送次数过多,请明天再试", daily_policy.bucket_version),
|
||||||
]
|
]
|
||||||
check_rate_limits(request, subject=req.device_id, rules=send_rules)
|
check_rate_limits(request, subject=subject_id, rules=send_rules)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cooldown = send_code(req.phone)
|
send_result = send_code(
|
||||||
|
req.phone,
|
||||||
|
cooldown_sec=(
|
||||||
|
None
|
||||||
|
if cooldown_policy.override_id is None
|
||||||
|
and cooldown_policy.bucket_version == "default"
|
||||||
|
else (0 if cooldown_policy.limit is None else cooldown_policy.limit)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cooldown = send_result.cooldown_sec
|
||||||
except SmsError as e:
|
except SmsError as e:
|
||||||
risk_repo.record_behavior_event(
|
risk_repo.record_behavior_event(
|
||||||
db,
|
db,
|
||||||
@@ -224,7 +263,7 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes
|
|||||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||||
|
|
||||||
# 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
|
# 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
|
||||||
record_rate_limits(request, subject=req.device_id, rules=send_rules)
|
record_rate_limits(request, subject=subject_id, rules=send_rules)
|
||||||
|
|
||||||
from app.core.config import settings # 局部 import 避免循环
|
from app.core.config import settings # 局部 import 避免循环
|
||||||
|
|
||||||
@@ -238,7 +277,9 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes
|
|||||||
phone=req.phone,
|
phone=req.phone,
|
||||||
client_ip=_client_ip(request),
|
client_ip=_client_ip(request),
|
||||||
outcome="success",
|
outcome="success",
|
||||||
details={"mock": settings.SMS_MOCK},
|
details={"mock": settings.SMS_MOCK,
|
||||||
|
"provider": send_result.provider,
|
||||||
|
"fallback": send_result.fallback},
|
||||||
evaluate_rule=risk_repo.RULE_SMS_HOURLY,
|
evaluate_rule=risk_repo.RULE_SMS_HOURLY,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -256,7 +297,7 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
|||||||
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
|
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
|
||||||
# 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。
|
# 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。
|
||||||
if test_account.is_test_account(req.phone):
|
if test_account.is_test_account(req.phone):
|
||||||
if not test_account.try_consume_quota():
|
if not test_account.try_consume_quota(req.phone):
|
||||||
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
|
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
|
||||||
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
|
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
|
||||||
risk_repo.record_behavior_event(
|
risk_repo.record_behavior_event(
|
||||||
@@ -283,16 +324,52 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
|||||||
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)。
|
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)。
|
||||||
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
|
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
|
||||||
# 退化为该 IP 下所有空设备聚一桶,仍受限。
|
# 退化为该 IP 下所有空设备聚一桶,仍受限。
|
||||||
|
login_policy = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
"sms.login.hourly",
|
||||||
|
phone=req.phone,
|
||||||
|
device=subject_id,
|
||||||
|
)
|
||||||
|
verify_policy = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
"sms.code.failed_attempts",
|
||||||
|
phone=req.phone,
|
||||||
|
)
|
||||||
|
login_limit = (
|
||||||
|
SMS_LOGIN_MAX_PER_HOUR
|
||||||
|
if login_policy.override_id is None
|
||||||
|
and login_policy.bucket_version == "default"
|
||||||
|
else login_policy.limit
|
||||||
|
)
|
||||||
enforce_rate_limit(
|
enforce_rate_limit(
|
||||||
request,
|
request,
|
||||||
scope="sms-login-device",
|
scope="sms-login-device",
|
||||||
subject=req.device_id,
|
subject=subject_id,
|
||||||
limit=SMS_LOGIN_MAX_PER_HOUR,
|
limit=login_limit,
|
||||||
window_sec=3600,
|
window_sec=3600,
|
||||||
detail="登录尝试过于频繁,请稍后再试",
|
detail="登录尝试过于频繁,请稍后再试",
|
||||||
|
bucket_suffix=login_policy.bucket_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not verify_code(req.phone, req.code):
|
try:
|
||||||
|
ok = verify_code(
|
||||||
|
req.phone,
|
||||||
|
req.code,
|
||||||
|
max_failed_attempts=(
|
||||||
|
None
|
||||||
|
if verify_policy.override_id is None
|
||||||
|
and verify_policy.bucket_version == "default"
|
||||||
|
else (
|
||||||
|
UNLIMITED_VERIFY_ATTEMPTS
|
||||||
|
if verify_policy.limit is None
|
||||||
|
else verify_policy.limit
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」
|
||||||
|
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||||
|
if not ok:
|
||||||
|
# 校验码错误才记风控失败事件(provider 降级 503 已在上面提前 raise,不算「验证失败」)
|
||||||
risk_repo.record_behavior_event(
|
risk_repo.record_behavior_event(
|
||||||
db,
|
db,
|
||||||
event_type=risk_repo.EVENT_SMS_LOGIN,
|
event_type=risk_repo.EVENT_SMS_LOGIN,
|
||||||
@@ -389,15 +466,25 @@ def _finish_wechat_bind(
|
|||||||
未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。"""
|
未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。"""
|
||||||
existing = user_repo.get_user_by_phone(db, phone)
|
existing = user_repo.get_user_by_phone(db, phone)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
from app.core.config import settings # 局部 import,避免循环
|
|
||||||
|
|
||||||
ticket = create_conflict_ticket(
|
ticket = create_conflict_ticket(
|
||||||
openid=openid,
|
openid=openid,
|
||||||
wechat_nickname=wechat_nickname,
|
wechat_nickname=wechat_nickname,
|
||||||
wechat_avatar_url=wechat_avatar_url,
|
wechat_avatar_url=wechat_avatar_url,
|
||||||
phone=phone,
|
phone=phone,
|
||||||
)
|
)
|
||||||
blocked = rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
|
rebind_policy = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
"phone.rebind.days",
|
||||||
|
phone=phone,
|
||||||
|
device=device_id,
|
||||||
|
)
|
||||||
|
rebind_days = rebind_policy.limit or 0
|
||||||
|
blocked = rebind_repo.rebound_within_days(
|
||||||
|
db,
|
||||||
|
phone,
|
||||||
|
rebind_days,
|
||||||
|
reset_at=rebind_policy.reset_at,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s",
|
"wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s",
|
||||||
mask_phone(phone), existing.id, bool(existing.wechat_openid),
|
mask_phone(phone), existing.id, bool(existing.wechat_openid),
|
||||||
@@ -413,7 +500,12 @@ def _finish_wechat_bind(
|
|||||||
conflict_ticket=ticket,
|
conflict_ticket=ticket,
|
||||||
rebind_available=not blocked,
|
rebind_available=not blocked,
|
||||||
rebind_blocked_days=(
|
rebind_blocked_days=(
|
||||||
rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
|
rebind_repo.remaining_block_days(
|
||||||
|
db,
|
||||||
|
phone,
|
||||||
|
rebind_days,
|
||||||
|
reset_at=rebind_policy.reset_at,
|
||||||
|
)
|
||||||
if blocked else 0
|
if blocked else 0
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -446,17 +538,53 @@ def wechat_bind_phone_sms(
|
|||||||
except TokenError as e:
|
except TokenError as e:
|
||||||
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
|
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
|
||||||
|
|
||||||
|
subject_id = _device_subject(req.device_id, request)
|
||||||
# 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数)
|
# 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数)
|
||||||
|
bind_policy = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
"wechat.bind.hourly",
|
||||||
|
phone=req.phone,
|
||||||
|
device=subject_id,
|
||||||
|
)
|
||||||
|
verify_policy = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
"sms.code.failed_attempts",
|
||||||
|
phone=req.phone,
|
||||||
|
)
|
||||||
|
bind_limit = (
|
||||||
|
SMS_LOGIN_MAX_PER_HOUR
|
||||||
|
if bind_policy.override_id is None
|
||||||
|
and bind_policy.bucket_version == "default"
|
||||||
|
else bind_policy.limit
|
||||||
|
)
|
||||||
enforce_rate_limit(
|
enforce_rate_limit(
|
||||||
request,
|
request,
|
||||||
scope="wechat-bind-sms-device",
|
scope="wechat-bind-sms-device",
|
||||||
subject=req.device_id,
|
subject=subject_id,
|
||||||
limit=SMS_LOGIN_MAX_PER_HOUR,
|
limit=bind_limit,
|
||||||
window_sec=3600,
|
window_sec=3600,
|
||||||
detail="登录尝试过于频繁,请稍后再试",
|
detail="登录尝试过于频繁,请稍后再试",
|
||||||
|
bucket_suffix=bind_policy.bucket_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not verify_code(req.phone, req.code):
|
try:
|
||||||
|
ok = verify_code(
|
||||||
|
req.phone,
|
||||||
|
req.code,
|
||||||
|
max_failed_attempts=(
|
||||||
|
None
|
||||||
|
if verify_policy.override_id is None
|
||||||
|
and verify_policy.bucket_version == "default"
|
||||||
|
else (
|
||||||
|
UNLIMITED_VERIFY_ATTEMPTS
|
||||||
|
if verify_policy.limit is None
|
||||||
|
else verify_policy.limit
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」
|
||||||
|
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||||
|
if not ok:
|
||||||
raise HTTPException(status_code=400, detail="invalid sms code")
|
raise HTTPException(status_code=400, detail="invalid sms code")
|
||||||
|
|
||||||
return _finish_wechat_bind(
|
return _finish_wechat_bind(
|
||||||
@@ -513,9 +641,23 @@ def wechat_conflict_continue(
|
|||||||
except TokenError as e:
|
except TokenError as e:
|
||||||
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
|
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
|
||||||
|
|
||||||
|
subject_id = _device_subject(req.device_id, request)
|
||||||
|
conflict_policy = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
"wechat.conflict.hourly",
|
||||||
|
phone=claims["phone"],
|
||||||
|
device=subject_id,
|
||||||
|
)
|
||||||
|
conflict_limit = (
|
||||||
|
SMS_LOGIN_MAX_PER_HOUR
|
||||||
|
if conflict_policy.override_id is None
|
||||||
|
and conflict_policy.bucket_version == "default"
|
||||||
|
else conflict_policy.limit
|
||||||
|
)
|
||||||
enforce_rate_limit(
|
enforce_rate_limit(
|
||||||
request, scope="wechat-conflict-device", subject=req.device_id,
|
request, scope="wechat-conflict-device", subject=subject_id,
|
||||||
limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
|
limit=conflict_limit, window_sec=3600, detail="操作过于频繁,请稍后再试",
|
||||||
|
bucket_suffix=conflict_policy.bucket_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
user = user_repo.get_user_by_phone(db, claims["phone"])
|
user = user_repo.get_user_by_phone(db, claims["phone"])
|
||||||
@@ -553,21 +695,50 @@ def wechat_conflict_continue(
|
|||||||
def wechat_conflict_rebind(
|
def wechat_conflict_rebind(
|
||||||
req: WechatConflictRebindRequest, request: Request, db: DbSession
|
req: WechatConflictRebindRequest, request: Request, db: DbSession
|
||||||
) -> WechatBindResultResponse:
|
) -> WechatBindResultResponse:
|
||||||
from app.core.config import settings # 局部 import,避免循环
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
claims = decode_conflict_ticket(req.conflict_ticket)
|
claims = decode_conflict_ticket(req.conflict_ticket)
|
||||||
except TokenError as e:
|
except TokenError as e:
|
||||||
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
|
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
|
||||||
|
|
||||||
|
subject_id = _device_subject(req.device_id, request)
|
||||||
|
conflict_policy = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
"wechat.conflict.hourly",
|
||||||
|
phone=claims["phone"],
|
||||||
|
device=subject_id,
|
||||||
|
)
|
||||||
|
conflict_limit = (
|
||||||
|
SMS_LOGIN_MAX_PER_HOUR
|
||||||
|
if conflict_policy.override_id is None
|
||||||
|
and conflict_policy.bucket_version == "default"
|
||||||
|
else conflict_policy.limit
|
||||||
|
)
|
||||||
enforce_rate_limit(
|
enforce_rate_limit(
|
||||||
request, scope="wechat-conflict-device", subject=req.device_id,
|
request, scope="wechat-conflict-device", subject=subject_id,
|
||||||
limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
|
limit=conflict_limit, window_sec=3600, detail="操作过于频繁,请稍后再试",
|
||||||
|
bucket_suffix=conflict_policy.bucket_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
phone = claims["phone"]
|
phone = claims["phone"]
|
||||||
if rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS):
|
rebind_policy = limit_policy.resolve(
|
||||||
days = rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
|
db,
|
||||||
|
"phone.rebind.days",
|
||||||
|
phone=phone,
|
||||||
|
device=req.device_id,
|
||||||
|
)
|
||||||
|
rebind_days = rebind_policy.limit or 0
|
||||||
|
if rebind_repo.rebound_within_days(
|
||||||
|
db,
|
||||||
|
phone,
|
||||||
|
rebind_days,
|
||||||
|
reset_at=rebind_policy.reset_at,
|
||||||
|
):
|
||||||
|
days = rebind_repo.remaining_block_days(
|
||||||
|
db,
|
||||||
|
phone,
|
||||||
|
rebind_days,
|
||||||
|
reset_at=rebind_policy.reset_at,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=409, detail=f"该手机号 {days} 天内已换绑过,暂不能再次换绑")
|
raise HTTPException(status_code=409, detail=f"该手机号 {days} 天内已换绑过,暂不能再次换绑")
|
||||||
|
|
||||||
user = user_repo.rebind_account(
|
user = user_repo.rebind_account(
|
||||||
|
|||||||
+27
-9
@@ -21,21 +21,22 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from app.api.deps import DbSession, OptionalUser
|
from app.api.deps import DbSession, OptionalUser
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logging import trace_id_ctx
|
from app.core.logging import trace_id_ctx
|
||||||
from app.core.pricebot_client import get_pricebot_client
|
from app.core.pricebot_client import get_pricebot_client
|
||||||
|
from app.core.trace_ids import new_trace_id
|
||||||
from app.core.pricebot_router import pick_pricebot
|
from app.core.pricebot_router import pick_pricebot
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
from app.repositories import comparison as crud_compare
|
from app.repositories import comparison as crud_compare
|
||||||
from app.repositories import risk as risk_repo
|
from app.repositories import risk as risk_repo
|
||||||
|
from app.services.comparison_llm_backfill import backfill_comparison_llm_cost
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.compare")
|
logger = logging.getLogger("shagua.compare")
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ def _harvest_running_blocking(
|
|||||||
def _harvest_done_blocking(
|
def _harvest_done_blocking(
|
||||||
trace_id: str, user_id: int | None, done_params: dict, business_type: str,
|
trace_id: str, user_id: int | None, done_params: dict, business_type: str,
|
||||||
device_id: str | None, device_info: dict | None, trace_url: str | None,
|
device_id: str | None, device_info: dict | None, trace_url: str | None,
|
||||||
) -> None:
|
) -> int:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
rec, newly_success = crud_compare.harvest_done(
|
rec, newly_success = crud_compare.harvest_done(
|
||||||
db, trace_id=trace_id, user_id=user_id, done_params=done_params,
|
db, trace_id=trace_id, user_id=user_id, done_params=done_params,
|
||||||
@@ -102,11 +103,12 @@ def _harvest_done_blocking(
|
|||||||
# 不在此处发邀请奖:#113 已把发奖口径从「比价」移到「实际下单」(order.py),harvest
|
# 不在此处发邀请奖:#113 已把发奖口径从「比价」移到「实际下单」(order.py),harvest
|
||||||
# 只记录比价、不发奖。否则比价先于下单 + try_reward 幂等闸会让奖落在「比价」这步,
|
# 只记录比价、不发奖。否则比价先于下单 + try_reward 幂等闸会让奖落在「比价」这步,
|
||||||
# 架空 #113 的「下单才发奖」防刷意图(newly_success 仅留作日志观测)。
|
# 架空 #113 的「下单才发奖」防刷意图(newly_success 仅留作日志观测)。
|
||||||
|
return rec.id
|
||||||
|
|
||||||
|
|
||||||
def _harvest_abort_blocking(
|
def _harvest_abort_blocking(
|
||||||
trace_id: str, status_hint: str, reason: str | None, trace_url: str | None,
|
trace_id: str, status_hint: str, reason: str | None, trace_url: str | None,
|
||||||
) -> None:
|
) -> int | None:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
rec = crud_compare.harvest_abort(
|
rec = crud_compare.harvest_abort(
|
||||||
db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url,
|
db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url,
|
||||||
@@ -116,6 +118,7 @@ def _harvest_abort_blocking(
|
|||||||
extra={"phase": "harvest_abort",
|
extra={"phase": "harvest_abort",
|
||||||
"status": (rec.status if rec else None), "reason": reason},
|
"status": (rec.status if rec else None), "reason": reason},
|
||||||
)
|
)
|
||||||
|
return rec.id if rec is not None else None
|
||||||
|
|
||||||
|
|
||||||
async def _forward(
|
async def _forward(
|
||||||
@@ -139,7 +142,7 @@ async def _forward(
|
|||||||
trace_id = meta.get("trace_id")
|
trace_id = meta.get("trace_id")
|
||||||
minted = False
|
minted = False
|
||||||
if not trace_id:
|
if not trace_id:
|
||||||
trace_id = str(uuid.uuid4())
|
trace_id = new_trace_id()
|
||||||
meta["trace_id"] = trace_id
|
meta["trace_id"] = trace_id
|
||||||
raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes
|
raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes
|
||||||
minted = True
|
minted = True
|
||||||
@@ -243,7 +246,12 @@ async def intent_precoupon_step(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传 + done 落库)")
|
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传 + done 落库)")
|
||||||
async def price_step(request: Request, user: OptionalUser, db: DbSession) -> dict[str, Any]:
|
async def price_step(
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
user: OptionalUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> dict[str, Any]:
|
||||||
_ensure_compare_allowed(user, db)
|
_ensure_compare_allowed(user, db)
|
||||||
resp, trace_id, meta = await _forward(request, "/api/price/step", user)
|
resp, trace_id, meta = await _forward(request, "/api/price/step", user)
|
||||||
# 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。
|
# 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。
|
||||||
@@ -252,12 +260,15 @@ async def price_step(request: Request, user: OptionalUser, db: DbSession) -> dic
|
|||||||
if action.get("command") == "done" and not resp.get("continue", True):
|
if action.get("command") == "done" and not resp.get("continue", True):
|
||||||
done_params = action.get("params") or {}
|
done_params = action.get("params") or {}
|
||||||
try:
|
try:
|
||||||
await run_in_threadpool(
|
record_id = await run_in_threadpool(
|
||||||
_harvest_done_blocking, trace_id, (user.id if user else None),
|
_harvest_done_blocking, trace_id, (user.id if user else None),
|
||||||
done_params, "food",
|
done_params, "food",
|
||||||
meta.get("device_id"), meta.get("device_info"),
|
meta.get("device_id"), meta.get("device_info"),
|
||||||
resp.get("trace_url") or done_params.get("trace_url"),
|
resp.get("trace_url") or done_params.get("trace_url"),
|
||||||
)
|
)
|
||||||
|
background_tasks.add_task(
|
||||||
|
backfill_comparison_llm_cost, record_id, trace_id
|
||||||
|
)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("harvest_done failed trace=%s: %s", trace_id, e)
|
logger.warning("harvest_done failed trace=%s: %s", trace_id, e)
|
||||||
return resp
|
return resp
|
||||||
@@ -281,7 +292,10 @@ async def trace_epilogue(
|
|||||||
|
|
||||||
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
|
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
|
||||||
async def trace_finalize(
|
async def trace_finalize(
|
||||||
request: Request, user: OptionalUser, db: DbSession
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
user: OptionalUser,
|
||||||
|
db: DbSession,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
_ensure_compare_allowed(user, db)
|
_ensure_compare_allowed(user, db)
|
||||||
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
|
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
|
||||||
@@ -292,12 +306,16 @@ async def trace_finalize(
|
|||||||
request, "/api/trace/finalize", user, harvest_first_frame=False,
|
request, "/api/trace/finalize", user, harvest_first_frame=False,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await run_in_threadpool(
|
record_id = await run_in_threadpool(
|
||||||
_harvest_abort_blocking, trace_id,
|
_harvest_abort_blocking, trace_id,
|
||||||
(meta.get("status") or "cancelled"),
|
(meta.get("status") or "cancelled"),
|
||||||
(meta.get("reason") or meta.get("information")),
|
(meta.get("reason") or meta.get("information")),
|
||||||
(resp.get("trace_url") if isinstance(resp, dict) else None),
|
(resp.get("trace_url") if isinstance(resp, dict) else None),
|
||||||
)
|
)
|
||||||
|
if record_id is not None:
|
||||||
|
background_tasks.add_task(
|
||||||
|
backfill_comparison_llm_cost, record_id, trace_id
|
||||||
|
)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("harvest_abort failed trace=%s: %s", trace_id, e)
|
logger.warning("harvest_abort failed trace=%s: %s", trace_id, e)
|
||||||
return resp
|
return resp
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import logging
|
|||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, DbSession
|
from app.api.deps import CurrentUser, DbSession
|
||||||
from app.db.session import SessionLocal
|
from app.core import limit_policy
|
||||||
from app.models.comparison import ComparisonRecord
|
from app.core.trace_ids import new_trace_id
|
||||||
from app.repositories import comparison as crud_compare
|
from app.repositories import comparison as crud_compare
|
||||||
from app.repositories import risk as risk_repo
|
from app.repositories import risk as risk_repo
|
||||||
from app.schemas.compare_record import (
|
from app.schemas.compare_record import (
|
||||||
@@ -30,8 +30,7 @@ from app.schemas.compare_record import (
|
|||||||
ComparisonRecordOut,
|
ComparisonRecordOut,
|
||||||
ComparisonRecordPage,
|
ComparisonRecordPage,
|
||||||
)
|
)
|
||||||
from app.services.llm_cost import compute_llm_cost, get_llm_prices
|
from app.services.comparison_llm_backfill import backfill_comparison_llm_cost
|
||||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.compare_record")
|
logger = logging.getLogger("shagua.compare_record")
|
||||||
|
|
||||||
@@ -55,18 +54,34 @@ def reserve_compare_start(
|
|||||||
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用")
|
raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用")
|
||||||
|
# trace_id 统一由服务端签发(客户端不带时):预占额度本就是任务的第一个请求,
|
||||||
|
# 签发与建 running 行合一,此后 Phase1/Phase2/记录/前端日志全链用同一个 id。
|
||||||
|
# 客户端带了则沿用——老客户端兼容 + 同 trace 重试幂等(reserve_daily_start 按 trace_id 去重)。
|
||||||
|
trace_id = payload.trace_id or new_trace_id()
|
||||||
try:
|
try:
|
||||||
|
policy = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
"compare.start.daily",
|
||||||
|
phone=user.phone,
|
||||||
|
device=payload.device_id,
|
||||||
|
)
|
||||||
rec, used = crud_compare.reserve_daily_start(
|
rec, used = crud_compare.reserve_daily_start(
|
||||||
db,
|
db,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
trace_id=payload.trace_id,
|
trace_id=trace_id,
|
||||||
business_type=payload.business_type,
|
business_type=payload.business_type,
|
||||||
device_id=payload.device_id,
|
device_id=payload.device_id,
|
||||||
|
limit=policy.limit,
|
||||||
|
reset_at=policy.reset_at,
|
||||||
)
|
)
|
||||||
except crud_compare.DailyCompareStartLimitExceeded:
|
except crud_compare.DailyCompareStartLimitExceeded:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
detail="今日已比价超过100次,请明天再试",
|
detail=(
|
||||||
|
f"今日已比价超过{policy.limit}次,请明天再试"
|
||||||
|
if policy.limit is not None
|
||||||
|
else "今日比价次数已达上限,请明天再试"
|
||||||
|
),
|
||||||
) from None
|
) from None
|
||||||
except crud_compare.ComparisonTraceOwnershipError:
|
except crud_compare.ComparisonTraceOwnershipError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -74,11 +89,17 @@ def reserve_compare_start(
|
|||||||
detail="比价任务标识冲突,请重新发起",
|
detail="比价任务标识冲突,请重新发起",
|
||||||
) from None
|
) from None
|
||||||
# 风控阈值由后台动态配置,不能再只在固定的 100 次业务上限处同步。
|
# 风控阈值由后台动态配置,不能再只在固定的 100 次业务上限处同步。
|
||||||
risk_repo.sync_compare_incident(db, user_id=user.id, at=rec.created_at)
|
risk_repo.sync_compare_incident(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
at=rec.created_at,
|
||||||
|
device_id=payload.device_id,
|
||||||
|
)
|
||||||
return CompareStartReserveOut(
|
return CompareStartReserveOut(
|
||||||
limit=crud_compare.DAILY_COMPARE_START_LIMIT,
|
limit=policy.limit,
|
||||||
used=used,
|
used=used,
|
||||||
remaining=max(crud_compare.DAILY_COMPARE_START_LIMIT - used, 0),
|
remaining=max(policy.limit - used, 0) if policy.limit is not None else None,
|
||||||
|
trace_id=trace_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,32 +142,7 @@ def report_record(
|
|||||||
def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
|
def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
|
||||||
"""后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。
|
"""后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。
|
||||||
独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报。"""
|
独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报。"""
|
||||||
calls = fetch_llm_calls(trace_id)
|
backfill_comparison_llm_cost(record_id, trace_id)
|
||||||
if not calls:
|
|
||||||
return
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
rec = db.get(ComparisonRecord, record_id)
|
|
||||||
if rec is None:
|
|
||||||
return
|
|
||||||
rec.llm_calls = calls
|
|
||||||
rec.llm_call_count = len(calls)
|
|
||||||
rec.retry_count = sum(1 for c in calls if c.get("error"))
|
|
||||||
# token 累加(usage 已被 pricebot llm_client 归一为 prompt/completion_tokens;
|
|
||||||
# error 的调用 usage 可能为 None,or {} 兜底)
|
|
||||||
rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
|
|
||||||
rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
|
|
||||||
# 本次比价 LLM 成本(元)+ 当时单价快照:按 app_config 现价逐模型算好冻结(services/llm_cost.py)。
|
|
||||||
rec.llm_cost_yuan, rec.llm_price_snapshot = compute_llm_cost(calls, get_llm_prices(db))
|
|
||||||
db.commit()
|
|
||||||
logger.info(
|
|
||||||
"backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d",
|
|
||||||
trace_id, len(calls), rec.input_tokens, rec.output_tokens,
|
|
||||||
)
|
|
||||||
except Exception as e: # noqa: BLE001 best-effort
|
|
||||||
logger.warning("backfill llm_calls failed trace=%s: %s", trace_id, e)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
+31
-5
@@ -21,6 +21,7 @@ from fastapi.concurrency import run_in_threadpool
|
|||||||
from app.api.deps import CurrentUser, DbSession
|
from app.api.deps import CurrentUser, DbSession
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.pricebot_client import get_pricebot_client
|
from app.core.pricebot_client import get_pricebot_client
|
||||||
|
from app.core.trace_ids import new_trace_id
|
||||||
from app.core.pricebot_router import pick_pricebot
|
from app.core.pricebot_router import pick_pricebot
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
from app.repositories import coupon_state as coupon_repo
|
from app.repositories import coupon_state as coupon_repo
|
||||||
@@ -30,6 +31,7 @@ from app.schemas.coupon_state import (
|
|||||||
CouponPromptShouldShowOut,
|
CouponPromptShouldShowOut,
|
||||||
CouponPromptShownIn,
|
CouponPromptShownIn,
|
||||||
CouponSessionIn,
|
CouponSessionIn,
|
||||||
|
CouponSessionOut,
|
||||||
CouponStatsOut,
|
CouponStatsOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -175,6 +177,12 @@ async def coupon_step(
|
|||||||
)
|
)
|
||||||
|
|
||||||
resp_json = resp.json()
|
resp_json = resp.json()
|
||||||
|
# 每帧响应顶层回传本次任务 trace_id(对齐 compare _forward 的 setdefault):客户端任一帧
|
||||||
|
# 都能从响应拿到全链 id。**只回显请求里带的、不 mint**——step 是循环接口,每帧签新 id
|
||||||
|
# 会把一次任务打散;领券 trace_id 的唯一签发点在 /coupon/session (status=started)。
|
||||||
|
# pricebot 响应顶层本无 trace_id(只有 trace_url),setdefault 不会覆盖任何上游值。
|
||||||
|
if isinstance(resp_json, dict) and trace_id:
|
||||||
|
resp_json.setdefault("trace_id", trace_id)
|
||||||
|
|
||||||
# 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。
|
# 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。
|
||||||
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
|
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
|
||||||
@@ -204,15 +212,33 @@ async def coupon_step(
|
|||||||
return resp_json
|
return resp_json
|
||||||
|
|
||||||
|
|
||||||
@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
|
@router.post(
|
||||||
def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
|
"/session",
|
||||||
|
response_model=CouponSessionOut,
|
||||||
|
summary="领券任务流水上报(admin 领券数据看板数据源;started 兼签发本轮 trace_id)",
|
||||||
|
)
|
||||||
|
def coupon_session(payload: CouponSessionIn, db: DbSession) -> CouponSessionOut:
|
||||||
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
|
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
|
||||||
到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算
|
到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算
|
||||||
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。"""
|
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。
|
||||||
|
|
||||||
|
trace_id 统一由后端签发:started 不带 trace_id → 签发 uuid 并随响应返回,客户端全程用它
|
||||||
|
(领券 step 循环 / 收尾上报 / 前端运行日志)。签发不依赖写库成功——写库失败照样返回 trace_id,
|
||||||
|
后续收尾上报 upsert 会补建行。非 started 缺 trace_id 不签发(收尾没有 id 只能是异常调用,
|
||||||
|
签发新 id 只会造出一行查不到发起信息的孤儿),不写库、trace_id=null 返回。
|
||||||
|
"""
|
||||||
|
trace_id = payload.trace_id or (
|
||||||
|
new_trace_id() if payload.status == "started" else None
|
||||||
|
)
|
||||||
|
if trace_id is None:
|
||||||
|
logger.warning(
|
||||||
|
"coupon session missing trace_id for status=%s (skip write)", payload.status
|
||||||
|
)
|
||||||
|
return CouponSessionOut(ok=True, trace_id=None)
|
||||||
try:
|
try:
|
||||||
coupon_repo.upsert_coupon_session(
|
coupon_repo.upsert_coupon_session(
|
||||||
db,
|
db,
|
||||||
trace_id=payload.trace_id,
|
trace_id=trace_id,
|
||||||
device_id=payload.device_id,
|
device_id=payload.device_id,
|
||||||
status=payload.status,
|
status=payload.status,
|
||||||
started_at_ms=payload.started_at_ms,
|
started_at_ms=payload.started_at_ms,
|
||||||
@@ -229,7 +255,7 @@ def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
|
|||||||
)
|
)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("coupon session write failed: %s", e)
|
logger.warning("coupon session write failed: %s", e)
|
||||||
return {"ok": True}
|
return CouponSessionOut(ok=True, trace_id=trace_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
|
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ from app.api.deps import CurrentUser, DbSession
|
|||||||
from app.integrations import vendor_push
|
from app.integrations import vendor_push
|
||||||
from app.repositories import device as device_repo
|
from app.repositories import device as device_repo
|
||||||
from app.schemas.device import (
|
from app.schemas.device import (
|
||||||
|
DeferredDeviceUnregisterRequest,
|
||||||
DeviceOut,
|
DeviceOut,
|
||||||
DeviceRegisterRequest,
|
DeviceRegisterRequest,
|
||||||
|
DeviceUnregisterRequest,
|
||||||
HeartbeatRequest,
|
HeartbeatRequest,
|
||||||
LivenessAckRequest,
|
LivenessAckRequest,
|
||||||
LivenessOut,
|
LivenessOut,
|
||||||
@@ -78,6 +80,7 @@ def register_device(
|
|||||||
registration_id=req.registration_id,
|
registration_id=req.registration_id,
|
||||||
push_vendor=req.push_vendor,
|
push_vendor=req.push_vendor,
|
||||||
push_token=req.push_token,
|
push_token=req.push_token,
|
||||||
|
push_binding_id=req.push_binding_id,
|
||||||
platform=req.platform,
|
platform=req.platform,
|
||||||
app_version=req.app_version,
|
app_version=req.app_version,
|
||||||
)
|
)
|
||||||
@@ -106,6 +109,36 @@ def report_heartbeat(
|
|||||||
registration_id=req.registration_id,
|
registration_id=req.registration_id,
|
||||||
push_vendor=req.push_vendor,
|
push_vendor=req.push_vendor,
|
||||||
push_token=req.push_token,
|
push_token=req.push_token,
|
||||||
|
push_binding_id=req.push_binding_id,
|
||||||
|
)
|
||||||
|
return OkResponse()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unregister", response_model=OkResponse, summary="解绑当前用户的本机推送目标")
|
||||||
|
def unregister_device(
|
||||||
|
req: DeviceUnregisterRequest,
|
||||||
|
user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> OkResponse:
|
||||||
|
device_repo.unregister_push_binding(db, user_id=user.id, device_id=req.device_id)
|
||||||
|
logger.info("device unregister user_id=%d device_id=%s", user.id, req.device_id)
|
||||||
|
return OkResponse()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/unregister/deferred",
|
||||||
|
response_model=OkResponse,
|
||||||
|
summary="离线退出后按登录会话凭据幂等解绑",
|
||||||
|
)
|
||||||
|
def unregister_device_deferred(
|
||||||
|
req: DeferredDeviceUnregisterRequest,
|
||||||
|
db: DbSession,
|
||||||
|
) -> OkResponse:
|
||||||
|
# 不返回是否命中,避免公开接口泄露设备绑定状态。
|
||||||
|
device_repo.unregister_push_binding_deferred(
|
||||||
|
db,
|
||||||
|
device_id=req.device_id,
|
||||||
|
push_binding_id=req.push_binding_id,
|
||||||
)
|
)
|
||||||
return OkResponse()
|
return OkResponse()
|
||||||
|
|
||||||
|
|||||||
+42
-27
@@ -1,22 +1,16 @@
|
|||||||
"""新手引导视频(领券等候浮层前 N 次替代广告)。
|
"""引导视频 prepare/start/reward 客户端 API。"""
|
||||||
|
|
||||||
路由前缀 `/api/v1/guide-video`(均需 Bearer):
|
|
||||||
POST /start 这次浮层放引导视频还是放广告?命中则**当场计次**并下发 play_token
|
|
||||||
POST /reward 播完 / 中途关闭都调,按 play_token 幂等发固定金币
|
|
||||||
|
|
||||||
发币额度以**服务端配置**为准(运营后台可改),客户端只报"播完/关闭",报不了金额,
|
|
||||||
所以被破解也刷不到超额金币;次数上限由 guide_video_play 行数(按账号)硬卡。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, DbSession
|
from app.api.deps import CurrentUser, DbSession
|
||||||
from app.core.ratelimit import rate_limit
|
from app.core.ratelimit import rate_limit
|
||||||
from app.repositories import guide_video as crud_guide
|
from app.repositories import guide_video as crud_guide
|
||||||
from app.schemas.guide_video import (
|
from app.schemas.guide_video import (
|
||||||
|
GuideVideoPrepareIn,
|
||||||
|
GuideVideoPrepareOut,
|
||||||
GuideVideoRewardIn,
|
GuideVideoRewardIn,
|
||||||
GuideVideoRewardOut,
|
GuideVideoRewardOut,
|
||||||
GuideVideoStartIn,
|
GuideVideoStartIn,
|
||||||
@@ -24,25 +18,41 @@ from app.schemas.guide_video import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.guide_video")
|
logger = logging.getLogger("shagua.guide_video")
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/guide-video", tags=["guide-video"])
|
router = APIRouter(prefix="/api/v1/guide-video", tags=["guide-video"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/prepare",
|
||||||
|
response_model=GuideVideoPrepareOut,
|
||||||
|
dependencies=[Depends(rate_limit(60, 60, "guide-video-prepare"))],
|
||||||
|
)
|
||||||
|
def prepare(
|
||||||
|
payload: GuideVideoPrepareIn, user: CurrentUser, db: DbSession
|
||||||
|
) -> GuideVideoPrepareOut:
|
||||||
|
result = crud_guide.prepare_play(db, user.id, scene=payload.scene)
|
||||||
|
logger.info(
|
||||||
|
"guide video prepare user_id=%d scene=%s should_play=%s reason=%s",
|
||||||
|
user.id, payload.scene, result["should_play"], result["reason"],
|
||||||
|
)
|
||||||
|
return GuideVideoPrepareOut(**result)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/start",
|
"/start",
|
||||||
response_model=GuideVideoStartOut,
|
response_model=GuideVideoStartOut,
|
||||||
summary="领券浮层是否放新手引导视频(命中即计次)",
|
|
||||||
dependencies=[Depends(rate_limit(60, 60, "guide-video-start"))],
|
dependencies=[Depends(rate_limit(60, 60, "guide-video-start"))],
|
||||||
)
|
)
|
||||||
def start(payload: GuideVideoStartIn, user: CurrentUser, db: DbSession) -> GuideVideoStartOut:
|
def start(
|
||||||
"""开播即计数:返回 should_play=True 时服务端已写下这一次,客户端必须真的播。
|
payload: GuideVideoStartIn, user: CurrentUser, db: DbSession
|
||||||
|
) -> GuideVideoStartOut:
|
||||||
没配视频 / 开关关 / 次数用完 → should_play=False,客户端照旧走广告链路(行为不变)。
|
try:
|
||||||
"""
|
result = crud_guide.start_play(db, user.id, play_token=payload.play_token)
|
||||||
result = crud_guide.start_play(db, user.id, scene=payload.scene or "coupon")
|
except crud_guide.PlayStateError as exc:
|
||||||
|
status_code = 404 if exc.code == "play_not_found" else 409
|
||||||
|
raise HTTPException(status_code=status_code, detail=exc.detail()) from exc
|
||||||
logger.info(
|
logger.info(
|
||||||
"guide video start user_id=%d scene=%s should_play=%s seq=%d remaining=%d",
|
"guide video start user_id=%d token=%s status=%s seq=%d",
|
||||||
user.id, payload.scene, result["should_play"], result["seq"], result["remaining"],
|
user.id, payload.play_token[:12], result["status"], result["seq"],
|
||||||
)
|
)
|
||||||
return GuideVideoStartOut(**result)
|
return GuideVideoStartOut(**result)
|
||||||
|
|
||||||
@@ -50,15 +60,20 @@ def start(payload: GuideVideoStartIn, user: CurrentUser, db: DbSession) -> Guide
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/reward",
|
"/reward",
|
||||||
response_model=GuideVideoRewardOut,
|
response_model=GuideVideoRewardOut,
|
||||||
summary="引导视频发金币(播完/中途关闭都发,play_token 幂等)",
|
dependencies=[Depends(rate_limit(120, 60, "guide-video-reward"))],
|
||||||
dependencies=[Depends(rate_limit(60, 60, "guide-video-reward"))],
|
|
||||||
)
|
)
|
||||||
def reward(payload: GuideVideoRewardIn, user: CurrentUser, db: DbSession) -> GuideVideoRewardOut:
|
def reward(
|
||||||
result = crud_guide.grant_play(
|
payload: GuideVideoRewardIn, user: CurrentUser, db: DbSession
|
||||||
db, user.id, play_token=payload.play_token, completed=payload.completed
|
) -> GuideVideoRewardOut:
|
||||||
|
result = crud_guide.grant_circle(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
play_token=payload.play_token,
|
||||||
|
circle=payload.circle,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"guide video reward user_id=%d token=%s completed=%s granted=%s coin=%d",
|
"guide video reward user_id=%d token=%s circle=%d status=%s granted=%s",
|
||||||
user.id, payload.play_token[:12], payload.completed, result["granted"], result["coin"],
|
user.id, payload.play_token[:12], payload.circle,
|
||||||
|
result["status"], result["granted"],
|
||||||
)
|
)
|
||||||
return GuideVideoRewardOut(**result)
|
return GuideVideoRewardOut(**result)
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""客户端运行日志专用落盘 writer(独立于服务端 app-server.log)。
|
||||||
|
|
||||||
|
- 独占 logger "shagua.client_log" + 自己的 RotatingFileHandler,propagate=False → 不污染 app-server.log。
|
||||||
|
- 每条按「白名单键(client_ts/level/trace_id/tag/msg)提顶层 + 其余并入 data」封装,再
|
||||||
|
json.dumps 成一行写出(钉死 SLS 索引列;见 spec §5)。formatter 用 %(message)s——行本身
|
||||||
|
已是 JSON,不能再过 JsonFormatter 二次编码。
|
||||||
|
- 滚动 20MB×10(env 可调),与服务日志同机制。
|
||||||
|
⚠️ 依赖 --workers 1:RotatingFileHandler 多进程并发 doRollover 会损坏/丢日志;扩 worker
|
||||||
|
前换 QueueHandler→单写入者 / 外部 logrotate(copytruncate) / 写 stdout 交 journald。
|
||||||
|
|
||||||
|
服务端补的字段(time/source/service/client_ip/device_id/...)是「事实」,与客户端自述分开。
|
||||||
|
`time` 用服务端接收时间作 SLS 主时间(客户端时钟不可信),client_ts 另存为可查字段。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 仅这些客户端键提到输出行顶层;其余(含客户端自带 data)一律并入 data,防 SLS 索引列爆炸
|
||||||
|
_TOP_LEVEL_KEYS = ("client_ts", "level", "trace_id", "tag", "msg")
|
||||||
|
|
||||||
|
# trace_id/tag/level 是 SLS 索引字段(spec §8):给长度上限,防客户端塞超大值撑爆索引/抬升成本。
|
||||||
|
# (msg 另有字节截断;data 内的值不限,留待后续「服务端脱敏」knob。)
|
||||||
|
_MAX_LEVEL_LEN = 16
|
||||||
|
_MAX_TRACE_ID_LEN = 256
|
||||||
|
_MAX_TAG_LEN = 128
|
||||||
|
|
||||||
|
_logger: logging.Logger | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _max_msg_bytes() -> int:
|
||||||
|
# 每次调用现读 env(不设模块级常量):便于运行期调整 / 测试 monkeypatch,开销可忽略。
|
||||||
|
return int(os.getenv("APPLOG_MAX_MSG_BYTES", "8192"))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_logger() -> logging.Logger:
|
||||||
|
lg = logging.getLogger("shagua.client_log") # 与仓库 shagua.* 业务 logger 命名一致
|
||||||
|
lg.setLevel(logging.INFO)
|
||||||
|
lg.propagate = False # 不冒泡到 root → 不写进 app-server.log
|
||||||
|
log_file = os.getenv("CLIENT_LOG_FILE") or str(
|
||||||
|
Path(os.getenv("LOG_DIR", "logs")) / "app-client.log"
|
||||||
|
)
|
||||||
|
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handler = RotatingFileHandler(
|
||||||
|
log_file,
|
||||||
|
maxBytes=int(os.getenv("CLIENT_LOG_MAX_BYTES", str(20 * 1024 * 1024))),
|
||||||
|
backupCount=int(os.getenv("CLIENT_LOG_BACKUP_COUNT", "10")),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s")) # 行已是 JSON,不再包装
|
||||||
|
lg.handlers = [handler]
|
||||||
|
return lg
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger() -> logging.Logger:
|
||||||
|
global _logger
|
||||||
|
if _logger is None:
|
||||||
|
_logger = _build_logger()
|
||||||
|
return _logger
|
||||||
|
|
||||||
|
|
||||||
|
def reset_client_logger() -> None:
|
||||||
|
"""测试用:关闭并丢弃当前 logger,使下次 get_logger 按当时 env 重建(切临时文件)。"""
|
||||||
|
global _logger
|
||||||
|
if _logger is not None:
|
||||||
|
for h in list(_logger.handlers):
|
||||||
|
h.close()
|
||||||
|
_logger.handlers = []
|
||||||
|
_logger = None
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_msg(msg: str) -> tuple[str, bool]:
|
||||||
|
raw = msg.encode("utf-8")
|
||||||
|
limit = _max_msg_bytes()
|
||||||
|
if len(raw) <= limit:
|
||||||
|
return msg, False
|
||||||
|
# 按字节截断后解码,忽略截断处半个多字节字符
|
||||||
|
return raw[:limit].decode("utf-8", "ignore") + "…[truncated]", True
|
||||||
|
|
||||||
|
|
||||||
|
def _build_line(
|
||||||
|
record: dict, *, meta: dict, client_ip: str, service: str, now_iso: str
|
||||||
|
) -> str:
|
||||||
|
out: dict = {
|
||||||
|
"time": now_iso,
|
||||||
|
"source": "client",
|
||||||
|
"service": service,
|
||||||
|
"client_ip": client_ip,
|
||||||
|
}
|
||||||
|
# 批级公共字段(非空才带)
|
||||||
|
for k in ("device_id", "user_id", "app_ver", "platform", "sent_at"):
|
||||||
|
v = meta.get(k)
|
||||||
|
if v is not None:
|
||||||
|
out[k] = v
|
||||||
|
# 白名单键提顶层(索引字段做长度上限 + 统一转 str,保证 SLS 里类型/大小可控)
|
||||||
|
if record.get("level") is not None:
|
||||||
|
out["level"] = str(record["level"])[:_MAX_LEVEL_LEN].upper()
|
||||||
|
if record.get("trace_id"):
|
||||||
|
out["trace_id"] = str(record["trace_id"])[:_MAX_TRACE_ID_LEN]
|
||||||
|
if record.get("tag"):
|
||||||
|
out["tag"] = str(record["tag"])[:_MAX_TAG_LEN]
|
||||||
|
if record.get("client_ts") is not None:
|
||||||
|
out["client_ts"] = record["client_ts"]
|
||||||
|
if record.get("msg") is not None:
|
||||||
|
msg, truncated = _truncate_msg(str(record["msg"]))
|
||||||
|
out["msg"] = msg
|
||||||
|
if truncated:
|
||||||
|
out["msg_truncated"] = True
|
||||||
|
# 其余键并入 data:先收白名单外的散键(兜底),再让客户端显式的 data 覆盖同名散键
|
||||||
|
# —— 显式 data 为准,不静默丢客户端明确给的值(保真)。
|
||||||
|
data: dict = {}
|
||||||
|
for k, v in record.items():
|
||||||
|
if k in _TOP_LEVEL_KEYS or k == "data":
|
||||||
|
continue
|
||||||
|
data[k] = v
|
||||||
|
client_data = record.get("data")
|
||||||
|
if isinstance(client_data, dict):
|
||||||
|
data.update(client_data)
|
||||||
|
if data:
|
||||||
|
out["data"] = data
|
||||||
|
return json.dumps(out, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def write_records(
|
||||||
|
records: list[dict], *, meta: dict, client_ip: str
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""把一批客户端日志逐条写入专用文件。返回 (received, dropped)。
|
||||||
|
|
||||||
|
尽力而为(fire-and-forget):logger 初始化或单条写入失败只跳过并计 dropped,
|
||||||
|
不抛给上层——端点因此永不因写日志而 500。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
lg = get_logger()
|
||||||
|
except Exception: # noqa: BLE001 — 初始化失败也不能让端点 500
|
||||||
|
logging.getLogger("shagua.applog").exception("client log writer init failed")
|
||||||
|
return 0, len(records)
|
||||||
|
service = os.getenv("CLIENT_LOG_SERVICE_NAME", "app-client")
|
||||||
|
# 一批共用同一「服务端接收时间」:降开销,且语义上是服务端「收到」而非逐条「处理」时间。
|
||||||
|
now_iso = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
||||||
|
received = dropped = 0
|
||||||
|
for rec in records:
|
||||||
|
try:
|
||||||
|
line = _build_line(
|
||||||
|
rec, meta=meta, client_ip=client_ip, service=service, now_iso=now_iso
|
||||||
|
)
|
||||||
|
lg.info(line)
|
||||||
|
received += 1
|
||||||
|
except Exception: # noqa: BLE001 — 坏条跳过,不影响其余
|
||||||
|
logging.getLogger("shagua.applog").exception(
|
||||||
|
"client log record dropped client_ip=%s", client_ip
|
||||||
|
)
|
||||||
|
dropped += 1
|
||||||
|
return received, dropped
|
||||||
+74
-4
@@ -79,6 +79,8 @@ class Settings(BaseSettings):
|
|||||||
HONOR_PUSH_APP_ID: str = ""
|
HONOR_PUSH_APP_ID: str = ""
|
||||||
HONOR_PUSH_CLIENT_ID: str = ""
|
HONOR_PUSH_CLIENT_ID: str = ""
|
||||||
HONOR_PUSH_CLIENT_SECRET: str = ""
|
HONOR_PUSH_CLIENT_SECRET: str = ""
|
||||||
|
# 0=正式消息(默认);1=测试消息(仅开发联调,勿用于生产)。
|
||||||
|
HONOR_PUSH_TARGET_USER_TYPE: int = Field(default=0, ge=0, le=1)
|
||||||
HONOR_PUSH_TOKEN_ENDPOINT: str = "https://iam.developer.honor.com/auth/token"
|
HONOR_PUSH_TOKEN_ENDPOINT: str = "https://iam.developer.honor.com/auth/token"
|
||||||
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
|
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
|
||||||
"https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage"
|
"https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage"
|
||||||
@@ -88,6 +90,8 @@ class Settings(BaseSettings):
|
|||||||
# (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。
|
# (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。
|
||||||
HUAWEI_PUSH_APP_ID: str = ""
|
HUAWEI_PUSH_APP_ID: str = ""
|
||||||
HUAWEI_PUSH_APP_SECRET: str = ""
|
HUAWEI_PUSH_APP_SECRET: str = ""
|
||||||
|
# 0=正式消息(默认,受正式消息频控);1=测试消息(仅开发联调,勿用于生产)。
|
||||||
|
HUAWEI_PUSH_TARGET_USER_TYPE: int = Field(default=0, ge=0, le=1)
|
||||||
HUAWEI_PUSH_TOKEN_ENDPOINT: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/token"
|
HUAWEI_PUSH_TOKEN_ENDPOINT: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/token"
|
||||||
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
|
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
|
||||||
"https://push-api.cloud.huawei.com/v1/{app_id}/messages:send"
|
"https://push-api.cloud.huawei.com/v1/{app_id}/messages:send"
|
||||||
@@ -141,19 +145,78 @@ class Settings(BaseSettings):
|
|||||||
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
|
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
|
||||||
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
|
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
|
||||||
|
|
||||||
|
# ===== 短信提供商(可切换:极光 / 阿里云号码认证 / 创蓝云智)=====
|
||||||
|
# jiguang(默认):本服务生成验证码,极光只负责下发,本地内存校验(自管码,现状不变)。
|
||||||
|
# aliyun:阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A);缺凭证时 /sms/* 返 503(优雅降级)。
|
||||||
|
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
|
||||||
|
SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang"
|
||||||
|
# 主 provider 供应商不可用(SmsError 503)时转投的备 provider;空=关闭 fallback(默认,
|
||||||
|
# 保持现状零风险)。仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。
|
||||||
|
SMS_FALLBACK_PROVIDER: Literal["jiguang", "chuanglan", ""] = ""
|
||||||
|
ALIYUN_SMS_ACCESS_KEY_ID: str = ""
|
||||||
|
ALIYUN_SMS_ACCESS_KEY_SECRET: str = ""
|
||||||
|
ALIYUN_SMS_SIGN_NAME: str = "" # 系统赠送签名(自定义签名下发易失败)
|
||||||
|
ALIYUN_SMS_TEMPLATE_CODE: str = "" # 赠送模板 CODE(须与赠送签名搭配)
|
||||||
|
ALIYUN_SMS_SCHEME_NAME: str = "" # 方案名(可空=默认方案);send/check 共用避免不匹配
|
||||||
|
ALIYUN_SMS_ENDPOINT: str = "dypnsapi.aliyuncs.com"
|
||||||
|
ALIYUN_SMS_CODE_LENGTH: int = 6 # 验证码位数(CodeLength 4~8)
|
||||||
|
ALIYUN_SMS_VALID_TIME_SEC: int = 300 # 验证码有效期秒(ValidTime);短信内 min 文案 = //60
|
||||||
|
ALIYUN_SMS_INTERVAL_SEC: int = 60 # 单号发送频控秒(Interval);核验免费
|
||||||
|
ALIYUN_SMS_TIMEOUT_SEC: int = 15 # 阿里云 API 读/连超时秒
|
||||||
|
|
||||||
|
# --- 创蓝云智(253)模板短信,Mode B 自管码,httpx 直连 + HMAC 签名(见 docs/integrations/chuanglan/tpl-send.md)---
|
||||||
|
CHUANGLAN_SMS_ACCOUNT: str = "" # YZM 前缀验证码账号
|
||||||
|
CHUANGLAN_SMS_PASSWORD: str = "" # API 密码(仅用于本地算 HMAC 签名,不随请求上行)
|
||||||
|
CHUANGLAN_SMS_TEMPLATE_ID: str = "" # 模板 ID(控制台创建)
|
||||||
|
CHUANGLAN_SMS_SIGNATURE: str = "" # 短信签名文案【品牌】;模板已关联签名则留空
|
||||||
|
CHUANGLAN_SMS_ENDPOINT: str = "https://smssh.253.com/msg/sms/v2/tpl/send"
|
||||||
|
CHUANGLAN_SMS_TIMEOUT_SEC: int = 10 # httpx 读/连超时秒
|
||||||
|
|
||||||
|
@property
|
||||||
|
def aliyun_sms_configured(self) -> bool:
|
||||||
|
"""阿里云短信凭证齐全(缺则 SMS_PROVIDER=aliyun 时 /sms/* 返 503,而非启动崩)。"""
|
||||||
|
return bool(
|
||||||
|
self.ALIYUN_SMS_ACCESS_KEY_ID
|
||||||
|
and self.ALIYUN_SMS_ACCESS_KEY_SECRET
|
||||||
|
and self.ALIYUN_SMS_SIGN_NAME
|
||||||
|
and self.ALIYUN_SMS_TEMPLATE_CODE
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chuanglan_sms_configured(self) -> bool:
|
||||||
|
"""创蓝短信凭证齐全(缺则 SMS_PROVIDER=chuanglan 时 /sms/send 返 503,而非启动崩)。"""
|
||||||
|
return bool(
|
||||||
|
self.CHUANGLAN_SMS_ACCOUNT
|
||||||
|
and self.CHUANGLAN_SMS_PASSWORD
|
||||||
|
and self.CHUANGLAN_SMS_TEMPLATE_ID
|
||||||
|
)
|
||||||
|
|
||||||
# ===== 测试账号(release 包全流程联调用)=====
|
# ===== 测试账号(release 包全流程联调用)=====
|
||||||
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
||||||
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
|
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
|
||||||
# 猜到号后脚本滥用。两个值都能随时改 .env。逻辑全在 app/core/test_account.py,与其他业务解耦。
|
# 猜到号后脚本滥用。两个值都能随时改 .env。逻辑全在 app/core/test_account.py,与其他业务解耦。
|
||||||
# ⚠️ TEST_ACCOUNT_PHONE 留空 = 整个功能关闭(生产默认安全;要启用才显式填号)。
|
# ⚠️ TEST_ACCOUNT_PHONE 留空 = 整个功能关闭(生产默认安全;要启用才显式填号)。
|
||||||
TEST_ACCOUNT_PHONE: str = "" # 测试手机号(11 位,如 11111111111);空=关闭整功能
|
TEST_ACCOUNT_PHONE: str = "" # 兼容旧配置:单个测试手机号
|
||||||
|
TEST_ACCOUNT_PHONES: str = "" # 多个测试手机号,英文逗号分隔
|
||||||
TEST_ACCOUNT_DAILY_LIMIT: int = 500 # 该测试号每日最多登录次数,当日超过即拒绝登录
|
TEST_ACCOUNT_DAILY_LIMIT: int = 500 # 该测试号每日最多登录次数,当日超过即拒绝登录
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def test_account_phone(self) -> str:
|
def test_account_phone(self) -> str:
|
||||||
"""规整后的测试手机号(去空白);空串=功能关闭。"""
|
"""兼容旧调用:规整后的单个测试手机号。"""
|
||||||
return self.TEST_ACCOUNT_PHONE.strip()
|
return self.TEST_ACCOUNT_PHONE.strip()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def test_account_phones(self) -> frozenset[str]:
|
||||||
|
"""所有测试手机号;新旧配置取并集,便于线上平滑迁移。"""
|
||||||
|
phones = {
|
||||||
|
phone.strip()
|
||||||
|
for phone in self.TEST_ACCOUNT_PHONES.split(",")
|
||||||
|
if phone.strip()
|
||||||
|
}
|
||||||
|
if self.test_account_phone:
|
||||||
|
phones.add(self.test_account_phone)
|
||||||
|
return frozenset(phones)
|
||||||
|
|
||||||
# ===== 美团联盟 CPS =====
|
# ===== 美团联盟 CPS =====
|
||||||
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
|
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
|
||||||
MT_CPS_APP_KEY: str = ""
|
MT_CPS_APP_KEY: str = ""
|
||||||
@@ -318,7 +381,7 @@ class Settings(BaseSettings):
|
|||||||
# ===== 穿山甲 GroMore 数据 API(报表收益拉取,T+1)=====
|
# ===== 穿山甲 GroMore 数据 API(报表收益拉取,T+1)=====
|
||||||
# ⚠️ 与上面发奖回调的 m-key 是【两套完全不同的凭证】:这三样在穿山甲后台
|
# ⚠️ 与上面发奖回调的 m-key 是【两套完全不同的凭证】:这三样在穿山甲后台
|
||||||
# 「接入中心 → GroMore-API → 聚合数据报告 API」文档页领取(user_id / role_id / Security Key),
|
# 「接入中心 → GroMore-API → 聚合数据报告 API」文档页领取(user_id / role_id / Security Key),
|
||||||
# 仅用于按天拉 GroMore 报表(revenue 排序价预估 + api_revenue ADN Reporting 收益),不参与发奖。
|
# 仅用于按天拉 GroMore 收益报表(revenue 预估收益 + api_revenue 收益Api),不参与发奖。
|
||||||
# 该 API 只能查【GroMore 聚合代码位】的数据(=我们 useMediation 的口径),非穿山甲 SDK 数据;
|
# 该 API 只能查【GroMore 聚合代码位】的数据(=我们 useMediation 的口径),非穿山甲 SDK 数据;
|
||||||
# 且不提供用户/设备维度(官方明确),故收益只能落到 日期×代码位 汇总,不能挂到逐条事件。
|
# 且不提供用户/设备维度(官方明确),故收益只能落到 日期×代码位 汇总,不能挂到逐条事件。
|
||||||
# 子账号(role_id≠user_id)需主账号在「角色管理」授予「查看全部数据」权限,否则查不到
|
# 子账号(role_id≠user_id)需主账号在「角色管理」授予「查看全部数据」权限,否则查不到
|
||||||
@@ -377,6 +440,12 @@ class Settings(BaseSettings):
|
|||||||
# 靠这个共享密钥头(X-Internal-Secret)校验,与 pricebot 侧 INTERNAL_API_SECRET 同值。
|
# 靠这个共享密钥头(X-Internal-Secret)校验,与 pricebot 侧 INTERNAL_API_SECRET 同值。
|
||||||
# 默认空 = 内部写端点关闭(返 503),启用前两边都要配上同一高熵串。
|
# 默认空 = 内部写端点关闭(返 503),启用前两边都要配上同一高熵串。
|
||||||
INTERNAL_API_SECRET: str = ""
|
INTERNAL_API_SECRET: str = ""
|
||||||
|
# Current compare clients are persisted by server-side harvest. Repair any
|
||||||
|
# recent terminal rows left without LLM usage by transient upstream/auth failures.
|
||||||
|
LLM_COST_BACKFILL_ENABLED: bool = True
|
||||||
|
LLM_COST_BACKFILL_INTERVAL_SEC: int = 300
|
||||||
|
LLM_COST_BACKFILL_BATCH_SIZE: int = 100
|
||||||
|
LLM_COST_BACKFILL_LOOKBACK_DAYS: int = 30
|
||||||
|
|
||||||
# ===== 媒体文件(用户头像上传)=====
|
# ===== 媒体文件(用户头像上传)=====
|
||||||
# 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。
|
# 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。
|
||||||
@@ -387,6 +456,7 @@ class Settings(BaseSettings):
|
|||||||
# 运营后台上传的新手引导视频上限。视频比图片大一个量级,单独一档;
|
# 运营后台上传的新手引导视频上限。视频比图片大一个量级,单独一档;
|
||||||
# ⚠️ 改大时同步放宽网关 client_max_body_size(实测 QA 4MiB / prod 32MiB),否则 nginx 先挡下。
|
# ⚠️ 改大时同步放宽网关 client_max_body_size(实测 QA 4MiB / prod 32MiB),否则 nginx 先挡下。
|
||||||
GUIDE_VIDEO_MAX_BYTES: int = 100 * 1024 * 1024 # 引导视频最大 100MB
|
GUIDE_VIDEO_MAX_BYTES: int = 100 * 1024 * 1024 # 引导视频最大 100MB
|
||||||
|
FFPROBE_BINARY: str = "ffprobe"
|
||||||
|
|
||||||
# ===== 邀请好友 =====
|
# ===== 邀请好友 =====
|
||||||
# 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。
|
# 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。
|
||||||
@@ -439,7 +509,7 @@ class Settings(BaseSettings):
|
|||||||
return self.APP_ENV == "prod"
|
return self.APP_ENV == "prod"
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _enforce_prod_secrets(self) -> "Settings":
|
def _enforce_prod_secrets(self) -> Settings:
|
||||||
"""prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。
|
"""prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。
|
||||||
|
|
||||||
只校验两个签发凭证:App 用户的 JWT_SECRET_KEY、后台的 ADMIN_JWT_SECRET——它们沿用默认值
|
只校验两个签发凭证:App 用户的 JWT_SECRET_KEY、后台的 ADMIN_JWT_SECRET——它们沿用默认值
|
||||||
|
|||||||
+133
-5
@@ -14,12 +14,138 @@ from app.core import rewards as r
|
|||||||
RISK_SMS_HOURLY_THRESHOLD_KEY = "risk_sms_hourly_threshold"
|
RISK_SMS_HOURLY_THRESHOLD_KEY = "risk_sms_hourly_threshold"
|
||||||
RISK_ONECLICK_DAILY_THRESHOLD_KEY = "risk_oneclick_daily_threshold"
|
RISK_ONECLICK_DAILY_THRESHOLD_KEY = "risk_oneclick_daily_threshold"
|
||||||
RISK_COMPARE_DAILY_THRESHOLD_KEY = "risk_compare_daily_threshold"
|
RISK_COMPARE_DAILY_THRESHOLD_KEY = "risk_compare_daily_threshold"
|
||||||
|
COMPARE_DAILY_LIMIT_KEY = "compare_daily_limit"
|
||||||
|
SMS_SEND_HOURLY_LIMIT_KEY = "sms_send_hourly_limit"
|
||||||
|
SMS_SEND_DAILY_LIMIT_KEY = "sms_send_daily_limit"
|
||||||
|
SMS_LOGIN_HOURLY_LIMIT_KEY = "sms_login_hourly_limit"
|
||||||
|
WECHAT_BIND_SMS_HOURLY_LIMIT_KEY = "wechat_bind_sms_hourly_limit"
|
||||||
|
WECHAT_CONFLICT_HOURLY_LIMIT_KEY = "wechat_conflict_hourly_limit"
|
||||||
|
SMS_PHONE_COOLDOWN_SECONDS_KEY = "sms_phone_cooldown_seconds"
|
||||||
|
SMS_CODE_MAX_FAILED_ATTEMPTS_KEY = "sms_code_max_failed_attempts"
|
||||||
|
AD_REWARD_VIDEO_DAILY_LIMIT_KEY = "ad_reward_video_daily_limit"
|
||||||
|
AD_FEED_DAILY_LIMIT_KEY = "ad_feed_daily_limit"
|
||||||
|
PHONE_REBIND_DAYS_KEY = "phone_rebind_days"
|
||||||
|
GUIDE_VIDEO_MAX_PLAYS_KEY = "guide_video_max_plays"
|
||||||
|
LIMIT_POLICY_GLOBAL_KEY = "limit_policy_global"
|
||||||
|
|
||||||
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum
|
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum
|
||||||
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
|
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
|
||||||
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
|
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
|
||||||
# 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。
|
# 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。
|
||||||
CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||||
|
COMPARE_DAILY_LIMIT_KEY: {
|
||||||
|
"default": 100,
|
||||||
|
"label": "账号每日比价次数上限",
|
||||||
|
"group": "限制策略",
|
||||||
|
"type": "int",
|
||||||
|
"min": 1,
|
||||||
|
"max": 100_000,
|
||||||
|
"hidden": True,
|
||||||
|
"help": "北京时间自然日内每个账号可发起的比价次数。",
|
||||||
|
},
|
||||||
|
SMS_SEND_HOURLY_LIMIT_KEY: {
|
||||||
|
"default": 5,
|
||||||
|
"label": "短信每小时发送上限",
|
||||||
|
"group": "限制策略",
|
||||||
|
"type": "int",
|
||||||
|
"min": 1,
|
||||||
|
"max": 10_000,
|
||||||
|
"hidden": True,
|
||||||
|
"help": "同一设备和 IP 在固定 1 小时窗口内成功发送短信的次数。",
|
||||||
|
},
|
||||||
|
SMS_SEND_DAILY_LIMIT_KEY: {
|
||||||
|
"default": 20,
|
||||||
|
"label": "短信 24 小时发送上限",
|
||||||
|
"group": "限制策略",
|
||||||
|
"type": "int",
|
||||||
|
"min": 1,
|
||||||
|
"max": 100_000,
|
||||||
|
"hidden": True,
|
||||||
|
"help": "同一设备和 IP 在固定 24 小时窗口内成功发送短信的次数。",
|
||||||
|
},
|
||||||
|
SMS_LOGIN_HOURLY_LIMIT_KEY: {
|
||||||
|
"default": 5,
|
||||||
|
"label": "短信登录每小时尝试上限",
|
||||||
|
"group": "限制策略",
|
||||||
|
"type": "int",
|
||||||
|
"min": 1,
|
||||||
|
"max": 10_000,
|
||||||
|
"hidden": True,
|
||||||
|
"help": "成功与失败均计入。",
|
||||||
|
},
|
||||||
|
WECHAT_BIND_SMS_HOURLY_LIMIT_KEY: {
|
||||||
|
"default": 5,
|
||||||
|
"label": "微信短信绑定每小时尝试上限",
|
||||||
|
"group": "限制策略",
|
||||||
|
"type": "int",
|
||||||
|
"min": 1,
|
||||||
|
"max": 10_000,
|
||||||
|
"hidden": True,
|
||||||
|
},
|
||||||
|
WECHAT_CONFLICT_HOURLY_LIMIT_KEY: {
|
||||||
|
"default": 5,
|
||||||
|
"label": "微信冲突处理每小时尝试上限",
|
||||||
|
"group": "限制策略",
|
||||||
|
"type": "int",
|
||||||
|
"min": 1,
|
||||||
|
"max": 10_000,
|
||||||
|
"hidden": True,
|
||||||
|
"help": "继续登录与重新绑定共享此额度。",
|
||||||
|
},
|
||||||
|
SMS_PHONE_COOLDOWN_SECONDS_KEY: {
|
||||||
|
"default": 60,
|
||||||
|
"label": "同手机号短信发送冷却秒数",
|
||||||
|
"group": "限制策略",
|
||||||
|
"type": "int",
|
||||||
|
"min": 0,
|
||||||
|
"max": 86_400,
|
||||||
|
"hidden": True,
|
||||||
|
},
|
||||||
|
SMS_CODE_MAX_FAILED_ATTEMPTS_KEY: {
|
||||||
|
"default": 5,
|
||||||
|
"label": "单验证码最大失败次数",
|
||||||
|
"group": "限制策略",
|
||||||
|
"type": "int",
|
||||||
|
"min": 1,
|
||||||
|
"max": 100,
|
||||||
|
"hidden": True,
|
||||||
|
},
|
||||||
|
AD_REWARD_VIDEO_DAILY_LIMIT_KEY: {
|
||||||
|
"default": r.DAILY_AD_REWARD_LIMIT,
|
||||||
|
"label": "激励视频每日发奖次数",
|
||||||
|
"group": "限制策略",
|
||||||
|
"type": "int",
|
||||||
|
"min": 1,
|
||||||
|
"max": 100_000,
|
||||||
|
"hidden": True,
|
||||||
|
},
|
||||||
|
AD_FEED_DAILY_LIMIT_KEY: {
|
||||||
|
"default": r.DAILY_AD_REWARD_LIMIT,
|
||||||
|
"label": "Draw 信息流每日发奖次数",
|
||||||
|
"group": "限制策略",
|
||||||
|
"type": "int",
|
||||||
|
"min": 1,
|
||||||
|
"max": 100_000,
|
||||||
|
"hidden": True,
|
||||||
|
},
|
||||||
|
PHONE_REBIND_DAYS_KEY: {
|
||||||
|
"default": 30,
|
||||||
|
"label": "手机/微信换绑冷却天数",
|
||||||
|
"group": "限制策略",
|
||||||
|
"type": "int",
|
||||||
|
"min": 0,
|
||||||
|
"max": 3650,
|
||||||
|
"hidden": True,
|
||||||
|
},
|
||||||
|
GUIDE_VIDEO_MAX_PLAYS_KEY: {
|
||||||
|
"default": 3,
|
||||||
|
"label": "领券引导视频最大播放次数",
|
||||||
|
"group": "限制策略",
|
||||||
|
"type": "int",
|
||||||
|
"min": 0,
|
||||||
|
"max": 50,
|
||||||
|
"hidden": True,
|
||||||
|
},
|
||||||
"signin_rewards": {
|
"signin_rewards": {
|
||||||
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
|
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
|
||||||
"group": "签到", "type": "int_list",
|
"group": "签到", "type": "int_list",
|
||||||
@@ -56,7 +182,8 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
|||||||
},
|
},
|
||||||
"ad_daily_limit": {
|
"ad_daily_limit": {
|
||||||
"default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)",
|
"default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)",
|
||||||
"group": "看广告", "type": "int", "help": "福利页激励视频每日可发奖次数上限,默认 500。",
|
"group": "看广告", "type": "int", "min": 1, "max": 100_000, "hidden": True,
|
||||||
|
"help": "历史共享上限;新配置由白名单页分别管理激励视频与 Draw 信息流。",
|
||||||
},
|
},
|
||||||
"ad_max_coin": {
|
"ad_max_coin": {
|
||||||
"default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限",
|
"default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限",
|
||||||
@@ -68,7 +195,8 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
|||||||
},
|
},
|
||||||
"ad_cooldown_sec": {
|
"ad_cooldown_sec": {
|
||||||
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
|
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
|
||||||
"group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
|
"group": "看广告", "type": "int", "min": 0, "max": 86_400,
|
||||||
|
"help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
|
||||||
},
|
},
|
||||||
"comparing_ad_enabled": {
|
"comparing_ad_enabled": {
|
||||||
"default": True, "label": "比价/领券期信息流广告",
|
"default": True, "label": "比价/领券期信息流广告",
|
||||||
@@ -118,9 +246,9 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
|||||||
"group": "风控",
|
"group": "风控",
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"min": 1,
|
"min": 1,
|
||||||
"max": 5,
|
"max": 100_000,
|
||||||
"hidden": True,
|
"hidden": True,
|
||||||
"help": "同一设备在北京时间同一自然小时内成功下发短信达到该次数时告警;不得高于现有每小时 5 次的发送上限。",
|
"help": "同一设备在北京时间同一自然小时内成功下发短信达到该次数时告警。",
|
||||||
},
|
},
|
||||||
RISK_ONECLICK_DAILY_THRESHOLD_KEY: {
|
RISK_ONECLICK_DAILY_THRESHOLD_KEY: {
|
||||||
"default": 20,
|
"default": 20,
|
||||||
@@ -138,7 +266,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
|||||||
"group": "风控",
|
"group": "风控",
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"min": 1,
|
"min": 1,
|
||||||
"max": 100,
|
"max": 100_000,
|
||||||
"hidden": True,
|
"hidden": True,
|
||||||
"help": "同一账户在北京时间同一自然日内发起比价达到该次数时告警。",
|
"help": "同一账户在北京时间同一自然日内发起比价达到该次数时告警。",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
"""Unified global limits and per-phone/device policy overrides.
|
||||||
|
|
||||||
|
The registry is the single source of truth for the whitelist page. Existing
|
||||||
|
constants remain as backwards-compatible defaults, while business call sites
|
||||||
|
resolve an effective value here.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import delete, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config_schema import (
|
||||||
|
AD_FEED_DAILY_LIMIT_KEY,
|
||||||
|
AD_REWARD_VIDEO_DAILY_LIMIT_KEY,
|
||||||
|
COMPARE_DAILY_LIMIT_KEY,
|
||||||
|
GUIDE_VIDEO_MAX_PLAYS_KEY,
|
||||||
|
LIMIT_POLICY_GLOBAL_KEY,
|
||||||
|
PHONE_REBIND_DAYS_KEY,
|
||||||
|
RISK_COMPARE_DAILY_THRESHOLD_KEY,
|
||||||
|
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
||||||
|
RISK_SMS_HOURLY_THRESHOLD_KEY,
|
||||||
|
SMS_CODE_MAX_FAILED_ATTEMPTS_KEY,
|
||||||
|
SMS_LOGIN_HOURLY_LIMIT_KEY,
|
||||||
|
SMS_PHONE_COOLDOWN_SECONDS_KEY,
|
||||||
|
SMS_SEND_DAILY_LIMIT_KEY,
|
||||||
|
SMS_SEND_HOURLY_LIMIT_KEY,
|
||||||
|
WECHAT_BIND_SMS_HOURLY_LIMIT_KEY,
|
||||||
|
WECHAT_CONFLICT_HOURLY_LIMIT_KEY,
|
||||||
|
)
|
||||||
|
from app.models.app_config import AppConfig
|
||||||
|
from app.models.limit_policy import LimitPolicyOverride
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories import app_config
|
||||||
|
|
||||||
|
MODE_INHERIT = "inherit"
|
||||||
|
MODE_OVERRIDE = "override"
|
||||||
|
MODE_UNLIMITED = "unlimited"
|
||||||
|
MODE_SUPPRESS_ALERT = "suppress_alert"
|
||||||
|
|
||||||
|
SUBJECT_PHONE = "phone"
|
||||||
|
SUBJECT_DEVICE = "device"
|
||||||
|
SUBJECT_TYPES = (SUBJECT_PHONE, SUBJECT_DEVICE)
|
||||||
|
SUBJECT_PRECEDENCE = {SUBJECT_DEVICE: 0, SUBJECT_PHONE: 1}
|
||||||
|
LEGACY_IP_DEVICE_PREFIX = "legacy-ip:"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuleDefinition:
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
group: str
|
||||||
|
config_key: str
|
||||||
|
default_limit: int
|
||||||
|
window_label: str
|
||||||
|
subject_types: tuple[str, ...]
|
||||||
|
min_value: int = 1
|
||||||
|
max_value: int = 100_000
|
||||||
|
allow_unlimited: bool = True
|
||||||
|
supports_reset: bool = True
|
||||||
|
alert_only: bool = False
|
||||||
|
legacy_config_key: str | None = None
|
||||||
|
legacy_json_field: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allowed_modes(self) -> tuple[str, ...]:
|
||||||
|
if self.alert_only:
|
||||||
|
return (MODE_SUPPRESS_ALERT,)
|
||||||
|
return (MODE_UNLIMITED,) if self.allow_unlimited else ()
|
||||||
|
|
||||||
|
|
||||||
|
RULES: tuple[RuleDefinition, ...] = (
|
||||||
|
RuleDefinition(
|
||||||
|
"compare.start.daily",
|
||||||
|
"每日发起比价次数",
|
||||||
|
"比价",
|
||||||
|
COMPARE_DAILY_LIMIT_KEY,
|
||||||
|
100,
|
||||||
|
"北京时间自然日",
|
||||||
|
SUBJECT_TYPES,
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"sms.send.hourly",
|
||||||
|
"短信每小时成功发送次数",
|
||||||
|
"短信与登录",
|
||||||
|
SMS_SEND_HOURLY_LIMIT_KEY,
|
||||||
|
5,
|
||||||
|
"固定 1 小时窗口",
|
||||||
|
SUBJECT_TYPES,
|
||||||
|
max_value=10_000,
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"sms.send.daily",
|
||||||
|
"短信 24 小时成功发送次数",
|
||||||
|
"短信与登录",
|
||||||
|
SMS_SEND_DAILY_LIMIT_KEY,
|
||||||
|
20,
|
||||||
|
"固定 24 小时窗口",
|
||||||
|
SUBJECT_TYPES,
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"sms.phone.cooldown",
|
||||||
|
"同手机号短信发送冷却",
|
||||||
|
"短信与登录",
|
||||||
|
SMS_PHONE_COOLDOWN_SECONDS_KEY,
|
||||||
|
60,
|
||||||
|
"秒",
|
||||||
|
(SUBJECT_PHONE,),
|
||||||
|
min_value=0,
|
||||||
|
max_value=86_400,
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"sms.code.failed_attempts",
|
||||||
|
"单验证码最大失败次数",
|
||||||
|
"短信与登录",
|
||||||
|
SMS_CODE_MAX_FAILED_ATTEMPTS_KEY,
|
||||||
|
5,
|
||||||
|
"单个验证码",
|
||||||
|
(SUBJECT_PHONE,),
|
||||||
|
max_value=100,
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"sms.login.hourly",
|
||||||
|
"短信登录每小时尝试次数",
|
||||||
|
"短信与登录",
|
||||||
|
SMS_LOGIN_HOURLY_LIMIT_KEY,
|
||||||
|
5,
|
||||||
|
"固定 1 小时窗口",
|
||||||
|
SUBJECT_TYPES,
|
||||||
|
max_value=10_000,
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"wechat.bind.hourly",
|
||||||
|
"微信短信绑定每小时尝试次数",
|
||||||
|
"短信与登录",
|
||||||
|
WECHAT_BIND_SMS_HOURLY_LIMIT_KEY,
|
||||||
|
5,
|
||||||
|
"固定 1 小时窗口",
|
||||||
|
SUBJECT_TYPES,
|
||||||
|
max_value=10_000,
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"wechat.conflict.hourly",
|
||||||
|
"微信冲突处理每小时尝试次数",
|
||||||
|
"短信与登录",
|
||||||
|
WECHAT_CONFLICT_HOURLY_LIMIT_KEY,
|
||||||
|
5,
|
||||||
|
"固定 1 小时窗口",
|
||||||
|
SUBJECT_TYPES,
|
||||||
|
max_value=10_000,
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"ad.reward_video.daily",
|
||||||
|
"激励视频每日发奖次数",
|
||||||
|
"广告",
|
||||||
|
AD_REWARD_VIDEO_DAILY_LIMIT_KEY,
|
||||||
|
500,
|
||||||
|
"北京时间自然日",
|
||||||
|
(SUBJECT_PHONE,),
|
||||||
|
legacy_config_key="ad_daily_limit",
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"ad.feed.daily",
|
||||||
|
"Draw 信息流每日发奖次数",
|
||||||
|
"广告",
|
||||||
|
AD_FEED_DAILY_LIMIT_KEY,
|
||||||
|
500,
|
||||||
|
"北京时间自然日",
|
||||||
|
(SUBJECT_PHONE,),
|
||||||
|
legacy_config_key="ad_daily_limit",
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"ad.reward_video.cooldown",
|
||||||
|
"激励视频发奖后冷却秒数",
|
||||||
|
"广告",
|
||||||
|
"ad_cooldown_sec",
|
||||||
|
3,
|
||||||
|
"秒",
|
||||||
|
(SUBJECT_PHONE,),
|
||||||
|
min_value=0,
|
||||||
|
max_value=86_400,
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"guide.video.lifetime",
|
||||||
|
"领券引导视频最大播放次数",
|
||||||
|
"引导与账号",
|
||||||
|
GUIDE_VIDEO_MAX_PLAYS_KEY,
|
||||||
|
3,
|
||||||
|
"账号生命周期",
|
||||||
|
(SUBJECT_PHONE,),
|
||||||
|
min_value=0,
|
||||||
|
max_value=50,
|
||||||
|
legacy_config_key="coupon_guide_video",
|
||||||
|
legacy_json_field="max_plays",
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"phone.rebind.days",
|
||||||
|
"手机/微信换绑冷却天数",
|
||||||
|
"引导与账号",
|
||||||
|
PHONE_REBIND_DAYS_KEY,
|
||||||
|
30,
|
||||||
|
"自然日",
|
||||||
|
(SUBJECT_PHONE,),
|
||||||
|
min_value=0,
|
||||||
|
max_value=3650,
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"risk.sms.hourly",
|
||||||
|
"短信设备每小时告警",
|
||||||
|
"风控免告警",
|
||||||
|
RISK_SMS_HOURLY_THRESHOLD_KEY,
|
||||||
|
5,
|
||||||
|
"北京时间自然小时",
|
||||||
|
(SUBJECT_DEVICE,),
|
||||||
|
max_value=100_000,
|
||||||
|
allow_unlimited=False,
|
||||||
|
alert_only=True,
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"risk.oneclick.daily",
|
||||||
|
"一键登录设备每日告警",
|
||||||
|
"风控免告警",
|
||||||
|
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
||||||
|
20,
|
||||||
|
"北京时间自然日",
|
||||||
|
(SUBJECT_DEVICE,),
|
||||||
|
max_value=100_000,
|
||||||
|
allow_unlimited=False,
|
||||||
|
alert_only=True,
|
||||||
|
),
|
||||||
|
RuleDefinition(
|
||||||
|
"risk.compare.daily",
|
||||||
|
"比价账号每日告警",
|
||||||
|
"风控免告警",
|
||||||
|
RISK_COMPARE_DAILY_THRESHOLD_KEY,
|
||||||
|
100,
|
||||||
|
"北京时间自然日",
|
||||||
|
(SUBJECT_PHONE,),
|
||||||
|
max_value=100_000,
|
||||||
|
allow_unlimited=False,
|
||||||
|
alert_only=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
RULE_MAP = {rule.code: rule for rule in RULES}
|
||||||
|
RULE_CODE_BY_CONFIG_KEY = {rule.config_key: rule.code for rule in RULES}
|
||||||
|
LIMIT_CONFIG_KEYS = tuple(RULE_CODE_BY_CONFIG_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EffectiveLimit:
|
||||||
|
rule_code: str
|
||||||
|
global_limit: int
|
||||||
|
limit: int | None
|
||||||
|
mode: str
|
||||||
|
suppressed: bool
|
||||||
|
override_id: int | None
|
||||||
|
matched_subject_type: str | None
|
||||||
|
matched_subject_value: str | None
|
||||||
|
reset_at: datetime | None
|
||||||
|
bucket_version: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unlimited(self) -> bool:
|
||||||
|
return self.limit is None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_subject(subject_type: str, value: str) -> str:
|
||||||
|
value = (value or "").strip()
|
||||||
|
if subject_type == SUBJECT_PHONE:
|
||||||
|
value = "".join(ch for ch in value if ch.isdigit())
|
||||||
|
if subject_type not in SUBJECT_TYPES:
|
||||||
|
raise ValueError(f"unsupported subject type: {subject_type}")
|
||||||
|
if not value:
|
||||||
|
raise ValueError("subject value is empty")
|
||||||
|
return value[:128]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_whitelist_subject(subject_type: str, value: str) -> str:
|
||||||
|
"""Normalize a whitelist subject and reject unsafe pseudo-devices.
|
||||||
|
|
||||||
|
Old clients without a device ID are grouped by public IP for rate limiting.
|
||||||
|
That fallback remains valid at runtime, but it is not a stable, unique
|
||||||
|
device identity and must never be persisted as a device whitelist target.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized = normalize_subject(subject_type, value)
|
||||||
|
if (
|
||||||
|
subject_type == SUBJECT_DEVICE
|
||||||
|
and normalized.startswith(LEGACY_IP_DEVICE_PREFIX)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"旧客户端未上报真实设备 ID,不能加入设备白名单,请升级客户端后重试"
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def get_rule(rule_code: str) -> RuleDefinition:
|
||||||
|
try:
|
||||||
|
return RULE_MAP[rule_code]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"unknown rule: {rule_code}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def device_source_scope(rule_code: str) -> str:
|
||||||
|
"""返回设备候选数据所属命名空间,防止一个设备 ID 跨来源误套规则。"""
|
||||||
|
rule = get_rule(rule_code)
|
||||||
|
if SUBJECT_DEVICE not in rule.subject_types:
|
||||||
|
raise ValueError("当前限制项不支持设备白名单")
|
||||||
|
return "comparison" if rule_code == "compare.start.daily" else "auth"
|
||||||
|
|
||||||
|
|
||||||
|
def default_global_limits() -> dict[str, int]:
|
||||||
|
"""Return the complete 16-rule default snapshot keyed by rule code."""
|
||||||
|
return {rule.code: rule.default_limit for rule in RULES}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_global_limits(value: object) -> dict[str, int]:
|
||||||
|
"""Merge a stored JSON object with safe code defaults.
|
||||||
|
|
||||||
|
The migration and every admin write persist all rules. Defaults are still
|
||||||
|
merged here so a manually damaged/older partial JSON cannot take the
|
||||||
|
service down after deployment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
values = default_global_limits()
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return values
|
||||||
|
for rule_code, raw in value.items():
|
||||||
|
rule = RULE_MAP.get(str(rule_code))
|
||||||
|
if rule is None or isinstance(raw, bool):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if rule.min_value <= parsed <= rule.max_value:
|
||||||
|
values[rule.code] = parsed
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_global_limit(db: Session, rule: RuleDefinition) -> tuple[int, str]:
|
||||||
|
"""Read the pre-bundle representation while upgrading old/test databases."""
|
||||||
|
|
||||||
|
row = db.get(AppConfig, rule.config_key)
|
||||||
|
if row is not None:
|
||||||
|
return int(row.value), "legacy-key"
|
||||||
|
|
||||||
|
if rule.legacy_config_key:
|
||||||
|
legacy = db.get(AppConfig, rule.legacy_config_key)
|
||||||
|
if legacy is not None:
|
||||||
|
value = legacy.value
|
||||||
|
if rule.legacy_json_field:
|
||||||
|
value = value.get(rule.legacy_json_field) if isinstance(value, dict) else None
|
||||||
|
if value is not None:
|
||||||
|
return int(value), "legacy"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return int(app_config.get_value(db, rule.config_key)), "default"
|
||||||
|
except KeyError:
|
||||||
|
return rule.default_limit, "default"
|
||||||
|
|
||||||
|
|
||||||
|
def get_global_limits(db: Session) -> dict[str, int]:
|
||||||
|
"""Read the complete global-limit JSON, with a pre-migration fallback."""
|
||||||
|
|
||||||
|
row = db.get(AppConfig, LIMIT_POLICY_GLOBAL_KEY)
|
||||||
|
if row is not None:
|
||||||
|
return _normalise_global_limits(row.value)
|
||||||
|
return {rule.code: _legacy_global_limit(db, rule)[0] for rule in RULES}
|
||||||
|
|
||||||
|
|
||||||
|
def set_global_limits(
|
||||||
|
db: Session,
|
||||||
|
updates: dict[str, int],
|
||||||
|
*,
|
||||||
|
admin_id: int,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Atomically update selected rules inside the single complete JSON row."""
|
||||||
|
|
||||||
|
parsed_updates: dict[str, int] = {}
|
||||||
|
for rule_code, raw_value in updates.items():
|
||||||
|
rule = get_rule(rule_code)
|
||||||
|
value = int(raw_value)
|
||||||
|
if not rule.min_value <= value <= rule.max_value:
|
||||||
|
raise ValueError(
|
||||||
|
f"limit for {rule_code} must be between "
|
||||||
|
f"{rule.min_value} and {rule.max_value}"
|
||||||
|
)
|
||||||
|
parsed_updates[rule.code] = value
|
||||||
|
|
||||||
|
row = db.scalar(
|
||||||
|
select(AppConfig)
|
||||||
|
.where(AppConfig.key == LIMIT_POLICY_GLOBAL_KEY)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
values = (
|
||||||
|
_normalise_global_limits(row.value)
|
||||||
|
if row is not None
|
||||||
|
else {rule.code: _legacy_global_limit(db, rule)[0] for rule in RULES}
|
||||||
|
)
|
||||||
|
values.update(parsed_updates)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
row = AppConfig(
|
||||||
|
key=LIMIT_POLICY_GLOBAL_KEY,
|
||||||
|
value=values,
|
||||||
|
updated_by_admin_id=admin_id,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.value = dict(values)
|
||||||
|
row.updated_by_admin_id = admin_id
|
||||||
|
|
||||||
|
# Once the bundle exists, stale sparse rows must not become a second source
|
||||||
|
# of truth. The data migration performs the same cleanup for production.
|
||||||
|
db.execute(delete(AppConfig).where(AppConfig.key.in_(LIMIT_CONFIG_KEYS)))
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
else:
|
||||||
|
db.flush()
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _global_limit(db: Session, rule: RuleDefinition) -> tuple[int, str]:
|
||||||
|
row = db.get(AppConfig, LIMIT_POLICY_GLOBAL_KEY)
|
||||||
|
if row is not None:
|
||||||
|
return _normalise_global_limits(row.value)[rule.code], "configured"
|
||||||
|
return _legacy_global_limit(db, rule)
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_overrides(
|
||||||
|
db: Session,
|
||||||
|
rule: RuleDefinition,
|
||||||
|
subjects: dict[str, str | None],
|
||||||
|
now: datetime,
|
||||||
|
) -> list[LimitPolicyOverride]:
|
||||||
|
pairs: list[tuple[str, str]] = []
|
||||||
|
for subject_type in rule.subject_types:
|
||||||
|
raw = subjects.get(subject_type)
|
||||||
|
if raw:
|
||||||
|
pairs.append((subject_type, normalize_subject(subject_type, raw)))
|
||||||
|
if not pairs:
|
||||||
|
return []
|
||||||
|
clauses = [
|
||||||
|
(
|
||||||
|
(LimitPolicyOverride.subject_type == subject_type)
|
||||||
|
& (LimitPolicyOverride.subject_value == subject_value)
|
||||||
|
)
|
||||||
|
for subject_type, subject_value in pairs
|
||||||
|
]
|
||||||
|
rows = list(
|
||||||
|
db.execute(
|
||||||
|
select(LimitPolicyOverride).where(
|
||||||
|
LimitPolicyOverride.rule_code == rule.code,
|
||||||
|
LimitPolicyOverride.enabled.is_(True),
|
||||||
|
or_(*clauses),
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
active = [
|
||||||
|
row
|
||||||
|
for row in rows
|
||||||
|
if (_aware(row.starts_at) is None or _aware(row.starts_at) <= now)
|
||||||
|
and (_aware(row.expires_at) is None or _aware(row.expires_at) > now)
|
||||||
|
]
|
||||||
|
return sorted(active, key=lambda row: SUBJECT_PRECEDENCE[row.subject_type])
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
db: Session,
|
||||||
|
rule_code: str,
|
||||||
|
*,
|
||||||
|
phone: str | None = None,
|
||||||
|
device: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> EffectiveLimit:
|
||||||
|
"""Resolve global config plus the most specific active override.
|
||||||
|
|
||||||
|
Device overrides win over phone overrides when both match.
|
||||||
|
"""
|
||||||
|
|
||||||
|
rule = get_rule(rule_code)
|
||||||
|
now = _aware(now) or datetime.now(UTC)
|
||||||
|
global_limit, global_version = _global_limit(db, rule)
|
||||||
|
matches = _matching_overrides(
|
||||||
|
db, rule, {SUBJECT_PHONE: phone, SUBJECT_DEVICE: device}, now
|
||||||
|
)
|
||||||
|
row = matches[0] if matches else None
|
||||||
|
if row is None:
|
||||||
|
return EffectiveLimit(
|
||||||
|
rule.code,
|
||||||
|
global_limit,
|
||||||
|
global_limit,
|
||||||
|
MODE_INHERIT,
|
||||||
|
False,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
global_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
limit: int | None = global_limit
|
||||||
|
suppressed = False
|
||||||
|
if row.mode == MODE_OVERRIDE:
|
||||||
|
limit = int(row.limit_value) if row.limit_value is not None else global_limit
|
||||||
|
elif row.mode == MODE_UNLIMITED:
|
||||||
|
limit = None
|
||||||
|
elif row.mode == MODE_SUPPRESS_ALERT:
|
||||||
|
suppressed = True
|
||||||
|
# 编辑限制值/备注不应隐式清空计数;只有显式“重置状态”才切换桶。
|
||||||
|
reset_version = _aware(row.reset_at)
|
||||||
|
version = (
|
||||||
|
f"{global_version}:o:{row.id}:"
|
||||||
|
f"{reset_version.isoformat() if reset_version else '0'}"
|
||||||
|
)
|
||||||
|
return EffectiveLimit(
|
||||||
|
rule.code,
|
||||||
|
global_limit,
|
||||||
|
limit,
|
||||||
|
row.mode,
|
||||||
|
suppressed,
|
||||||
|
row.id,
|
||||||
|
row.subject_type,
|
||||||
|
row.subject_value,
|
||||||
|
_aware(row.reset_at),
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_for_user(
|
||||||
|
db: Session,
|
||||||
|
rule_code: str,
|
||||||
|
user_id: int,
|
||||||
|
*,
|
||||||
|
device: str | None = None,
|
||||||
|
) -> EffectiveLimit:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
return resolve(
|
||||||
|
db,
|
||||||
|
rule_code,
|
||||||
|
phone=user.phone if user is not None else None,
|
||||||
|
device=device,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rule_catalog(db: Session) -> list[dict]:
|
||||||
|
out: list[dict] = []
|
||||||
|
values = get_global_limits(db)
|
||||||
|
for rule in RULES:
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"code": rule.code,
|
||||||
|
"label": rule.label,
|
||||||
|
"group": rule.group,
|
||||||
|
"global_limit": values[rule.code],
|
||||||
|
"default_limit": rule.default_limit,
|
||||||
|
"window_label": rule.window_label,
|
||||||
|
"subject_types": list(rule.subject_types),
|
||||||
|
"allowed_modes": list(rule.allowed_modes),
|
||||||
|
"min_value": rule.min_value,
|
||||||
|
"max_value": rule.max_value,
|
||||||
|
"supports_reset": rule.supports_reset,
|
||||||
|
"alert_only": rule.alert_only,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def validate_override(
|
||||||
|
rule: RuleDefinition,
|
||||||
|
*,
|
||||||
|
subject_type: str,
|
||||||
|
mode: str,
|
||||||
|
limit_value: int | None,
|
||||||
|
starts_at: datetime | None,
|
||||||
|
expires_at: datetime | None,
|
||||||
|
) -> None:
|
||||||
|
if subject_type not in rule.subject_types:
|
||||||
|
raise ValueError("该规则不支持此主体类型")
|
||||||
|
if mode not in rule.allowed_modes:
|
||||||
|
raise ValueError("该规则不支持此策略模式")
|
||||||
|
if limit_value is not None:
|
||||||
|
raise ValueError("白名单不支持覆盖指定值")
|
||||||
|
if mode in {MODE_UNLIMITED, MODE_SUPPRESS_ALERT} and expires_at is None:
|
||||||
|
raise ValueError("临时白名单必须设置失效时间")
|
||||||
|
if starts_at and expires_at and _aware(expires_at) <= _aware(starts_at):
|
||||||
|
raise ValueError("失效时间必须晚于生效时间")
|
||||||
|
if (
|
||||||
|
mode in {MODE_UNLIMITED, MODE_SUPPRESS_ALERT}
|
||||||
|
and expires_at is not None
|
||||||
|
and _aware(expires_at) <= datetime.now(UTC)
|
||||||
|
):
|
||||||
|
raise ValueError("临时白名单的失效时间必须晚于当前时间")
|
||||||
|
if mode == MODE_SUPPRESS_ALERT and not rule.alert_only:
|
||||||
|
raise ValueError("免告警只支持风控监控的三项规则")
|
||||||
|
|
||||||
|
|
||||||
|
def active_overrides(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
subject_type: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
) -> Iterable[LimitPolicyOverride]:
|
||||||
|
stmt = select(LimitPolicyOverride)
|
||||||
|
if subject_type:
|
||||||
|
stmt = stmt.where(LimitPolicyOverride.subject_type == subject_type)
|
||||||
|
if keyword:
|
||||||
|
stmt = stmt.where(LimitPolicyOverride.subject_value.ilike(f"%{keyword.strip()}%"))
|
||||||
|
return db.execute(
|
||||||
|
stmt.order_by(LimitPolicyOverride.updated_at.desc(), LimitPolicyOverride.id.desc())
|
||||||
|
).scalars()
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Periodic repair worker for comparison records with missing LLM token cost."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.comparison_llm_backfill import repair_missing_comparison_llm_costs
|
||||||
|
from app.services.pricebot_llm_calls import pricebot_llm_auth_ready
|
||||||
|
|
||||||
|
logger = logging.getLogger("shagua.llm_cost_backfill_worker")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_loop() -> None:
|
||||||
|
interval = max(60, int(settings.LLM_COST_BACKFILL_INTERVAL_SEC))
|
||||||
|
logger.info(
|
||||||
|
"LLM cost backfill worker started interval=%ss batch=%s lookback_days=%s",
|
||||||
|
interval,
|
||||||
|
settings.LLM_COST_BACKFILL_BATCH_SIZE,
|
||||||
|
settings.LLM_COST_BACKFILL_LOOKBACK_DAYS,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
auth_ready = await asyncio.to_thread(pricebot_llm_auth_ready)
|
||||||
|
if auth_ready:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
repair_missing_comparison_llm_costs,
|
||||||
|
limit=settings.LLM_COST_BACKFILL_BATCH_SIZE,
|
||||||
|
lookback_days=settings.LLM_COST_BACKFILL_LOOKBACK_DAYS,
|
||||||
|
)
|
||||||
|
logger.info("LLM cost backfill batch result=%s", result)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"LLM cost backfill skipped: PriceBot internal auth is not ready"
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("LLM cost backfill batch failed")
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("LLM cost backfill worker stopped")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def start_llm_cost_backfill_worker() -> asyncio.Task | None:
|
||||||
|
if not settings.LLM_COST_BACKFILL_ENABLED:
|
||||||
|
logger.info("LLM cost backfill worker disabled")
|
||||||
|
return None
|
||||||
|
if not settings.INTERNAL_API_SECRET:
|
||||||
|
logger.warning(
|
||||||
|
"LLM cost backfill worker not started: INTERNAL_API_SECRET is empty"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return asyncio.create_task(_run_loop(), name="llm-cost-backfill")
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_llm_cost_backfill_worker(task: asyncio.Task | None) -> None:
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
+76
-9
@@ -10,8 +10,13 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
@@ -81,29 +86,91 @@ def _sniff_video_ext(data: bytes) -> str | None:
|
|||||||
"""按魔数判定视频类型,返回扩展名;非支持类型返回 None。
|
"""按魔数判定视频类型,返回扩展名;非支持类型返回 None。
|
||||||
|
|
||||||
只认 MP4 家族(ISO BMFF):`....ftyp` 在偏移 4。Android ExoPlayer 与浏览器 <video>
|
只认 MP4 家族(ISO BMFF):`....ftyp` 在偏移 4。Android ExoPlayer 与浏览器 <video>
|
||||||
都稳吃 H.264/AAC 的 mp4;放开 mkv/avi 只会让端上放不出来,不如在入口就挡掉。
|
支持 H.264 或 HEVC/H.265 视频及 AAC 音频;放开 mkv/avi 只会让端上放不出来,
|
||||||
|
不如在入口就挡掉。
|
||||||
"""
|
"""
|
||||||
if len(data) >= 12 and data[4:8] == b"ftyp":
|
if len(data) >= 12 and data[4:8] == b"ftyp":
|
||||||
return ".mp4"
|
return ".mp4"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def save_guide_video(data: bytes) -> str:
|
def _probe_guide_video(path: Path) -> dict[str, Any]:
|
||||||
"""保存新手引导视频(运营后台上传的运营素材),返回相对 URL(`/media/guide_video/<file>`)。
|
"""用 ffprobe 读取服务端可信的时长/编码信息。"""
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
settings.FFPROBE_BINARY,
|
||||||
|
"-v", "error",
|
||||||
|
"-show_format",
|
||||||
|
"-show_streams",
|
||||||
|
"-of", "json",
|
||||||
|
str(path),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise MediaError("服务器未安装 ffprobe,暂时无法分析视频") from exc
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
raise MediaError("ffprobe 分析视频失败") from exc
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise MediaError("无法分析视频,请确认文件是有效的 MP4")
|
||||||
|
try:
|
||||||
|
payload = json.loads(proc.stdout)
|
||||||
|
streams = payload.get("streams") or []
|
||||||
|
video = next(s for s in streams if s.get("codec_type") == "video")
|
||||||
|
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
||||||
|
raw_duration = (
|
||||||
|
(payload.get("format") or {}).get("duration")
|
||||||
|
or video.get("duration")
|
||||||
|
)
|
||||||
|
duration_ms = int(
|
||||||
|
(Decimal(str(raw_duration)) * 1000).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||||
|
)
|
||||||
|
except (StopIteration, TypeError, ValueError, InvalidOperation) as exc:
|
||||||
|
raise MediaError("视频缺少可识别的视频轨或时长") from exc
|
||||||
|
video_codec = str(video.get("codec_name") or "").lower()
|
||||||
|
audio_codec = str(audio.get("codec_name") or "").lower() if audio else None
|
||||||
|
if not 30_000 <= duration_ms <= 180_000:
|
||||||
|
raise MediaError("视频时长必须在 30~180 秒之间")
|
||||||
|
if video_codec not in ("h264", "hevc", "h265"):
|
||||||
|
raise MediaError("视频编码必须为 H.264 或 HEVC/H.265")
|
||||||
|
if audio_codec not in (None, "aac"):
|
||||||
|
raise MediaError("音频编码必须为 AAC")
|
||||||
|
return {
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"video_codec": video_codec,
|
||||||
|
"audio_codec": audio_codec,
|
||||||
|
"analysis_status": "valid",
|
||||||
|
"analysis_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
与图片分开一套校验:体积上限走 [settings.GUIDE_VIDEO_MAX_BYTES],类型只认 MP4。
|
|
||||||
"""
|
def save_guide_video(data: bytes) -> tuple[str, dict[str, Any]]:
|
||||||
|
"""临时落盘、ffprobe 校验后原子发布,返回 URL 与分析结果。"""
|
||||||
if not data:
|
if not data:
|
||||||
raise MediaError("空文件")
|
raise MediaError("空文件")
|
||||||
limit = settings.GUIDE_VIDEO_MAX_BYTES
|
limit = settings.GUIDE_VIDEO_MAX_BYTES
|
||||||
if len(data) > limit:
|
if len(data) > limit:
|
||||||
raise MediaError(f"视频过大(上限 {limit // (1024 * 1024)}MB)")
|
raise MediaError(f"视频过大(上限 {limit // (1024 * 1024)}MB)")
|
||||||
if _sniff_video_ext(data) is None:
|
if _sniff_video_ext(data) is None:
|
||||||
raise MediaError("仅支持 MP4 视频(H.264 编码)")
|
raise MediaError("仅支持 MP4 视频(H.264 或 HEVC/H.265 编码)")
|
||||||
|
|
||||||
fname = f"guide_{secrets.token_hex(8)}.mp4"
|
directory = _media_dir("guide_video")
|
||||||
(_media_dir("guide_video") / fname).write_bytes(data)
|
stem = f"guide_{secrets.token_hex(8)}"
|
||||||
return f"{settings.MEDIA_URL_PREFIX}/guide_video/{fname}"
|
temp_path = directory / f".{stem}.upload"
|
||||||
|
final_path = directory / f"{stem}.mp4"
|
||||||
|
try:
|
||||||
|
temp_path.write_bytes(data)
|
||||||
|
analysis = _probe_guide_video(temp_path)
|
||||||
|
os.replace(temp_path, final_path)
|
||||||
|
except Exception:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
final_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
return f"{settings.MEDIA_URL_PREFIX}/guide_video/{final_path.name}", analysis
|
||||||
|
|
||||||
|
|
||||||
def save_cps_image(admin_id: int, data: bytes) -> str:
|
def save_cps_image(admin_id: int, data: bytes) -> str:
|
||||||
|
|||||||
+19
-6
@@ -75,10 +75,11 @@ def enforce_rate_limit(
|
|||||||
request: Request,
|
request: Request,
|
||||||
scope: str,
|
scope: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
limit: int,
|
limit: int | None,
|
||||||
window_sec: float,
|
window_sec: float,
|
||||||
*,
|
*,
|
||||||
detail: str = "操作过于频繁,请稍后再试",
|
detail: str = "操作过于频繁,请稍后再试",
|
||||||
|
bucket_suffix: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""在路由内部手动限流,按 (subject, 客户端 IP) 计数。
|
"""在路由内部手动限流,按 (subject, 客户端 IP) 计数。
|
||||||
|
|
||||||
@@ -87,9 +88,9 @@ def enforce_rate_limit(
|
|||||||
key = `scope:subject:client_ip`;同一 (subject, IP) 在 window_sec 内超过 limit 次 → 抛 429。
|
key = `scope:subject:client_ip`;同一 (subject, IP) 在 window_sec 内超过 limit 次 → 抛 429。
|
||||||
受 [settings.RATE_LIMIT_ENABLED] 总开关控制(与 [rate_limit] 一致)。
|
受 [settings.RATE_LIMIT_ENABLED] 总开关控制(与 [rate_limit] 一致)。
|
||||||
"""
|
"""
|
||||||
if not settings.RATE_LIMIT_ENABLED:
|
if not settings.RATE_LIMIT_ENABLED or limit is None:
|
||||||
return
|
return
|
||||||
key = f"{scope}:{subject}:{_client_ip(request)}"
|
key = f"{scope}:{subject}:{_client_ip(request)}:{bucket_suffix}"
|
||||||
if not _hit(key, limit, window_sec):
|
if not _hit(key, limit, window_sec):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
@@ -112,9 +113,10 @@ class RateLimitRule(NamedTuple):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
scope: str
|
scope: str
|
||||||
limit: int
|
limit: int | None
|
||||||
window_sec: float
|
window_sec: float
|
||||||
detail: str = "操作过于频繁,请稍后再试"
|
detail: str = "操作过于频繁,请稍后再试"
|
||||||
|
bucket_suffix: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _peek(key: str, limit: int, window_sec: float) -> bool:
|
def _peek(key: str, limit: int, window_sec: float) -> bool:
|
||||||
@@ -150,7 +152,13 @@ def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]
|
|||||||
return
|
return
|
||||||
ip = _client_ip(request)
|
ip = _client_ip(request)
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec):
|
if rule.limit is None:
|
||||||
|
continue
|
||||||
|
if not _peek(
|
||||||
|
f"{rule.scope}:{subject}:{ip}:{rule.bucket_suffix}",
|
||||||
|
rule.limit,
|
||||||
|
rule.window_sec,
|
||||||
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
detail=rule.detail,
|
detail=rule.detail,
|
||||||
@@ -167,4 +175,9 @@ def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule
|
|||||||
return
|
return
|
||||||
ip = _client_ip(request)
|
ip = _client_ip(request)
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
_commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec)
|
if rule.limit is None:
|
||||||
|
continue
|
||||||
|
_commit(
|
||||||
|
f"{rule.scope}:{subject}:{ip}:{rule.bucket_suffix}",
|
||||||
|
rule.window_sec,
|
||||||
|
)
|
||||||
|
|||||||
+11
-1
@@ -233,10 +233,20 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i
|
|||||||
eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值
|
eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值
|
||||||
会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
|
会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
|
||||||
所有真实值,不影响正规发奖。
|
所有真实值,不影响正规发奖。
|
||||||
|
|
||||||
|
下限(2026-08,产品口径「看了就保底 1」):**任何输入**都至少 1 金币,与前端展示公式
|
||||||
|
FeedRewardFormula.singleUnitCoin 完全对齐(那边注释:"无论 eCPM 是否为空、非法或非正数,
|
||||||
|
单条广告最低都发 1 金币,不能出现 +0")。此前 eCPM 缺失/为 0 返 0,造成两端不一致:
|
||||||
|
小球显示 +1、后端信息流记 too_short 零发;激励视频侧 "0" 字符串还是 truthy、绕过
|
||||||
|
ecpm_missing 的 `if not ecpm_raw` 判定,落成 granted 0 币且白占当日额度/LT 计数。
|
||||||
|
防刷影响:伪造 eCPM≤0 每天至多多骗 每日上限×1 金币(500 金币=0.05 元),量级可控;
|
||||||
|
天价伪造仍由 AD_ECPM_MAX_FEN 钳顶把守。
|
||||||
"""
|
"""
|
||||||
ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
|
ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
|
||||||
|
if ecpm_yuan <= 0:
|
||||||
|
return 1 # 保底:缺失/为 0/非法也发 1(镜像前端 validEcpmFen 判非法 → 直接返 1)
|
||||||
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
|
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
|
||||||
return max(0, round(yuan * COIN_PER_YUAN))
|
return max(1, round(yuan * COIN_PER_YUAN))
|
||||||
|
|
||||||
|
|
||||||
# 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认
|
# 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认
|
||||||
|
|||||||
+15
-16
@@ -9,9 +9,8 @@
|
|||||||
3. **每日使用次数上限**:防被人猜到这个号后写脚本一直刷。当天登录数超过上限即拒绝(429),
|
3. **每日使用次数上限**:防被人猜到这个号后写脚本一直刷。当天登录数超过上限即拒绝(429),
|
||||||
次日自动归零。
|
次日自动归零。
|
||||||
|
|
||||||
手机号与上限都在 .env 配(`TEST_ACCOUNT_PHONE` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改。
|
手机号与上限都在 .env 配(`TEST_ACCOUNT_PHONES` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改。
|
||||||
`TEST_ACCOUNT_PHONE` 留空 = 整个功能关闭(生产默认态),`is_test_account()` 对任何号都返回
|
兼容旧的单号配置 `TEST_ACCOUNT_PHONE`;两项都留空 = 整个功能关闭(生产默认态)。
|
||||||
False,登录/短信回到原逻辑,零影响。
|
|
||||||
|
|
||||||
计数存**进程内存**(单 worker 够用,与 sms.py 同款约定):重启清零、多 worker 不共享。作为
|
计数存**进程内存**(单 worker 够用,与 sms.py 同款约定):重启清零、多 worker 不共享。作为
|
||||||
一个测试号的粗粒度防滥用闸够用;且因所有登录都落同一个 phone → 同一个 user,滥用面天然只
|
一个测试号的粗粒度防滥用闸够用;且因所有登录都落同一个 phone → 同一个 user,滥用面天然只
|
||||||
@@ -27,9 +26,9 @@ from app.core.config import settings
|
|||||||
|
|
||||||
logger = logging.getLogger("shagua.test_account")
|
logger = logging.getLogger("shagua.test_account")
|
||||||
|
|
||||||
# 进程内每日计数:(date_str, 当日已登录次数)。单 worker 有效,重启清零(见模块 docstring)。
|
# 进程内每日计数:{手机号: (date_str, 当日已登录次数)}。单 worker 有效,重启清零。
|
||||||
_lock = Lock()
|
_lock = Lock()
|
||||||
_usage: tuple[str, int] = ("", 0)
|
_usage: dict[str, tuple[str, int]] = {}
|
||||||
|
|
||||||
|
|
||||||
def _today() -> str:
|
def _today() -> str:
|
||||||
@@ -37,16 +36,16 @@ def _today() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def is_enabled() -> bool:
|
def is_enabled() -> bool:
|
||||||
"""功能总开关:配了 TEST_ACCOUNT_PHONE 才启用(空=关闭)。"""
|
"""功能总开关:至少配置了一个测试手机号才启用。"""
|
||||||
return bool(settings.test_account_phone)
|
return bool(settings.test_account_phones)
|
||||||
|
|
||||||
|
|
||||||
def is_test_account(phone: str) -> bool:
|
def is_test_account(phone: str) -> bool:
|
||||||
"""该手机号是否为配置的测试账号。功能关闭时对任何号都返回 False。"""
|
"""该手机号是否在测试账号集合中。"""
|
||||||
return is_enabled() and phone == settings.test_account_phone
|
return phone in settings.test_account_phones
|
||||||
|
|
||||||
|
|
||||||
def try_consume_quota() -> bool:
|
def try_consume_quota(phone: str) -> bool:
|
||||||
"""测试账号登录时调:当日计数 +1。
|
"""测试账号登录时调:当日计数 +1。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -59,17 +58,17 @@ def try_consume_quota() -> bool:
|
|||||||
limit = settings.TEST_ACCOUNT_DAILY_LIMIT
|
limit = settings.TEST_ACCOUNT_DAILY_LIMIT
|
||||||
with _lock:
|
with _lock:
|
||||||
today = _today()
|
today = _today()
|
||||||
day, cnt = _usage
|
day, cnt = _usage.get(phone, ("", 0))
|
||||||
if day != today: # 跨天归零
|
if day != today: # 跨天归零
|
||||||
cnt = 0
|
cnt = 0
|
||||||
if cnt >= limit:
|
if cnt >= limit:
|
||||||
_usage = (today, cnt) # 已满,保持不变
|
_usage[phone] = (today, cnt) # 已满,保持不变
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"测试账号 %s 今日登录数已达上限 %d,拒绝", settings.test_account_phone, limit
|
"测试账号 %s 今日登录数已达上限 %d,拒绝", phone, limit
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
_usage = (today, cnt + 1)
|
_usage[phone] = (today, cnt + 1)
|
||||||
logger.info("测试账号 %s 第 %d/%d 次登录", settings.test_account_phone, cnt + 1, limit)
|
logger.info("测试账号 %s 第 %d/%d 次登录", phone, cnt + 1, limit)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -77,4 +76,4 @@ def _reset_for_test() -> None:
|
|||||||
"""仅供单测:清空进程内计数,隔离用例间状态。"""
|
"""仅供单测:清空进程内计数,隔离用例间状态。"""
|
||||||
global _usage
|
global _usage
|
||||||
with _lock:
|
with _lock:
|
||||||
_usage = ("", 0)
|
_usage = {}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""trace_id 签发(全后端唯一签发口径, 2026-07 起替代裸 uuid4)。
|
||||||
|
|
||||||
|
格式: "YYYYMMDD_HHMMSS_" + 12 位小写 hex 随机, 共 28 字符, 如
|
||||||
|
20260731_162254_a1b2c3d4e5f6
|
||||||
|
|
||||||
|
Why 带时间前缀: pricebot 落盘目录名/trace_url 尾段**直接用 trace_id 本身**
|
||||||
|
(见 pricebot app/utils/trace_ids.py), id/目录/URL 三者合一——此前 uuid trace_id
|
||||||
|
与 {首帧时刻}_{uuid[:16]} 目录名是两套标识, URL 只有 pricebot 能拼、按前缀反查
|
||||||
|
还有同秒歧义。时间用北京时间(CN_TZ)——不依赖各机器 TZ 配置, 与业务时区一致。
|
||||||
|
|
||||||
|
唯一性: 秒级前缀 + 48bit 随机(hex12), 同一秒内碰撞概率可忽略(比价/领券发起 QPS
|
||||||
|
远低于产生生日碰撞的量级); pricebot 侧同秒多 trace 靠随机段区分(目录精确匹配、
|
||||||
|
llm jsonl 按尾 12 分文件, 不做前缀模糊匹配)。
|
||||||
|
|
||||||
|
兼容: 三个签发点(compare/start、coupon/session started、compare.py _forward mint)
|
||||||
|
统一走这里; 客户端自带 trace_id(老客户端/重试幂等)仍原样沿用——pricebot 对老
|
||||||
|
uuid 格式保持既有目录/短标识行为, 两代 id 并行不冲突。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.rewards import CN_TZ
|
||||||
|
|
||||||
|
|
||||||
|
def new_trace_id() -> str:
|
||||||
|
"""签发一个自描述 trace_id: 北京时间前缀 + 12 位 hex 随机。"""
|
||||||
|
return f"{datetime.now(CN_TZ):%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:12]}"
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
- 只返回【GroMore 聚合代码位】在 GroMore 内的数据(=我们 useMediation 的口径),
|
- 只返回【GroMore 聚合代码位】在 GroMore 内的数据(=我们 useMediation 的口径),
|
||||||
查不到穿山甲 SDK 自身的数据;
|
查不到穿山甲 SDK 自身的数据;
|
||||||
- **不提供分用户/设备维度**(官方 FAQ 明确拒绝),最细到 日期×应用×代码位×广告源;
|
- **不提供分用户/设备维度**(官方 FAQ 明确拒绝),最细到 日期×应用×代码位×广告源;
|
||||||
- `revenue` = 排序价/竞价实时价预估(元,非结算收入);`api_revenue` = 各 ADN 经 Reporting
|
- `revenue` = 预估收益(元,所有 ADN 都有);`api_revenue` = 收益Api(各 ADN 经 Reporting
|
||||||
回传、按实时汇率折算账号币种的收益,需后台为该 ADN 配置 Reporting 才有、且不支持当天;
|
回传、按实时汇率折算账号币种,更接近结算),需后台为该 ADN 配置 Reporting 才有、且不支持当天;
|
||||||
- 「今天」与「今天以前」必须分开查;天级跨度 ≤ 1 个月、不早于 12 个月。
|
- 「今天」与「今天以前」必须分开查;天级跨度 ≤ 1 个月、不早于 12 个月。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""短信验证码服务 —— provider 分派入口(极光主 + 可选创蓝备)。
|
||||||
|
|
||||||
|
对外暴露 `send_code`(返回 SendResult) / `verify_code` / `SmsError` / `SendResult`。
|
||||||
|
主 provider = settings.SMS_PROVIDER(默认 jiguang);备 = settings.SMS_FALLBACK_PROVIDER
|
||||||
|
(默认空=无备)。**每次调用读 settings**,支持运行时切换 / 灰度回退。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
from . import aliyun, chuanglan, jiguang
|
||||||
|
from .base import SendResult, SmsError
|
||||||
|
|
||||||
|
__all__ = ["SmsError", "SendResult", "send_code", "verify_code"]
|
||||||
|
|
||||||
|
logger = logging.getLogger("shagua.sms")
|
||||||
|
|
||||||
|
# provider 名 <-> 模块。未知/缺省主 provider 回退 jiguang(防误配把登录打挂)。
|
||||||
|
_ALL = {"jiguang": jiguang, "aliyun": aliyun, "chuanglan": chuanglan}
|
||||||
|
_NAME = {jiguang: "jiguang", aliyun: "aliyun", chuanglan: "chuanglan"}
|
||||||
|
|
||||||
|
|
||||||
|
def _primary():
|
||||||
|
"""当前主 provider 模块(未知/缺省回退 jiguang)。"""
|
||||||
|
return _ALL.get(settings.SMS_PROVIDER, jiguang)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback():
|
||||||
|
"""启用的备 provider 模块;未配置 / 同主 / 未知名 → None(= 不启用 fallback)。"""
|
||||||
|
name = (settings.SMS_FALLBACK_PROVIDER or "").strip()
|
||||||
|
if not name or name == settings.SMS_PROVIDER:
|
||||||
|
return None
|
||||||
|
return _ALL.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_with_cooldown(provider, phone: str, cooldown_sec: int | None) -> int:
|
||||||
|
if cooldown_sec is None:
|
||||||
|
return provider.send_code(phone)
|
||||||
|
return provider.send_code(phone, cooldown_sec=cooldown_sec)
|
||||||
|
|
||||||
|
|
||||||
|
def send_code(
|
||||||
|
phone: str,
|
||||||
|
*,
|
||||||
|
cooldown_sec: int | None = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""发码:主成功即返回;仅主「供应商不可用(503)」且配置了备时转备补发。
|
||||||
|
|
||||||
|
429(本地冷却/超频)、400(手机号无效)不转——不绕过防刷、不为无效号白烧。
|
||||||
|
备也失败则抛备的 SmsError。调用方传入的动态冷却值在主、备渠道保持一致。
|
||||||
|
"""
|
||||||
|
primary = _primary()
|
||||||
|
fb = _fallback()
|
||||||
|
try:
|
||||||
|
cooldown = _send_with_cooldown(primary, phone, cooldown_sec)
|
||||||
|
return SendResult(cooldown_sec=cooldown, provider=_NAME[primary], fallback=False)
|
||||||
|
except SmsError as e:
|
||||||
|
if fb is not None and e.status_code == 503:
|
||||||
|
logger.warning("[SMS] primary=%s 不可用(%s),fallback→%s",
|
||||||
|
_NAME[primary], e, _NAME[fb])
|
||||||
|
cooldown = _send_with_cooldown(fb, phone, cooldown_sec)
|
||||||
|
return SendResult(cooldown_sec=cooldown, provider=_NAME[fb], fallback=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def verify_code(
|
||||||
|
phone: str,
|
||||||
|
code: str,
|
||||||
|
*,
|
||||||
|
max_failed_attempts: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""校验:try-both,遍历「启用的 fallback 链」(主→备),任一命中即 True。
|
||||||
|
|
||||||
|
码只存在实际发码那家(fallback 前主已 pop 掉自己的码),另一家 rec is None 即 False、
|
||||||
|
不误判、不累加其防爆破计数。动态失败次数上限会一致传给主备渠道。
|
||||||
|
"""
|
||||||
|
chain = [_primary()]
|
||||||
|
fb = _fallback()
|
||||||
|
if fb is not None:
|
||||||
|
chain.append(fb)
|
||||||
|
for prov in chain:
|
||||||
|
verified = (
|
||||||
|
prov.verify_code(phone, code)
|
||||||
|
if max_failed_attempts is None
|
||||||
|
else prov.verify_code(
|
||||||
|
phone,
|
||||||
|
code,
|
||||||
|
max_failed_attempts=max_failed_attempts,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if verified:
|
||||||
|
logger.info("[SMS] verify hit provider=%s", _NAME[prov])
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""阿里云号码认证(dypns)短信 provider —— Mode A(阿里云托管验证码)。
|
||||||
|
|
||||||
|
与极光(自管码)最大不同:**本服务不生成/不存储验证码**,验证码由阿里云生成+存储+下发+校验。
|
||||||
|
- 发码:调 SendSmsVerifyCode,TemplateParam 用 `{"code":"##code##","min":...}` 占位,阿里云生成。
|
||||||
|
- 校验:调 CheckSmsVerifyCode,阿里云返回 PASS / UNKNOWN。核验免费。
|
||||||
|
→ 天然消除极光路径「内存存码、多 worker 不共享」的技术债(发码/校验可落不同 worker,阿里云统一裁决)。
|
||||||
|
|
||||||
|
**唯一本地态**:per-phone 连续失败计数(`_verify_attempts`),用于复刻极光「单码失败
|
||||||
|
`SMS_MAX_VERIFY_ATTEMPTS` 次即作废」的防爆破语义 —— 刻意与极光一致,避免两 provider 行为不同
|
||||||
|
导致排查困惑。其多 worker 降级特性与极光现状同级;另有 API 层登录频控(设备+IP)做硬兜底。
|
||||||
|
|
||||||
|
单号发送频控(冷却)交给阿里云 `Interval` 参数(命中→FREQUENCY_FAIL→429),本地不再维护冷却。
|
||||||
|
|
||||||
|
SDK 交互隔离在 `_call_send` / `_call_check` 两个薄封装(惰性 import + 惰性建 client,仿 wxpay
|
||||||
|
惰性加载),单测 monkeypatch 这两个即可,不触真 SDK / 网络。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
from .base import SmsError, mock_verify
|
||||||
|
|
||||||
|
logger = logging.getLogger("shagua.sms.aliyun")
|
||||||
|
|
||||||
|
# 阿里云路径唯一本地态:per-phone 连续失败次数(与极光同语义,防爆破)。
|
||||||
|
_verify_attempts: dict[str, int] = {} # phone -> 连续失败次数
|
||||||
|
_verify_seen: dict[str, float] = {} # phone -> 最近触碰 epoch(仅供 GC 老化)
|
||||||
|
_lock = Lock()
|
||||||
|
_GC_THRESHOLD = 10000 # 超此阈值,send 时顺手清老于验证码有效期的计数(仿极光 _gc)
|
||||||
|
|
||||||
|
# 发码错误码 → (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。
|
||||||
|
_SEND_ERRORS: dict[str, tuple[int, str]] = {
|
||||||
|
"MOBILE_NUMBER_ILLEGAL": (400, "请输入有效的手机号"),
|
||||||
|
"BUSINESS_LIMIT_CONTROL": (429, "今日发送次数过多,请明天再试"),
|
||||||
|
"FREQUENCY_FAIL": (429, "发送过于频繁,请稍后再试"),
|
||||||
|
}
|
||||||
|
# 需运维介入的配置/开通类错误:打 critical 日志(融合认证未开通 / 参数非法)。
|
||||||
|
_SEND_CRITICAL_CODES = frozenset({"FUNCTION_NOT_OPENED", "INVALID_PARAMETERS"})
|
||||||
|
|
||||||
|
_client = None # 惰性构建的 SDK client(模块级缓存)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ 对外:发码 / 校验 ============================
|
||||||
|
|
||||||
|
def send_code(phone: str, *, cooldown_sec: int | None = None) -> int:
|
||||||
|
"""发送验证码(阿里云生成+下发)。
|
||||||
|
|
||||||
|
Returns: 距下次可发的秒数(= ALIYUN_SMS_INTERVAL_SEC,冷却由阿里云 Interval 侧执行)。
|
||||||
|
Raises: SmsError(手机号无效 400 / 过频·天级流控 429 / 未配置·未开通·其他 503)。
|
||||||
|
"""
|
||||||
|
effective_cooldown = (
|
||||||
|
settings.ALIYUN_SMS_INTERVAL_SEC if cooldown_sec is None else cooldown_sec
|
||||||
|
)
|
||||||
|
if settings.SMS_MOCK:
|
||||||
|
logger.info("[SMS-aliyun-MOCK] to %s**** (不真发)", phone[:3])
|
||||||
|
return effective_cooldown
|
||||||
|
if not settings.aliyun_sms_configured:
|
||||||
|
raise SmsError("短信服务未配置(缺阿里云凭证)", status_code=503)
|
||||||
|
|
||||||
|
result = (
|
||||||
|
_call_send(phone)
|
||||||
|
if cooldown_sec is None
|
||||||
|
else _call_send(phone, cooldown_sec=effective_cooldown)
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["success"] and result["code"] == "OK":
|
||||||
|
now = time.time()
|
||||||
|
with _lock:
|
||||||
|
_gc(now) # 顺手清老计数(超阈值才扫)
|
||||||
|
_verify_attempts.pop(phone, None) # 新码 = 新失败预算
|
||||||
|
_verify_seen.pop(phone, None)
|
||||||
|
logger.info("[SMS-aliyun] sent to %s****", phone[:3])
|
||||||
|
return effective_cooldown
|
||||||
|
|
||||||
|
code = result["code"]
|
||||||
|
logger.error("[SMS-aliyun] send failed code=%s msg=%s", code, result["message"])
|
||||||
|
if code in _SEND_CRITICAL_CODES:
|
||||||
|
logger.critical("[SMS-aliyun] %s —— 需运维处理(融合认证未开通 / 参数非法)", code)
|
||||||
|
status, msg = _SEND_ERRORS.get(code, (503, "短信服务暂不可用,请稍后重试"))
|
||||||
|
raise SmsError(msg, status_code=status)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_code(
|
||||||
|
phone: str,
|
||||||
|
code: str,
|
||||||
|
*,
|
||||||
|
max_failed_attempts: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""校验验证码(阿里云裁决)。
|
||||||
|
|
||||||
|
- **mock**:放行任意 N 位数字(provider 无关,同极光)。
|
||||||
|
- **real**:先查本地失败计数(达上限即本地作废,不调阿里云,与极光一致)→ 调 CheckSmsVerifyCode:
|
||||||
|
PASS 清计数返 True(一次性);UNKNOWN 计数 +1 返 False;接口异常抛 SmsError(503)。
|
||||||
|
"""
|
||||||
|
if settings.SMS_MOCK:
|
||||||
|
ok = mock_verify(code)
|
||||||
|
logger.info("[SMS-aliyun-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
|
||||||
|
return ok
|
||||||
|
|
||||||
|
# 失败计数是 best-effort:网络调用不持锁(不能锁跨 IO),故并发下同号可能多放行个位数次。
|
||||||
|
# 无碍——API 层登录频控(设备+IP 5/时)是硬上限,阿里云码有效期 + DuplicatePolicy 亦兜底。
|
||||||
|
effective_max_attempts = (
|
||||||
|
settings.SMS_MAX_VERIFY_ATTEMPTS
|
||||||
|
if max_failed_attempts is None
|
||||||
|
else max_failed_attempts
|
||||||
|
)
|
||||||
|
with _lock:
|
||||||
|
if _verify_attempts.get(phone, 0) >= effective_max_attempts:
|
||||||
|
return False # 已作废:保持计数(直到 send_code 重置),与极光「达上限即作废」一致
|
||||||
|
|
||||||
|
result = _call_check(phone, code) # 传输/SDK 异常在内部抛 SmsError(503)
|
||||||
|
|
||||||
|
if not (result["success"] and result["code"] == "OK"):
|
||||||
|
# 接口层失败(非码错):降级 503,别误报「验证码错误」(400),便于区分排查。
|
||||||
|
logger.error("[SMS-aliyun] check failed code=%s msg=%s", result["code"], result["message"])
|
||||||
|
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503)
|
||||||
|
|
||||||
|
if result["verify_result"] == "PASS":
|
||||||
|
with _lock:
|
||||||
|
_verify_attempts.pop(phone, None) # 验过即清(一次性)
|
||||||
|
_verify_seen.pop(phone, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# UNKNOWN:码错 / 过期 → 失败计数 +1(累计到上限即作废)
|
||||||
|
with _lock:
|
||||||
|
_verify_attempts[phone] = _verify_attempts.get(phone, 0) + 1
|
||||||
|
_verify_seen[phone] = time.time()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _gc(now: float) -> None:
|
||||||
|
"""超阈值时清理老于验证码有效期的失败计数(码早已在阿里云侧失效,计数无意义)。仅持锁调用。"""
|
||||||
|
if len(_verify_attempts) <= _GC_THRESHOLD:
|
||||||
|
return
|
||||||
|
cutoff = now - settings.ALIYUN_SMS_VALID_TIME_SEC
|
||||||
|
for p in [p for p, ts in _verify_seen.items() if ts < cutoff]:
|
||||||
|
_verify_attempts.pop(p, None)
|
||||||
|
_verify_seen.pop(p, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ SDK 接缝(单测 monkeypatch 这两个)============================
|
||||||
|
|
||||||
|
def _get_client():
|
||||||
|
"""惰性构建 dypns SDK client(仿 wxpay 惰性加载:jiguang-only 部署不加载 alibabacloud)。"""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
from alibabacloud_dypnsapi20170525.client import Client
|
||||||
|
from alibabacloud_tea_openapi import models as open_api_models
|
||||||
|
|
||||||
|
cfg = open_api_models.Config(
|
||||||
|
access_key_id=settings.ALIYUN_SMS_ACCESS_KEY_ID,
|
||||||
|
access_key_secret=settings.ALIYUN_SMS_ACCESS_KEY_SECRET,
|
||||||
|
read_timeout=settings.ALIYUN_SMS_TIMEOUT_SEC * 1000, # SDK 单位 ms
|
||||||
|
connect_timeout=settings.ALIYUN_SMS_TIMEOUT_SEC * 1000,
|
||||||
|
)
|
||||||
|
cfg.endpoint = settings.ALIYUN_SMS_ENDPOINT
|
||||||
|
_client = Client(cfg)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def _call_send(phone: str, *, cooldown_sec: int | None = None) -> dict:
|
||||||
|
"""调 SendSmsVerifyCode。返回归一化 {success, code, message};import/建 client/调用 任一失败抛 SmsError(503)。"""
|
||||||
|
valid_min = max(1, settings.ALIYUN_SMS_VALID_TIME_SEC // 60)
|
||||||
|
template_param = json.dumps({"code": "##code##", "min": str(valid_min)}, ensure_ascii=False)
|
||||||
|
try:
|
||||||
|
# import + 建 req + 调用 全在 try 内:任一 provider 侧失败都归一成 503(保「provider 出问题→503」不变式)
|
||||||
|
from alibabacloud_dypnsapi20170525 import models as dypns_models
|
||||||
|
req = dypns_models.SendSmsVerifyCodeRequest(
|
||||||
|
phone_number=phone,
|
||||||
|
sign_name=settings.ALIYUN_SMS_SIGN_NAME,
|
||||||
|
template_code=settings.ALIYUN_SMS_TEMPLATE_CODE,
|
||||||
|
template_param=template_param,
|
||||||
|
code_length=settings.ALIYUN_SMS_CODE_LENGTH,
|
||||||
|
valid_time=settings.ALIYUN_SMS_VALID_TIME_SEC,
|
||||||
|
interval=(
|
||||||
|
settings.ALIYUN_SMS_INTERVAL_SEC
|
||||||
|
if cooldown_sec is None
|
||||||
|
else cooldown_sec
|
||||||
|
),
|
||||||
|
scheme_name=settings.ALIYUN_SMS_SCHEME_NAME or None,
|
||||||
|
)
|
||||||
|
body = _get_client().send_sms_verify_code(req).body
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[SMS-aliyun] send_sms_verify_code 调用异常 phone=%s****", phone[:3])
|
||||||
|
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
|
||||||
|
return {"success": bool(body.success), "code": body.code, "message": body.message}
|
||||||
|
|
||||||
|
|
||||||
|
def _call_check(phone: str, code: str) -> dict:
|
||||||
|
"""调 CheckSmsVerifyCode。返回归一化 {success, code, message, verify_result};import/建 client/调用 任一失败抛 SmsError(503)。"""
|
||||||
|
try:
|
||||||
|
# import + 建 req + 调用 全在 try 内:任一 provider 侧失败都归一成 503(保「provider 出问题→503」不变式)
|
||||||
|
from alibabacloud_dypnsapi20170525 import models as dypns_models
|
||||||
|
req = dypns_models.CheckSmsVerifyCodeRequest(
|
||||||
|
phone_number=phone,
|
||||||
|
verify_code=code,
|
||||||
|
scheme_name=settings.ALIYUN_SMS_SCHEME_NAME or None,
|
||||||
|
)
|
||||||
|
body = _get_client().check_sms_verify_code(req).body
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[SMS-aliyun] check_sms_verify_code 调用异常 phone=%s****", phone[:3])
|
||||||
|
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
|
||||||
|
verify_result = getattr(body.model, "verify_result", None) if body.model else None
|
||||||
|
return {
|
||||||
|
"success": bool(body.success),
|
||||||
|
"code": body.code,
|
||||||
|
"message": body.message,
|
||||||
|
"verify_result": verify_result,
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""短信 provider 共享基座:业务异常 + provider 无关的 mock 校验。
|
||||||
|
|
||||||
|
各 provider(jiguang / aliyun)都 `from .base import SmsError`,api 层也从包入口拿到同一个
|
||||||
|
`SmsError` —— 保证无论用哪个 provider,异常类型与 HTTP 码映射语义一致。
|
||||||
|
`SendResult` 是分派层 `send_code` 的返回类型,携带 cooldown、实际 provider 及是否走了 fallback。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class SmsError(Exception):
|
||||||
|
"""业务异常。`status_code` 决定 api 层翻成哪个 HTTP 码:
|
||||||
|
过频/每日超限 = 429(客户端等会再来),供应商不可用 = 503,手机号无效 = 400。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int = 429) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SendResult:
|
||||||
|
"""发码结果:距下次可发秒数 + 实际发码的 provider 名 + 是否走了备(fallback)。
|
||||||
|
|
||||||
|
分派层 send_code 的返回类型(provider 各自的 send_code 仍返回 int cooldown,
|
||||||
|
由分派层包装)。auth 层据此把渠道记入风控流水 details。
|
||||||
|
"""
|
||||||
|
|
||||||
|
cooldown_sec: int
|
||||||
|
provider: str
|
||||||
|
fallback: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def mock_verify(code: str) -> bool:
|
||||||
|
"""mock 模式校验:放行任意 SMS_CODE_LENGTH 位数字(provider 无关,测试/开发便利,不真校验)。"""
|
||||||
|
return len(code) == settings.SMS_CODE_LENGTH and code.isdigit()
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""创蓝云智(253)短信 provider(自管码 Mode B)。
|
||||||
|
|
||||||
|
创蓝 `tpl/send` v2 是**纯发送网关**(本服务生成码 → 放入 templateParamJson → 创蓝只下发,
|
||||||
|
无校验接口),故与极光同为 **Mode B**:本服务生成/存储/校验验证码,创蓝只负责发。
|
||||||
|
|
||||||
|
**本模块的存码/冷却/一次性/防爆破/GC 机器与 [jiguang.py](jiguang.py) 是刻意的隔离复制**
|
||||||
|
(设计见 docs/superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md):极光文件一行不动、
|
||||||
|
零回归风险于登录关键路径的默认 provider;代价是两处 Mode B 并发逻辑重复,改动需同步。唯一新逻辑
|
||||||
|
是 `_send_via_chuanglan`(HMAC-SHA256 签名 + httpx POST + 错误码映射)。
|
||||||
|
|
||||||
|
两种运行模式由 `SMS_MOCK` 切换:
|
||||||
|
- **mock**(开发/测试,默认):不真发,验证码打日志;校验放行任意 N 位数字。
|
||||||
|
- **real**(`SMS_MOCK=false` 且 `SMS_PROVIDER=chuanglan`):`secrets` 生成码 → 调创蓝 `tpl/send`
|
||||||
|
下发(HMAC 签名,password 仅本地算签不上行)→ 校验比对本地存码(一次性 / 过期 / 防爆破)。
|
||||||
|
|
||||||
|
验证码存储:**进程内存**(单 worker 够用,多 worker 不共享,与极光同级技术债)。防刷同极光:
|
||||||
|
单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)+ 单设备/IP 频控(api 层)+ 单码失败 `SMS_MAX_VERIFY_ATTEMPTS`
|
||||||
|
次即作废。运维侧另需在创蓝控制台配 **IP 白名单**(否则 117)。接口调研见 docs/integrations/chuanglan/tpl-send.md。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
from .base import SmsError, mock_verify
|
||||||
|
|
||||||
|
logger = logging.getLogger("shagua.sms.chuanglan")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _CodeRecord:
|
||||||
|
code: str
|
||||||
|
expires_at: float
|
||||||
|
attempts: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring)。与极光同结构。
|
||||||
|
_codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码
|
||||||
|
_last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却)
|
||||||
|
_lock = Lock()
|
||||||
|
_GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长)
|
||||||
|
|
||||||
|
# 发码错误码(创蓝 `code`)→ (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。
|
||||||
|
_SEND_ERRORS: dict[str, tuple[int, str]] = {
|
||||||
|
"103": (429, "发送过于频繁,请稍后再试"), # 提交速度过快
|
||||||
|
"107": (400, "请输入有效的手机号"), # 手机号码错误
|
||||||
|
}
|
||||||
|
# 需运维介入的配置/开通/余额类错误:打 critical 日志(仍归 503)。
|
||||||
|
_SEND_CRITICAL_CODES = frozenset({
|
||||||
|
"109", # 无发送量/余额不足
|
||||||
|
"117", # IP 未加白名单
|
||||||
|
"102", # 密码错误
|
||||||
|
"116", # 签名不合法
|
||||||
|
"124", # 模板内容不匹配
|
||||||
|
"152", # 模板不存在
|
||||||
|
"101", # 账号不存在
|
||||||
|
"118", # 无发送权限
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_code() -> str:
|
||||||
|
"""生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。"""
|
||||||
|
return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH))
|
||||||
|
|
||||||
|
|
||||||
|
def _gc(now: float) -> None:
|
||||||
|
"""顺手清理过期内存项,防两个 dict 无限增长。仅在持锁时调用,且某 dict 超阈值才扫它。"""
|
||||||
|
if len(_codes) > _GC_THRESHOLD:
|
||||||
|
for p in [p for p, r in _codes.items() if now > r.expires_at]:
|
||||||
|
_codes.pop(p, None)
|
||||||
|
if len(_last_sent) > _GC_THRESHOLD:
|
||||||
|
cutoff = now - settings.SMS_SEND_INTERVAL_SEC
|
||||||
|
for p in [p for p, ts in _last_sent.items() if ts < cutoff]:
|
||||||
|
_last_sent.pop(p, None)
|
||||||
|
|
||||||
|
|
||||||
|
def send_code(phone: str, *, cooldown_sec: int | None = None) -> int:
|
||||||
|
"""发送验证码。
|
||||||
|
|
||||||
|
Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC)
|
||||||
|
Raises: SmsError(过频 429 / 手机号无效 400 / 供应商失败 503)
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
effective_cooldown = (
|
||||||
|
settings.SMS_SEND_INTERVAL_SEC if cooldown_sec is None else cooldown_sec
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- lock 内:防刷检查 + 预占(防并发重复发烧钱)---
|
||||||
|
with _lock:
|
||||||
|
_gc(now) # 顺手清过期内存(超阈值才扫)
|
||||||
|
elapsed = now - _last_sent.get(phone, 0.0)
|
||||||
|
if elapsed < effective_cooldown:
|
||||||
|
remain = int(effective_cooldown - elapsed)
|
||||||
|
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
|
||||||
|
|
||||||
|
code = _gen_code()
|
||||||
|
# 预占:先记冷却/存码,释放锁后再发网络(发失败保留冷却,见下)
|
||||||
|
_last_sent[phone] = now
|
||||||
|
_codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC)
|
||||||
|
|
||||||
|
# --- lock 外:真正发送(网络 IO 不持锁)---
|
||||||
|
try:
|
||||||
|
if settings.SMS_MOCK:
|
||||||
|
logger.info("[SMS-chuanglan-MOCK] to %s**** code=%s (不真发)", phone[:3], code)
|
||||||
|
else:
|
||||||
|
_send_via_chuanglan(phone, code)
|
||||||
|
logger.info("[SMS-chuanglan] sent to %s****", phone[:3])
|
||||||
|
except Exception as e:
|
||||||
|
# 发送失败:**保留冷却**(失败也限速,挡住余额不足/签名失效时前端重试狂打),
|
||||||
|
# 只清掉没发出去的码(用户收不到,留着无意义且占内存)。
|
||||||
|
with _lock:
|
||||||
|
_codes.pop(phone, None)
|
||||||
|
if isinstance(e, SmsError):
|
||||||
|
raise
|
||||||
|
logger.exception("[SMS-chuanglan] send failed phone=%s****", phone[:3])
|
||||||
|
raise SmsError("验证码发送失败,请稍后重试", status_code=503) from e
|
||||||
|
|
||||||
|
return effective_cooldown
|
||||||
|
|
||||||
|
|
||||||
|
def verify_code(
|
||||||
|
phone: str,
|
||||||
|
code: str,
|
||||||
|
*,
|
||||||
|
max_failed_attempts: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""校验验证码。
|
||||||
|
|
||||||
|
- **mock 模式**:放行任意 N 位数字(测试/开发便利,不真校验)。
|
||||||
|
- **real 模式**:比对本服务存的码,匹配即作废(一次性);失败累计到上限也作废(防爆破)。
|
||||||
|
"""
|
||||||
|
if settings.SMS_MOCK:
|
||||||
|
ok = mock_verify(code)
|
||||||
|
logger.info("[SMS-chuanglan-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
|
||||||
|
return ok
|
||||||
|
|
||||||
|
effective_max_attempts = (
|
||||||
|
settings.SMS_MAX_VERIFY_ATTEMPTS
|
||||||
|
if max_failed_attempts is None
|
||||||
|
else max_failed_attempts
|
||||||
|
)
|
||||||
|
with _lock:
|
||||||
|
rec = _codes.get(phone)
|
||||||
|
if rec is None:
|
||||||
|
return False
|
||||||
|
if time.time() > rec.expires_at:
|
||||||
|
_codes.pop(phone, None)
|
||||||
|
return False
|
||||||
|
if rec.attempts >= effective_max_attempts:
|
||||||
|
_codes.pop(phone, None) # 试错过多,作废
|
||||||
|
return False
|
||||||
|
if secrets.compare_digest(code.encode("utf-8"), rec.code.encode("utf-8")):
|
||||||
|
_codes.pop(phone, None) # 验过即作废
|
||||||
|
return True
|
||||||
|
rec.attempts += 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ 发送接缝(单测 monkeypatch 这两个 / httpx.post)============================
|
||||||
|
|
||||||
|
def _sign(password: str, timestamp: str, nonce: str) -> str:
|
||||||
|
"""创蓝 HMAC-SHA256 签名:key=md5(password),msg=sorted([md5pwd,ts,nonce]) 拼接去空白,输出小写 hex。"""
|
||||||
|
md5pwd = hashlib.md5(password.encode()).hexdigest() # 32 位小写 hex
|
||||||
|
raw = "".join(sorted([md5pwd, timestamp, nonce])) # 字典序升序,无分隔符拼接
|
||||||
|
raw = "".join(raw.split()) # 去所有空白(faithful;三段本无空白)
|
||||||
|
return hmac.new(md5pwd.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _call_chuanglan(phone: str, code: str) -> dict:
|
||||||
|
"""组装 + 签名 + POST 创蓝 tpl/send,返回解析后的响应 dict。
|
||||||
|
|
||||||
|
传输错误 / HTTP≠200 / 响应非 JSON 一律抛 SmsError(503)(保「provider 出问题→503」不变式);
|
||||||
|
业务码(含 000000)由调用方 `_send_via_chuanglan` 判读。password 只用于算签,不入 body。
|
||||||
|
"""
|
||||||
|
timestamp = str(int(time.time()))
|
||||||
|
nonce = secrets.token_hex(16) # 32 位 hex
|
||||||
|
body = {
|
||||||
|
"account": settings.CHUANGLAN_SMS_ACCOUNT,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"nonce": nonce,
|
||||||
|
"phoneNumbers": phone,
|
||||||
|
"templateId": settings.CHUANGLAN_SMS_TEMPLATE_ID,
|
||||||
|
"templateParamJson": json.dumps([{"param1": code}]),
|
||||||
|
}
|
||||||
|
if settings.CHUANGLAN_SMS_SIGNATURE:
|
||||||
|
body["signature"] = settings.CHUANGLAN_SMS_SIGNATURE
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-QA-Hmac-Signature": _sign(settings.CHUANGLAN_SMS_PASSWORD, timestamp, nonce),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
settings.CHUANGLAN_SMS_ENDPOINT,
|
||||||
|
json=body,
|
||||||
|
headers=headers,
|
||||||
|
timeout=settings.CHUANGLAN_SMS_TIMEOUT_SEC,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.exception("[SMS-chuanglan] 网络错误 phone=%s****", phone[:3])
|
||||||
|
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error("[SMS-chuanglan] http=%s body=%s", resp.status_code, resp.text[:200])
|
||||||
|
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503)
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[SMS-chuanglan] 响应非 JSON: %s", resp.text[:200])
|
||||||
|
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
|
||||||
|
|
||||||
|
|
||||||
|
def _send_via_chuanglan(phone: str, code: str) -> None:
|
||||||
|
"""调创蓝 tpl/send 发送。成功静默返回;失败按错误码映射抛 SmsError。"""
|
||||||
|
if not settings.chuanglan_sms_configured:
|
||||||
|
raise SmsError("短信服务未配置(缺创蓝 account/password/templateId)", status_code=503)
|
||||||
|
|
||||||
|
result = _call_chuanglan(phone, code) # 传输/非200/解析异常在内部抛 SmsError(503)
|
||||||
|
rcode = str(result.get("code"))
|
||||||
|
if rcode == "000000":
|
||||||
|
return
|
||||||
|
|
||||||
|
emsg = result.get("errorMsg") or ""
|
||||||
|
logger.error("[SMS-chuanglan] send failed code=%s msg=%s", rcode, emsg)
|
||||||
|
if rcode in _SEND_CRITICAL_CODES:
|
||||||
|
logger.critical("[SMS-chuanglan] %s —— 需运维处理(余额/IP白名单/密码/签名/模板/账号)", rcode)
|
||||||
|
status, msg = _SEND_ERRORS.get(rcode, (503, "短信服务暂不可用,请稍后重试"))
|
||||||
|
raise SmsError(msg, status_code=status)
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
"""短信验证码服务。
|
"""极光短信 provider(自管码 Mode B)。
|
||||||
|
|
||||||
两种运行模式由 `SMS_MOCK` 切换:
|
两种运行模式由 `SMS_MOCK` 切换:
|
||||||
- **mock**(开发/测试,默认):不真发短信,验证码打到日志;校验**放行任意 N 位数字**
|
- **mock**(开发/测试,默认):不真发短信,验证码打到日志;校验**放行任意 N 位数字**
|
||||||
(测试/开发便利)。真实校验逻辑(比对存码 / 一次性 / 防爆破)由 real 分支 + 单测覆盖。
|
(测试/开发便利)。真实校验逻辑(比对存码 / 一次性 / 防爆破)由 real 分支 + 单测覆盖。
|
||||||
- **real**(生产 `SMS_MOCK=false`):本服务生成 N 位验证码 → 调极光短信 REST
|
- **real**(生产 `SMS_MOCK=false` 且 `SMS_PROVIDER=jiguang`):本服务生成 N 位验证码 → 调极光
|
||||||
`/v1/messages` 发送(自定义验证码模式,极光只负责发,code 由本服务生成/保管/
|
短信 REST `/v1/messages` 发送(自定义验证码模式,极光只负责发,code 由本服务生成/保管/
|
||||||
校验)→ 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)。
|
校验)→ 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)。
|
||||||
|
|
||||||
验证码存储:**进程内存**(单 worker uvicorn 够用)。重启丢失(用户重发即可)。多
|
验证码存储:**进程内存**(单 worker uvicorn 够用)。重启丢失(用户重发即可)。多
|
||||||
worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移到 DB/Redis。
|
worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移到 DB/Redis(或改用 aliyun provider,
|
||||||
见 docs/待办与技术债.md。
|
其验证码由阿里云托管、无本地存码)。见 docs/待办与技术债.md。
|
||||||
|
|
||||||
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
|
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
|
||||||
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
|
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
|
||||||
@@ -34,17 +34,9 @@ import httpx
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.sms")
|
from .base import SmsError, mock_verify
|
||||||
|
|
||||||
|
logger = logging.getLogger("shagua.sms.jiguang")
|
||||||
class SmsError(Exception):
|
|
||||||
"""业务异常。`status_code` 决定 api 层翻成哪个 HTTP 码:
|
|
||||||
过频/每日超限 = 429(客户端等会再来),供应商不可用 = 503,手机号无效 = 400。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, message: str, status_code: int = 429) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.status_code = status_code
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -78,20 +70,23 @@ def _gc(now: float) -> None:
|
|||||||
_last_sent.pop(p, None)
|
_last_sent.pop(p, None)
|
||||||
|
|
||||||
|
|
||||||
def send_code(phone: str) -> int:
|
def send_code(phone: str, *, cooldown_sec: int | None = None) -> int:
|
||||||
"""发送验证码。
|
"""发送验证码。
|
||||||
|
|
||||||
Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC)
|
Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC)
|
||||||
Raises: SmsError(过频 429 / 当日超限 429 / 供应商失败 503 / 手机号无效 400)
|
Raises: SmsError(过频 429 / 当日超限 429 / 供应商失败 503 / 手机号无效 400)
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
effective_cooldown = (
|
||||||
|
settings.SMS_SEND_INTERVAL_SEC if cooldown_sec is None else cooldown_sec
|
||||||
|
)
|
||||||
|
|
||||||
# --- lock 内:防刷检查 + 预占(防并发重复发烧钱)---
|
# --- lock 内:防刷检查 + 预占(防并发重复发烧钱)---
|
||||||
with _lock:
|
with _lock:
|
||||||
_gc(now) # 顺手清过期内存(超阈值才扫)
|
_gc(now) # 顺手清过期内存(超阈值才扫)
|
||||||
elapsed = now - _last_sent.get(phone, 0.0)
|
elapsed = now - _last_sent.get(phone, 0.0)
|
||||||
if elapsed < settings.SMS_SEND_INTERVAL_SEC:
|
if elapsed < effective_cooldown:
|
||||||
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
|
remain = int(effective_cooldown - elapsed)
|
||||||
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
|
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
|
||||||
|
|
||||||
code = _gen_code()
|
code = _gen_code()
|
||||||
@@ -116,20 +111,30 @@ def send_code(phone: str) -> int:
|
|||||||
logger.exception("[SMS] send failed phone=%s****", phone[:3])
|
logger.exception("[SMS] send failed phone=%s****", phone[:3])
|
||||||
raise SmsError("验证码发送失败,请稍后重试", status_code=503) from e
|
raise SmsError("验证码发送失败,请稍后重试", status_code=503) from e
|
||||||
|
|
||||||
return settings.SMS_SEND_INTERVAL_SEC
|
return effective_cooldown
|
||||||
|
|
||||||
|
|
||||||
def verify_code(phone: str, code: str) -> bool:
|
def verify_code(
|
||||||
|
phone: str,
|
||||||
|
code: str,
|
||||||
|
*,
|
||||||
|
max_failed_attempts: int | None = None,
|
||||||
|
) -> bool:
|
||||||
"""校验验证码。
|
"""校验验证码。
|
||||||
|
|
||||||
- **mock 模式**:放行任意 N 位数字(测试/开发便利,不真校验)。
|
- **mock 模式**:放行任意 N 位数字(测试/开发便利,不真校验)。
|
||||||
- **real 模式**:比对本服务存的码,匹配即作废(一次性);失败累计到上限也作废(防爆破)。
|
- **real 模式**:比对本服务存的码,匹配即作废(一次性);失败累计到上限也作废(防爆破)。
|
||||||
"""
|
"""
|
||||||
if settings.SMS_MOCK:
|
if settings.SMS_MOCK:
|
||||||
ok = len(code) == settings.SMS_CODE_LENGTH and code.isdigit()
|
ok = mock_verify(code)
|
||||||
logger.info("[SMS-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
|
logger.info("[SMS-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
|
effective_max_attempts = (
|
||||||
|
settings.SMS_MAX_VERIFY_ATTEMPTS
|
||||||
|
if max_failed_attempts is None
|
||||||
|
else max_failed_attempts
|
||||||
|
)
|
||||||
with _lock:
|
with _lock:
|
||||||
rec = _codes.get(phone)
|
rec = _codes.get(phone)
|
||||||
if rec is None:
|
if rec is None:
|
||||||
@@ -137,7 +142,7 @@ def verify_code(phone: str, code: str) -> bool:
|
|||||||
if time.time() > rec.expires_at:
|
if time.time() > rec.expires_at:
|
||||||
_codes.pop(phone, None)
|
_codes.pop(phone, None)
|
||||||
return False
|
return False
|
||||||
if rec.attempts >= settings.SMS_MAX_VERIFY_ATTEMPTS:
|
if rec.attempts >= effective_max_attempts:
|
||||||
_codes.pop(phone, None) # 试错过多,作废
|
_codes.pop(phone, None) # 试错过多,作废
|
||||||
return False
|
return False
|
||||||
if secrets.compare_digest(code.encode("utf-8"), rec.code.encode("utf-8")):
|
if secrets.compare_digest(code.encode("utf-8"), rec.code.encode("utf-8")):
|
||||||
@@ -193,5 +198,5 @@ def _send_via_jiguang(phone: str, code: str) -> None:
|
|||||||
if ecode == 50009: # 极光侧超频
|
if ecode == 50009: # 极光侧超频
|
||||||
raise SmsError("发送过于频繁,请稍后再试", status_code=429)
|
raise SmsError("发送过于频繁,请稍后再试", status_code=429)
|
||||||
if ecode == 50006: # 手机号无效(schema 已挡格式,这里多是空号/停机)
|
if ecode == 50006: # 手机号无效(schema 已挡格式,这里多是空号/停机)
|
||||||
raise SmsError("手机号无效", status_code=400)
|
raise SmsError("请输入有效的手机号", status_code=400)
|
||||||
raise SmsError(f"短信发送失败(code={ecode})", status_code=503)
|
raise SmsError(f"短信发送失败(code={ecode})", status_code=503)
|
||||||
+459
-47
@@ -22,7 +22,7 @@ import uuid
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote, urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ from app.core.config import settings
|
|||||||
logger = logging.getLogger("shagua.vendor_push")
|
logger = logging.getLogger("shagua.vendor_push")
|
||||||
|
|
||||||
TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled"
|
TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled"
|
||||||
|
DATA_EVENT_NOTIFICATION_CREATED = "notification_created"
|
||||||
SUPPORTED_VENDORS = frozenset({"honor", "huawei", "vivo", "xiaomi", "oppo"})
|
SUPPORTED_VENDORS = frozenset({"honor", "huawei", "vivo", "xiaomi", "oppo"})
|
||||||
|
|
||||||
# vendor key → 中文名(测试/配置状态接口展示用)
|
# vendor key → 中文名(测试/配置状态接口展示用)
|
||||||
@@ -69,6 +70,18 @@ class _CachedToken:
|
|||||||
|
|
||||||
_token_cache: dict[str, _CachedToken] = {}
|
_token_cache: dict[str, _CachedToken] = {}
|
||||||
|
|
||||||
|
_LOG_SUMMARY_MAX_CHARS = 1500
|
||||||
|
_LOG_STRING_MAX_CHARS = 200
|
||||||
|
_SENSITIVE_LOG_KEYS = {
|
||||||
|
"accesstoken",
|
||||||
|
"appkey",
|
||||||
|
"authtoken",
|
||||||
|
"authorization",
|
||||||
|
"clientsecret",
|
||||||
|
"mastersecret",
|
||||||
|
"sign",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def normalize_vendor(push_vendor: str | None) -> str | None:
|
def normalize_vendor(push_vendor: str | None) -> str | None:
|
||||||
if not push_vendor:
|
if not push_vendor:
|
||||||
@@ -115,8 +128,16 @@ def send_notification(
|
|||||||
|
|
||||||
if mock:
|
if mock:
|
||||||
logger.info(
|
logger.info(
|
||||||
"[mock push] vendor=%s token=%s... title=%s body=%s extras=%s",
|
"vendor push mock vendor=%s request=%s",
|
||||||
vendor, token[:12], title, body, extras,
|
vendor,
|
||||||
|
_log_summary(
|
||||||
|
{
|
||||||
|
"push_token": token,
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"extras": extras,
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"mock": True,
|
"mock": True,
|
||||||
@@ -133,7 +154,28 @@ def send_notification(
|
|||||||
"xiaomi": _send_xiaomi,
|
"xiaomi": _send_xiaomi,
|
||||||
"oppo": _send_oppo,
|
"oppo": _send_oppo,
|
||||||
}
|
}
|
||||||
return dispatch[vendor](token, title, body, extras)
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
result = dispatch[vendor](token, title, body, extras)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"vendor push dispatch failed vendor=%s token=%s notification_type=%s "
|
||||||
|
"elapsed_ms=%.1f",
|
||||||
|
vendor,
|
||||||
|
token,
|
||||||
|
extras.get("type", ""),
|
||||||
|
_elapsed_ms(started),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
logger.info(
|
||||||
|
"vendor push dispatch succeeded vendor=%s token=%s notification_type=%s "
|
||||||
|
"elapsed_ms=%.1f",
|
||||||
|
vendor,
|
||||||
|
token,
|
||||||
|
extras.get("type", ""),
|
||||||
|
_elapsed_ms(started),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def send_accessibility_disabled(
|
def send_accessibility_disabled(
|
||||||
@@ -153,19 +195,191 @@ def send_accessibility_disabled(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_data_event(
|
||||||
|
push_vendor: str,
|
||||||
|
push_token: str,
|
||||||
|
*,
|
||||||
|
event: str,
|
||||||
|
notification_id: str,
|
||||||
|
mock: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""发送无界面的轻量事件;当前只允许站内消息创建事件。"""
|
||||||
|
vendor = normalize_vendor(push_vendor)
|
||||||
|
token = push_token.strip() if push_token else ""
|
||||||
|
if not vendor or vendor not in SUPPORTED_VENDORS:
|
||||||
|
raise VendorPushError(f"unsupported push vendor: {push_vendor}")
|
||||||
|
if not token:
|
||||||
|
raise VendorPushError("push token is empty")
|
||||||
|
if event != DATA_EVENT_NOTIFICATION_CREATED:
|
||||||
|
raise VendorPushError(f"unsupported data event: {event}")
|
||||||
|
if not notification_id:
|
||||||
|
raise VendorPushError("notification_id is empty")
|
||||||
|
|
||||||
|
payload = {"event": event, "notificationId": str(notification_id)}
|
||||||
|
if mock:
|
||||||
|
return {"mock": True, "vendor": vendor, "payload": payload}
|
||||||
|
|
||||||
|
# vivo 的通知已设置 foregroundShow=false:App 在前台时必走
|
||||||
|
# onForegroundMessageArrived,正好就是本事件需要的刷新信号;不重复占用一次推送配额。
|
||||||
|
if vendor == "vivo":
|
||||||
|
return {"skipped": True, "reason": "foreground notification callback"}
|
||||||
|
|
||||||
|
# 小米服务端不再发送透传消息;通知栏消息仍照常发送,前台角标由客户端主动刷新。
|
||||||
|
if vendor == "xiaomi":
|
||||||
|
return {"skipped": True, "reason": "xiaomi data messages disabled"}
|
||||||
|
|
||||||
|
# OPush 当前只支持通知栏消息,没有服务端透传单推接口。
|
||||||
|
# 客户端在 OPPO 首页可见时轮询未读数;这里必须安全跳过,不能请求不存在的
|
||||||
|
# /message/transparent/unicast(该地址会稳定返回 HTTP 404)。
|
||||||
|
if vendor == "oppo":
|
||||||
|
return {"skipped": True, "reason": "oppo does not support data messages"}
|
||||||
|
|
||||||
|
dispatch: dict[str, Callable[[str, dict[str, str]], dict[str, Any]]] = {
|
||||||
|
"honor": _send_honor_data,
|
||||||
|
"huawei": _send_huawei_data,
|
||||||
|
}
|
||||||
|
return dispatch[vendor](token, payload)
|
||||||
|
|
||||||
|
|
||||||
def _require(value: str, name: str) -> str:
|
def _require(value: str, name: str) -> str:
|
||||||
if not value:
|
if not value:
|
||||||
raise VendorPushError(f"{name} not configured")
|
raise VendorPushError(f"{name} not configured")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _elapsed_ms(started: float) -> float:
|
||||||
|
return (time.perf_counter() - started) * 1000
|
||||||
|
|
||||||
|
|
||||||
|
def _redacted_value(value: Any) -> str:
|
||||||
|
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
|
||||||
|
digest = hashlib.sha256(raw.encode()).hexdigest()[:10]
|
||||||
|
return f"<redacted len={len(raw)} sha256={digest}>"
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_for_log(value: Any, *, key: str = "", depth: int = 0) -> Any:
|
||||||
|
"""生成有排障价值、但不暴露服务端鉴权凭据的紧凑日志摘要。"""
|
||||||
|
normalized_key = "".join(char for char in key.lower() if char.isalnum())
|
||||||
|
if normalized_key in _SENSITIVE_LOG_KEYS or normalized_key.endswith("secret"):
|
||||||
|
return _redacted_value(value)
|
||||||
|
if depth >= 10:
|
||||||
|
return f"<{type(value).__name__}>"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
items = list(value.items())
|
||||||
|
sanitized = {
|
||||||
|
str(item_key): _sanitize_for_log(item_value, key=str(item_key), depth=depth + 1)
|
||||||
|
for item_key, item_value in items[:30]
|
||||||
|
}
|
||||||
|
if len(items) > 30:
|
||||||
|
sanitized["<omitted_keys>"] = len(items) - 30
|
||||||
|
return sanitized
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
items = list(value)
|
||||||
|
sanitized = [_sanitize_for_log(item, key=key, depth=depth + 1) for item in items[:20]]
|
||||||
|
if len(items) > 20:
|
||||||
|
sanitized.append(f"<omitted_items={len(items) - 20}>")
|
||||||
|
return sanitized
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value.lstrip().startswith(("{", "[")):
|
||||||
|
try:
|
||||||
|
decoded = json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return _sanitize_for_log(decoded, key=key, depth=depth + 1)
|
||||||
|
if len(value) > _LOG_STRING_MAX_CHARS:
|
||||||
|
return f"{value[:_LOG_STRING_MAX_CHARS]}…<len={len(value)}>"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _log_summary(value: Any) -> str:
|
||||||
|
rendered = json.dumps(
|
||||||
|
_sanitize_for_log(value),
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
if len(rendered) > _LOG_SUMMARY_MAX_CHARS:
|
||||||
|
return f"{rendered[:_LOG_SUMMARY_MAX_CHARS]}…<len={len(rendered)}>"
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_log_summary(value: Any) -> str:
|
||||||
|
"""厂商响应摘要:保留原始字段和值,仅限制单条日志长度。"""
|
||||||
|
rendered = json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
if len(rendered) > _LOG_SUMMARY_MAX_CHARS:
|
||||||
|
return f"{rendered[:_LOG_SUMMARY_MAX_CHARS]}…<len={len(rendered)}>"
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_for_log(url: str) -> str:
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _request_summary(kwargs: dict[str, Any]) -> str:
|
||||||
|
summary: dict[str, Any] = {}
|
||||||
|
if "json" in kwargs:
|
||||||
|
summary["body_type"] = "json"
|
||||||
|
summary["request_payload"] = kwargs["json"]
|
||||||
|
elif "data" in kwargs:
|
||||||
|
summary["body_type"] = "form"
|
||||||
|
summary["request_payload"] = kwargs["data"]
|
||||||
|
if kwargs.get("params"):
|
||||||
|
summary["params"] = kwargs["params"]
|
||||||
|
if kwargs.get("headers"):
|
||||||
|
summary["headers"] = kwargs["headers"]
|
||||||
|
return _log_summary(summary)
|
||||||
|
|
||||||
|
|
||||||
|
def _response_summary(resp: Any, parsed: Any | None = None) -> str:
|
||||||
|
if parsed is None:
|
||||||
|
try:
|
||||||
|
parsed = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if parsed is not None:
|
||||||
|
return _raw_log_summary(parsed)
|
||||||
|
return _raw_log_summary(getattr(resp, "text", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _vendor_response_failed(vendor: str, data: dict[str, Any]) -> bool:
|
||||||
|
"""识别 HTTP 200 中明确的厂商业务失败,避免将失败请求记录成 success。"""
|
||||||
|
if data.get("error") or data.get("success") is False:
|
||||||
|
return True
|
||||||
|
if vendor == "xiaomi":
|
||||||
|
code = data.get("code")
|
||||||
|
result = str(data.get("result", "ok")).lower()
|
||||||
|
return code not in (0, "0", None) or result not in ("ok", "success")
|
||||||
|
if vendor == "oppo" and data.get("code") is not None:
|
||||||
|
return int(data["code"]) != 0
|
||||||
|
if vendor == "vivo" and data.get("result") is not None:
|
||||||
|
return int(data["result"]) != 0
|
||||||
|
if vendor == "honor" and data.get("code") is not None:
|
||||||
|
return int(data["code"]) != 200
|
||||||
|
if vendor == "huawei" and data.get("code") is not None:
|
||||||
|
return str(data["code"]) != "80000000"
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _request_json(
|
def _request_json(
|
||||||
method: str,
|
method: str,
|
||||||
url: str,
|
url: str,
|
||||||
*,
|
*,
|
||||||
|
vendor: str,
|
||||||
|
operation: str,
|
||||||
expected_status: tuple[int, ...] = (200,),
|
expected_status: tuple[int, ...] = (200,),
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
request_summary = _request_summary(kwargs)
|
||||||
try:
|
try:
|
||||||
resp = httpx.request(
|
resp = httpx.request(
|
||||||
method,
|
method,
|
||||||
@@ -174,41 +388,85 @@ def _request_json(
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(
|
||||||
|
"vendor push http completed vendor=%s operation=%s method=%s endpoint=%s "
|
||||||
|
"outcome=network_error request=%s response=%s elapsed_ms=%.1f error=%s",
|
||||||
|
vendor,
|
||||||
|
operation,
|
||||||
|
method,
|
||||||
|
_endpoint_for_log(url),
|
||||||
|
request_summary,
|
||||||
|
"<no_response>",
|
||||||
|
_elapsed_ms(started),
|
||||||
|
type(e).__name__,
|
||||||
|
)
|
||||||
raise VendorPushError(f"push http error: {e}") from e
|
raise VendorPushError(f"push http error: {e}") from e
|
||||||
|
|
||||||
if resp.status_code not in expected_status:
|
if resp.status_code not in expected_status:
|
||||||
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
|
logger.error(
|
||||||
|
"vendor push http completed vendor=%s operation=%s method=%s endpoint=%s "
|
||||||
|
"outcome=http_error http_status=%s request=%s response=%s elapsed_ms=%.1f",
|
||||||
|
vendor,
|
||||||
|
operation,
|
||||||
|
method,
|
||||||
|
_endpoint_for_log(url),
|
||||||
|
resp.status_code,
|
||||||
|
request_summary,
|
||||||
|
_response_summary(resp),
|
||||||
|
_elapsed_ms(started),
|
||||||
|
)
|
||||||
raise VendorPushError(f"push http {resp.status_code}")
|
raise VendorPushError(f"push http {resp.status_code}")
|
||||||
try:
|
try:
|
||||||
return resp.json()
|
data = resp.json()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
logger.error(
|
||||||
|
"vendor push http completed vendor=%s operation=%s method=%s endpoint=%s "
|
||||||
|
"outcome=invalid_json http_status=%s request=%s response=%s elapsed_ms=%.1f",
|
||||||
|
vendor,
|
||||||
|
operation,
|
||||||
|
method,
|
||||||
|
_endpoint_for_log(url),
|
||||||
|
resp.status_code,
|
||||||
|
request_summary,
|
||||||
|
_response_summary(resp),
|
||||||
|
_elapsed_ms(started),
|
||||||
|
)
|
||||||
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
|
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
|
||||||
|
vendor_failed = _vendor_response_failed(vendor, data)
|
||||||
|
log = logger.warning if vendor_failed else logger.info
|
||||||
|
log(
|
||||||
|
"vendor push http completed vendor=%s operation=%s method=%s endpoint=%s "
|
||||||
|
"outcome=%s http_status=%s request=%s response=%s elapsed_ms=%.1f",
|
||||||
|
vendor,
|
||||||
|
operation,
|
||||||
|
method,
|
||||||
|
_endpoint_for_log(url),
|
||||||
|
"vendor_error" if vendor_failed else "success",
|
||||||
|
resp.status_code,
|
||||||
|
request_summary,
|
||||||
|
_response_summary(resp, data),
|
||||||
|
_elapsed_ms(started),
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _request_form(
|
def _request_form(
|
||||||
method: str,
|
method: str,
|
||||||
url: str,
|
url: str,
|
||||||
*,
|
*,
|
||||||
|
vendor: str,
|
||||||
|
operation: str,
|
||||||
expected_status: tuple[int, ...] = (200,),
|
expected_status: tuple[int, ...] = (200,),
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
return _request_json(
|
||||||
resp = httpx.request(
|
method,
|
||||||
method,
|
url,
|
||||||
url,
|
vendor=vendor,
|
||||||
timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
|
operation=operation,
|
||||||
**kwargs,
|
expected_status=expected_status,
|
||||||
)
|
**kwargs,
|
||||||
except httpx.HTTPError as e:
|
)
|
||||||
raise VendorPushError(f"push http error: {e}") from e
|
|
||||||
|
|
||||||
if resp.status_code not in expected_status:
|
|
||||||
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
|
|
||||||
raise VendorPushError(f"push http {resp.status_code}")
|
|
||||||
try:
|
|
||||||
return resp.json()
|
|
||||||
except ValueError as e:
|
|
||||||
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_get(key: str) -> str | None:
|
def _cache_get(key: str) -> str | None:
|
||||||
@@ -234,6 +492,8 @@ def _honor_access_token() -> str:
|
|||||||
data = _request_form(
|
data = _request_form(
|
||||||
"POST",
|
"POST",
|
||||||
settings.HONOR_PUSH_TOKEN_ENDPOINT,
|
settings.HONOR_PUSH_TOKEN_ENDPOINT,
|
||||||
|
vendor="honor",
|
||||||
|
operation="authenticate",
|
||||||
data={
|
data={
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
@@ -243,25 +503,31 @@ def _honor_access_token() -> str:
|
|||||||
)
|
)
|
||||||
token = data.get("access_token")
|
token = data.get("access_token")
|
||||||
if not token:
|
if not token:
|
||||||
raise VendorPushError(f"honor auth failed: {data}")
|
raise VendorPushError(f"honor auth failed: {_raw_log_summary(data)}")
|
||||||
return _cache_put(cache_key, str(token), data.get("expires_in"))
|
return _cache_put(cache_key, str(token), data.get("expires_in"))
|
||||||
|
|
||||||
|
|
||||||
def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||||
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
|
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
|
||||||
access_token = _honor_access_token()
|
access_token = _honor_access_token()
|
||||||
|
click_action: dict[str, Any]
|
||||||
|
if extras.get("notificationId"):
|
||||||
|
# type=3 只负责打开首页,不保证 data 会变成目标 Activity 的 extras。消息中心通知必须
|
||||||
|
# 用自定义页面(type=1)+ intent URI,把 notificationId/type/feedbackId 等直接带给
|
||||||
|
# MainActivity;否则华为/荣耀系统能展示通知,但用户点击后客户端收不到任何导航参数。
|
||||||
|
click_action = {"type": 1, "intent": _click_intent_uri(extras)}
|
||||||
|
else:
|
||||||
|
click_action = {"type": 3}
|
||||||
payload = {
|
payload = {
|
||||||
# clickAction type=3(打开应用首页)时,荣耀点击会把 data JSON 的键值对注入启动 intent 的
|
|
||||||
# extras(与 HMS 同机制)→ MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。
|
|
||||||
"data": json.dumps(_click_extras(extras), ensure_ascii=False),
|
"data": json.dumps(_click_extras(extras), ensure_ascii=False),
|
||||||
"notification": {"title": title, "body": body},
|
"notification": {"title": title, "body": body},
|
||||||
"android": {
|
"android": {
|
||||||
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
||||||
"targetUserType": 1,
|
"targetUserType": settings.HONOR_PUSH_TARGET_USER_TYPE,
|
||||||
"notification": {
|
"notification": {
|
||||||
"title": title,
|
"title": title,
|
||||||
"body": body,
|
"body": body,
|
||||||
"clickAction": {"type": 3},
|
"clickAction": click_action,
|
||||||
"importance": "NORMAL",
|
"importance": "NORMAL",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -270,6 +536,8 @@ def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> di
|
|||||||
data = _request_json(
|
data = _request_json(
|
||||||
"POST",
|
"POST",
|
||||||
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
||||||
|
vendor="honor",
|
||||||
|
operation="send_notification",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/json; charset=UTF-8",
|
"Content-Type": "application/json; charset=UTF-8",
|
||||||
@@ -279,7 +547,28 @@ def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> di
|
|||||||
)
|
)
|
||||||
code = data.get("code")
|
code = data.get("code")
|
||||||
if code is not None and int(code) != 200:
|
if code is not None and int(code) != 200:
|
||||||
raise VendorPushError(f"honor push failed: {data}")
|
raise VendorPushError(f"honor push failed: {_raw_log_summary(data)}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _send_honor_data(token: str, payload: dict[str, str]) -> dict[str, Any]:
|
||||||
|
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
|
||||||
|
access_token = _honor_access_token()
|
||||||
|
data = _request_json(
|
||||||
|
"POST",
|
||||||
|
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
||||||
|
vendor="honor",
|
||||||
|
operation="send_data_event",
|
||||||
|
json={"data": json.dumps(payload, ensure_ascii=False), "token": [token]},
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json; charset=UTF-8",
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"timestamp": str(int(time.time() * 1000)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
code = data.get("code")
|
||||||
|
if code is not None and int(code) != 200:
|
||||||
|
raise VendorPushError(f"honor data push failed: {_raw_log_summary(data)}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -294,6 +583,8 @@ def _huawei_access_token() -> str:
|
|||||||
data = _request_form(
|
data = _request_form(
|
||||||
"POST",
|
"POST",
|
||||||
settings.HUAWEI_PUSH_TOKEN_ENDPOINT,
|
settings.HUAWEI_PUSH_TOKEN_ENDPOINT,
|
||||||
|
vendor="huawei",
|
||||||
|
operation="authenticate",
|
||||||
data={
|
data={
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
"client_id": app_id,
|
"client_id": app_id,
|
||||||
@@ -303,7 +594,7 @@ def _huawei_access_token() -> str:
|
|||||||
)
|
)
|
||||||
token = data.get("access_token")
|
token = data.get("access_token")
|
||||||
if not token:
|
if not token:
|
||||||
raise VendorPushError(f"huawei auth failed: {data}")
|
raise VendorPushError(f"huawei auth failed: {_raw_log_summary(data)}")
|
||||||
return _cache_put(cache_key, str(token), data.get("expires_in"))
|
return _cache_put(cache_key, str(token), data.get("expires_in"))
|
||||||
|
|
||||||
|
|
||||||
@@ -312,18 +603,24 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d
|
|||||||
'80100000' 为部分成功(单 token 场景仍视为失败,错误里带原始响应便于排障)。"""
|
'80100000' 为部分成功(单 token 场景仍视为失败,错误里带原始响应便于排障)。"""
|
||||||
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
|
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
|
||||||
access_token = _huawei_access_token()
|
access_token = _huawei_access_token()
|
||||||
|
click_action: dict[str, Any]
|
||||||
|
if extras.get("notificationId"):
|
||||||
|
# type=3 只打开首页,Mate 20/EMUI 不会把 message.data 自动拆成启动 Intent extras。
|
||||||
|
# type=1 的自定义 intent 才能稳定携带反馈记录 id,且冷启动/onNewIntent 都走同一路由。
|
||||||
|
click_action = {"type": 1, "intent": _click_intent_uri(extras)}
|
||||||
|
else:
|
||||||
|
click_action = {"type": 3}
|
||||||
payload = {
|
payload = {
|
||||||
"validate_only": False,
|
"validate_only": False,
|
||||||
"message": {
|
"message": {
|
||||||
# click_action type=3(打开应用首页)时,HMS 点击会把 data JSON 的键值对注入启动 intent
|
|
||||||
# 的 extras → MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。
|
|
||||||
"data": json.dumps(_click_extras(extras), ensure_ascii=False),
|
"data": json.dumps(_click_extras(extras), ensure_ascii=False),
|
||||||
"android": {
|
"android": {
|
||||||
|
"target_user_type": settings.HUAWEI_PUSH_TARGET_USER_TYPE,
|
||||||
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
||||||
"notification": {
|
"notification": {
|
||||||
"title": title,
|
"title": title,
|
||||||
"body": body,
|
"body": body,
|
||||||
"click_action": {"type": 3},
|
"click_action": click_action,
|
||||||
"importance": "NORMAL",
|
"importance": "NORMAL",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -333,6 +630,8 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d
|
|||||||
data = _request_json(
|
data = _request_json(
|
||||||
"POST",
|
"POST",
|
||||||
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
||||||
|
vendor="huawei",
|
||||||
|
operation="send_notification",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/json; charset=UTF-8",
|
"Content-Type": "application/json; charset=UTF-8",
|
||||||
@@ -340,7 +639,32 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if str(data.get("code", "")) != "80000000":
|
if str(data.get("code", "")) != "80000000":
|
||||||
raise VendorPushError(f"huawei push failed: {data}")
|
raise VendorPushError(f"huawei push failed: {_raw_log_summary(data)}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _send_huawei_data(token: str, payload: dict[str, str]) -> dict[str, Any]:
|
||||||
|
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
|
||||||
|
access_token = _huawei_access_token()
|
||||||
|
data = _request_json(
|
||||||
|
"POST",
|
||||||
|
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
||||||
|
vendor="huawei",
|
||||||
|
operation="send_data_event",
|
||||||
|
json={
|
||||||
|
"validate_only": False,
|
||||||
|
"message": {
|
||||||
|
"data": json.dumps(payload, ensure_ascii=False),
|
||||||
|
"token": [token],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json; charset=UTF-8",
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if str(data.get("code", "")) != "80000000":
|
||||||
|
raise VendorPushError(f"huawei data push failed: {_raw_log_summary(data)}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -357,6 +681,8 @@ def _vivo_auth_token() -> str:
|
|||||||
data = _request_json(
|
data = _request_json(
|
||||||
"POST",
|
"POST",
|
||||||
settings.VIVO_PUSH_AUTH_ENDPOINT,
|
settings.VIVO_PUSH_AUTH_ENDPOINT,
|
||||||
|
vendor="vivo",
|
||||||
|
operation="authenticate",
|
||||||
json={
|
json={
|
||||||
"appId": app_id,
|
"appId": app_id,
|
||||||
"appKey": app_key,
|
"appKey": app_key,
|
||||||
@@ -366,10 +692,10 @@ def _vivo_auth_token() -> str:
|
|||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
if int(data.get("result", -1)) != 0:
|
if int(data.get("result", -1)) != 0:
|
||||||
raise VendorPushError(f"vivo auth failed: {data}")
|
raise VendorPushError(f"vivo auth failed: {_raw_log_summary(data)}")
|
||||||
token = data.get("authToken")
|
token = data.get("authToken")
|
||||||
if not token:
|
if not token:
|
||||||
raise VendorPushError(f"vivo auth missing authToken: {data}")
|
raise VendorPushError(f"vivo auth missing authToken: {_raw_log_summary(data)}")
|
||||||
return _cache_put(cache_key, str(token), 24 * 3600)
|
return _cache_put(cache_key, str(token), 24 * 3600)
|
||||||
|
|
||||||
|
|
||||||
@@ -385,15 +711,22 @@ def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
|
|||||||
"timeToLive": settings.PUSH_TIME_TO_LIVE_SEC,
|
"timeToLive": settings.PUSH_TIME_TO_LIVE_SEC,
|
||||||
"requestId": uuid.uuid4().hex,
|
"requestId": uuid.uuid4().hex,
|
||||||
"pushMode": settings.VIVO_PUSH_MODE,
|
"pushMode": settings.VIVO_PUSH_MODE,
|
||||||
|
# vivo SDK 4.1.5 只有在前台展示关闭时才调用
|
||||||
|
# OpenClientPushMessageReceiver.onForegroundMessageArrived。
|
||||||
|
# App 在该回调中刷新服务端未读数并补发一条本地通知;后台/锁屏时仍由
|
||||||
|
# vivo 系统展示,因而任何场景都只会出现一条通知。
|
||||||
|
"foregroundShow": False,
|
||||||
|
# vivo 官方 VPush 角标字段:离线收到通知时先由桌面自动 +1;
|
||||||
|
# App 启动/同步后再由统一未读数通过系统 API 精确校准。
|
||||||
|
"addBadge": True,
|
||||||
"clientCustomMap": extras,
|
"clientCustomMap": extras,
|
||||||
}
|
}
|
||||||
# 点击落地:消息中心推送(带 notificationId)→ skipType=4 + skipContent=intent uri,由 vivo
|
# vivo Push SDK 480+ 已不再回调 skipType=3;必须使用 skipType=4 的完整 Intent URI。
|
||||||
# 系统直启 MainActivity 并携带 S. extras(与小米 notify_effect=2 同机制)。不依赖客户端
|
# URI 同时包含 data/scheme、显式 component 和 S. extras,OriginOS 才会把业务参数
|
||||||
# VivoPushReceiver.onNotificationMessageClicked 里的后台 startActivity——Android 10+ BAL
|
# 原样交给 MainActivity(仅靠隐式 deeplink 会打开 App,但可能剥掉 extras)。
|
||||||
# 会静默拦掉,receiver 路径仅作兜底。无 notificationId 的召回类保持 skipType=1 仅打开首页。
|
|
||||||
if extras.get("notificationId"):
|
if extras.get("notificationId"):
|
||||||
payload["skipType"] = 4
|
payload["skipType"] = 4
|
||||||
payload["skipContent"] = _click_intent_uri(extras)
|
payload["skipContent"] = _vivo_click_intent_uri(extras)
|
||||||
else:
|
else:
|
||||||
payload["skipType"] = 1
|
payload["skipType"] = 1
|
||||||
if settings.VIVO_PUSH_CATEGORY:
|
if settings.VIVO_PUSH_CATEGORY:
|
||||||
@@ -401,14 +734,37 @@ def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
|
|||||||
data = _request_json(
|
data = _request_json(
|
||||||
"POST",
|
"POST",
|
||||||
settings.VIVO_PUSH_SEND_ENDPOINT,
|
settings.VIVO_PUSH_SEND_ENDPOINT,
|
||||||
|
vendor="vivo",
|
||||||
|
operation="send_notification",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"authToken": auth_token,
|
"authToken": auth_token,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# 10089 = 当前 vivo 应用尚未开通 VPush「离线自动角标」能力。
|
||||||
|
# 角标只是通知中心未读数的镜像,不能因此让整条通知发送失败:去掉 addBadge
|
||||||
|
# 原样重试,通知到达/应用同步后仍由客户端系统 API 按统一未读数精确设置。
|
||||||
|
if int(data.get("result", -1)) == 10089:
|
||||||
|
logger.warning("vivo VPush badge not enabled (10089); retrying without addBadge")
|
||||||
|
fallback_payload = dict(payload)
|
||||||
|
fallback_payload.pop("addBadge", None)
|
||||||
|
fallback_payload["requestId"] = uuid.uuid4().hex
|
||||||
|
data = _request_json(
|
||||||
|
"POST",
|
||||||
|
settings.VIVO_PUSH_SEND_ENDPOINT,
|
||||||
|
vendor="vivo",
|
||||||
|
operation="send_notification",
|
||||||
|
json=fallback_payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"authToken": auth_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if int(data.get("result", -1)) == 0:
|
||||||
|
data = {**data, "badgeFallback": True}
|
||||||
if int(data.get("result", -1)) != 0:
|
if int(data.get("result", -1)) != 0:
|
||||||
raise VendorPushError(f"vivo push failed: {data}")
|
raise VendorPushError(f"vivo push failed: {_raw_log_summary(data)}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -446,14 +802,43 @@ def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> d
|
|||||||
data = _request_form(
|
data = _request_form(
|
||||||
"POST",
|
"POST",
|
||||||
settings.XIAOMI_PUSH_SEND_ENDPOINT,
|
settings.XIAOMI_PUSH_SEND_ENDPOINT,
|
||||||
|
vendor="xiaomi",
|
||||||
|
operation="send_notification",
|
||||||
data=form,
|
data=form,
|
||||||
headers={"Authorization": f"key={app_secret}"},
|
headers={"Authorization": f"key={app_secret}"},
|
||||||
)
|
)
|
||||||
code = data.get("code")
|
code = data.get("code")
|
||||||
if code not in (0, "0", None):
|
if code not in (0, "0", None):
|
||||||
raise VendorPushError(f"xiaomi push failed: {data}")
|
raise VendorPushError(f"xiaomi push failed: {_raw_log_summary(data)}")
|
||||||
if str(data.get("result", "ok")).lower() not in ("ok", "success"):
|
if str(data.get("result", "ok")).lower() not in ("ok", "success"):
|
||||||
raise VendorPushError(f"xiaomi push failed: {data}")
|
raise VendorPushError(f"xiaomi push failed: {_raw_log_summary(data)}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _send_xiaomi_data(token: str, payload: dict[str, str]) -> dict[str, Any]:
|
||||||
|
app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET")
|
||||||
|
form = {
|
||||||
|
"registration_id": token,
|
||||||
|
"restricted_package_name": settings.ANDROID_PACKAGE_NAME,
|
||||||
|
"payload": json.dumps(payload, ensure_ascii=False),
|
||||||
|
"pass_through": "1",
|
||||||
|
"time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000),
|
||||||
|
}
|
||||||
|
if settings.XIAOMI_PUSH_CHANNEL_ID.strip():
|
||||||
|
form["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip()
|
||||||
|
data = _request_form(
|
||||||
|
"POST",
|
||||||
|
settings.XIAOMI_PUSH_SEND_ENDPOINT,
|
||||||
|
vendor="xiaomi",
|
||||||
|
operation="send_data_event",
|
||||||
|
data=form,
|
||||||
|
headers={"Authorization": f"key={app_secret}"},
|
||||||
|
)
|
||||||
|
code = data.get("code")
|
||||||
|
if code not in (0, "0", None):
|
||||||
|
raise VendorPushError(f"xiaomi data push failed: {_raw_log_summary(data)}")
|
||||||
|
if str(data.get("result", "ok")).lower() not in ("ok", "success"):
|
||||||
|
raise VendorPushError(f"xiaomi data push failed: {_raw_log_summary(data)}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -490,6 +875,24 @@ def _click_intent_uri(extras: dict[str, str]) -> str:
|
|||||||
return ";".join(parts)
|
return ";".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _vivo_click_intent_uri(extras: dict[str, str]) -> str:
|
||||||
|
"""vivo skipType=4 使用官方要求的完整 intent deeplink 形态。
|
||||||
|
|
||||||
|
`?#Intent` 中的问号不能省;OriginOS 对缺少它的 URI 会退化为“仅打开应用”,
|
||||||
|
不把 S. 参数交给目标 Activity。
|
||||||
|
"""
|
||||||
|
pkg = settings.ANDROID_PACKAGE_NAME
|
||||||
|
parts = [
|
||||||
|
"intent://push/detail?#Intent",
|
||||||
|
"scheme=shaguabijia",
|
||||||
|
f"component={pkg}/{pkg}.MainActivity",
|
||||||
|
"launchFlags=0x14000000",
|
||||||
|
]
|
||||||
|
parts += [f"S.{key}={quote(str(value), safe='')}" for key, value in _click_extras(extras).items()]
|
||||||
|
parts.append("end")
|
||||||
|
return ";".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _xiaomi_template_param(title: str, alert: str) -> str:
|
def _xiaomi_template_param(title: str, alert: str) -> str:
|
||||||
rendered = (
|
rendered = (
|
||||||
settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON
|
settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON
|
||||||
@@ -522,6 +925,8 @@ def _oppo_auth_token() -> str:
|
|||||||
data = _request_form(
|
data = _request_form(
|
||||||
"POST",
|
"POST",
|
||||||
settings.OPPO_PUSH_AUTH_ENDPOINT,
|
settings.OPPO_PUSH_AUTH_ENDPOINT,
|
||||||
|
vendor="oppo",
|
||||||
|
operation="authenticate",
|
||||||
data={
|
data={
|
||||||
"app_key": app_key,
|
"app_key": app_key,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
@@ -530,10 +935,10 @@ def _oppo_auth_token() -> str:
|
|||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
)
|
)
|
||||||
if int(data.get("code", -1)) != 0:
|
if int(data.get("code", -1)) != 0:
|
||||||
raise VendorPushError(f"oppo auth failed: {data}")
|
raise VendorPushError(f"oppo auth failed: {_raw_log_summary(data)}")
|
||||||
token = (data.get("data") or {}).get("auth_token") or data.get("auth_token")
|
token = (data.get("data") or {}).get("auth_token") or data.get("auth_token")
|
||||||
if not token:
|
if not token:
|
||||||
raise VendorPushError(f"oppo auth missing auth_token: {data}")
|
raise VendorPushError(f"oppo auth missing auth_token: {_raw_log_summary(data)}")
|
||||||
return _cache_put(cache_key, str(token), 24 * 3600)
|
return _cache_put(cache_key, str(token), 24 * 3600)
|
||||||
|
|
||||||
|
|
||||||
@@ -548,6 +953,11 @@ def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
|
|||||||
"off_line_ttl": ttl_hours,
|
"off_line_ttl": ttl_hours,
|
||||||
"action_parameters": json.dumps(_click_extras(extras), ensure_ascii=False),
|
"action_parameters": json.dumps(_click_extras(extras), ensure_ascii=False),
|
||||||
}
|
}
|
||||||
|
if extras.get("notificationId"):
|
||||||
|
# 只有已落库的站内消息才计入角标;保活提醒等临时推送不应留下无法消除的未读数。
|
||||||
|
# 客户端打开/已读后会按服务端真实未读总数覆盖,避免长期漂移。
|
||||||
|
notification["badge_operation_type"] = 1
|
||||||
|
notification["badge_message_count"] = 1
|
||||||
# 点击落地:OPPO SDK 没有点击回调,参数只能靠服务端点击动作配置送达——action_parameters 的
|
# 点击落地:OPPO SDK 没有点击回调,参数只能靠服务端点击动作配置送达——action_parameters 的
|
||||||
# 键值对仅在 click_action_type=1/4 时才会注入目标 Activity 的 intent extras(type=0「启动应用」
|
# 键值对仅在 click_action_type=1/4 时才会注入目标 Activity 的 intent extras(type=0「启动应用」
|
||||||
# 会忽略它,extras 全丢 → 点了没反应,与小米 notify_effect=1 同款坑)。
|
# 会忽略它,extras 全丢 → 点了没反应,与小米 notify_effect=1 同款坑)。
|
||||||
@@ -573,6 +983,8 @@ def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
|
|||||||
data = _request_form(
|
data = _request_form(
|
||||||
"POST",
|
"POST",
|
||||||
settings.OPPO_PUSH_SEND_ENDPOINT,
|
settings.OPPO_PUSH_SEND_ENDPOINT,
|
||||||
|
vendor="oppo",
|
||||||
|
operation="send_notification",
|
||||||
data={
|
data={
|
||||||
"auth_token": auth_token,
|
"auth_token": auth_token,
|
||||||
"message": json.dumps(message, ensure_ascii=False),
|
"message": json.dumps(message, ensure_ascii=False),
|
||||||
@@ -580,5 +992,5 @@ def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
|
|||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
)
|
)
|
||||||
if int(data.get("code", -1)) != 0:
|
if int(data.get("code", -1)) != 0:
|
||||||
raise VendorPushError(f"oppo push failed: {data}")
|
raise VendorPushError(f"oppo push failed: {_raw_log_summary(data)}")
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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.internal.store import router as internal_store_router
|
||||||
from app.api.v1.ad import router as ad_router
|
from app.api.v1.ad import router as ad_router
|
||||||
from app.api.v1.analytics import router as analytics_router
|
from app.api.v1.analytics import router as analytics_router
|
||||||
|
from app.api.v1.applog import router as applog_router
|
||||||
from app.api.v1.auth import router as auth_router
|
from app.api.v1.auth import router as auth_router
|
||||||
from app.api.v1.compare import router as compare_router
|
from app.api.v1.compare import router as compare_router
|
||||||
from app.api.v1.compare_milestone import router as compare_milestone_router
|
from app.api.v1.compare_milestone import router as compare_milestone_router
|
||||||
@@ -60,6 +61,10 @@ from app.core.inactivity_reset_worker import (
|
|||||||
start_inactivity_reset_worker,
|
start_inactivity_reset_worker,
|
||||||
stop_inactivity_reset_worker,
|
stop_inactivity_reset_worker,
|
||||||
)
|
)
|
||||||
|
from app.core.llm_cost_backfill_worker import (
|
||||||
|
start_llm_cost_backfill_worker,
|
||||||
|
stop_llm_cost_backfill_worker,
|
||||||
|
)
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
from app.core.observe import RequestMetricsMiddleware
|
from app.core.observe import RequestMetricsMiddleware
|
||||||
from app.core.observe_worker import (
|
from app.core.observe_worker import (
|
||||||
@@ -103,6 +108,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|||||||
daily_exchange_task = start_daily_exchange_worker()
|
daily_exchange_task = start_daily_exchange_worker()
|
||||||
observe_task = start_observe_worker()
|
observe_task = start_observe_worker()
|
||||||
inactivity_task = start_inactivity_reset_worker()
|
inactivity_task = start_inactivity_reset_worker()
|
||||||
|
llm_cost_backfill_task = start_llm_cost_backfill_worker()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@@ -112,6 +118,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|||||||
await stop_daily_exchange_worker(daily_exchange_task)
|
await stop_daily_exchange_worker(daily_exchange_task)
|
||||||
await stop_observe_worker(observe_task)
|
await stop_observe_worker(observe_task)
|
||||||
await stop_inactivity_reset_worker(inactivity_task)
|
await stop_inactivity_reset_worker(inactivity_task)
|
||||||
|
await stop_llm_cost_backfill_worker(llm_cost_backfill_task)
|
||||||
await aclose_pricebot_client()
|
await aclose_pricebot_client()
|
||||||
mt_meituan.close_client()
|
mt_meituan.close_client()
|
||||||
logger.info("shutting down")
|
logger.info("shutting down")
|
||||||
@@ -147,6 +154,7 @@ app.include_router(auth_router)
|
|||||||
app.include_router(user_router)
|
app.include_router(user_router)
|
||||||
app.include_router(feedback_router)
|
app.include_router(feedback_router)
|
||||||
app.include_router(analytics_router)
|
app.include_router(analytics_router)
|
||||||
|
app.include_router(applog_router)
|
||||||
app.include_router(invite_router)
|
app.include_router(invite_router)
|
||||||
app.include_router(coupon_router)
|
app.include_router(coupon_router)
|
||||||
app.include_router(device_router)
|
app.include_router(device_router)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from app.models.inactivity import ( # noqa: F401
|
|||||||
from app.models.invite import InviteRelation # noqa: F401
|
from app.models.invite import InviteRelation # noqa: F401
|
||||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||||
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
|
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
|
||||||
|
from app.models.limit_policy import LimitPolicyOverride # noqa: F401
|
||||||
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
|
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
|
||||||
from app.models.notification import Notification # noqa: F401
|
from app.models.notification import Notification # noqa: F401
|
||||||
from app.models.onboarding import OnboardingCompletion # noqa: F401
|
from app.models.onboarding import OnboardingCompletion # noqa: F401
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"""GroMore 天级排序价预估与 ADN Reporting 收益(定时拉取入库)。
|
"""穿山甲 GroMore 天级收益报表(后台结算口径,定时拉取入库)。
|
||||||
|
|
||||||
每行 = GroMore 数据 API 返回的一条「日期 × 应用 × 代码位」聚合收益(`integrations/pangle_report`
|
每行 = GroMore 数据 API 返回的一条「日期 × 应用 × 代码位」聚合收益(`integrations/pangle_report`
|
||||||
+ `scripts/sync_pangle_revenue` 落库),与 `ad_ecpm_record`(客户端 SDK eCPM 折算的预估)
|
+ `scripts/sync_pangle_revenue` 落库)。**权威/预估收益的来源**,与 `ad_ecpm_record`(客户端自报
|
||||||
互为对照:
|
eCPM 折算的预估)互为对照:
|
||||||
|
|
||||||
- `revenue_yuan` ← 接口 `revenue`(排序价/竞价实时价预估,元,不是结算收入);
|
- `revenue_yuan` ← 接口 `revenue`(预估收益,元;排序价×展示/1000,所有 ADN 都有);
|
||||||
- `api_revenue_yuan` ← 接口 `api_revenue`(各 ADN Reporting 回传收益,元,更接近结算;
|
- `api_revenue_yuan` ← 接口 `api_revenue`(收益Api,元;各 ADN 经 Reporting 回传、更接近结算;
|
||||||
未配置该 ADN 的 Reporting 或查当天时为空)。
|
未配置该 ADN 的 Reporting 或查当天时为空)。
|
||||||
|
|
||||||
⚠️ 穿山甲不提供分用户/设备维度,故本表最细只到 日期×应用×代码位,**无法挂到逐条广告事件**;
|
⚠️ 穿山甲不提供分用户/设备维度,故本表最细只到 日期×应用×代码位,**无法挂到逐条广告事件**;
|
||||||
广告收益报表里只用于汇总/趋势级的 GroMore/ADN 对账,不改逐条行的客户端预估。
|
广告收益报表里只用于汇总/趋势级的「穿山甲后台收益」,不改逐条行的客户端预估。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -51,9 +51,9 @@ class AdPangleDailyRevenue(Base):
|
|||||||
our_code_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
our_code_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||||
# 广告源(接口 network 数字→名,如 pangle/gdt);"" = 未分广告源的代码位汇总行(当前默认口径)。
|
# 广告源(接口 network 数字→名,如 pangle/gdt);"" = 未分广告源的代码位汇总行(当前默认口径)。
|
||||||
adn: Mapped[str] = mapped_column(String(16), nullable=False, default="")
|
adn: Mapped[str] = mapped_column(String(16), nullable=False, default="")
|
||||||
# 排序价/竞价实时价预估(元)← 接口 revenue,非结算收入。
|
# 预估收益(元)← 接口 revenue。
|
||||||
revenue_yuan: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
revenue_yuan: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||||
# ADN Reporting API 收益(元)← api_revenue;未配 Reporting / 当天等情况不返回 → NULL。
|
# 收益Api(元)← 接口 api_revenue;未配 Reporting / 当天 等情况接口不返回 → NULL。
|
||||||
api_revenue_yuan: Mapped[float | None] = mapped_column(Float, nullable=True)
|
api_revenue_yuan: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
# 预估 eCPM 原值(接口 ecpm,单位元/千次,**与客户端 getEcpm 的「分」不同**),参考用原样存。
|
# 预估 eCPM 原值(接口 ecpm,单位元/千次,**与客户端 getEcpm 的「分」不同**),参考用原样存。
|
||||||
ecpm: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
ecpm: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
|||||||
@@ -102,12 +102,21 @@ class ComparisonRecord(Base):
|
|||||||
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
|
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
|
||||||
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
|
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
|
||||||
information: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
information: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
# 失败卡「原因」行的展示文案(仅 status=failed 时非空):information 具体则=它;笼统则从
|
||||||
|
# platform_results 捞出的业务原因(打烊/未起送/找不到店或菜/单点不配送);纯系统失败为 None
|
||||||
|
# → 端侧显示品牌兜底「网络开小差…」。写路径(harvest_done / upsert_record)落库时派生。
|
||||||
|
# 见 repositories.comparison._derive_fail_display。
|
||||||
|
fail_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
|
||||||
# ===== 明细(JSON,越详细越好)=====
|
# ===== 明细(JSON,越详细越好)=====
|
||||||
# 下单菜品 [{name, qty, specs?}]
|
# 下单菜品 [{name, qty, specs?}]
|
||||||
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||||
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细)
|
# 逐平台对比 [{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)
|
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||||
|
# 展示模型统一数组(pricebot done.params.platforms 原样存): 每平台一行、自带
|
||||||
|
# status/is_best/display/display_order, 记录页据此直接渲染, 不再靠 comparison_results
|
||||||
|
# + 客户端合并 + 前端派生。老记录/旧客户端为空 → 前端回退老 comparison_results 渲染。
|
||||||
|
platforms: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||||
# 目标平台未找到、跳过的菜名
|
# 目标平台未找到、跳过的菜名
|
||||||
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||||
# 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底
|
# 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ class DeviceLiveness(Base):
|
|||||||
__tablename__ = "device_liveness"
|
__tablename__ = "device_liveness"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
|
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
|
||||||
|
UniqueConstraint(
|
||||||
|
"push_vendor",
|
||||||
|
"push_token",
|
||||||
|
name="uq_device_liveness_vendor_token",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -48,6 +53,11 @@ class DeviceLiveness(Base):
|
|||||||
push_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
push_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
# 厂商 push token / regId / registration_id;不同厂商命名不同,后端统一存这里。
|
# 厂商 push token / regId / registration_id;不同厂商命名不同,后端统一存这里。
|
||||||
push_token: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
push_token: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
# 客户端每次登录生成的高熵随机值。离线退出后只凭此值精确撤销旧会话绑定,
|
||||||
|
# 新账号/新会话注册时会替换它,故旧解绑任务绝不能误解绑新登录。
|
||||||
|
push_binding_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
|
||||||
|
# 被退出/转移的 binding 留作墓碑,阻止旧的在途 register/heartbeat 把 token 抢回。
|
||||||
|
push_binding_revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
|
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
|
||||||
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
|
||||||
|
|||||||
+25
-41
@@ -1,20 +1,4 @@
|
|||||||
"""新手引导视频播放记录(领券浮层前 N 次用它替代广告)。
|
"""引导视频待播放计划、起播快照与逐圈结算状态。"""
|
||||||
|
|
||||||
产品规则(2026-07 拍板):新用户点「一键自动领取」后的等候浮层,**前 3 次**不放广告,
|
|
||||||
改放运营后台上传的引导视频;每次固定 120 金币,中途关闭也算看完照发。
|
|
||||||
|
|
||||||
口径:
|
|
||||||
- **计次按账号**(user_id),与设备无关 —— 换设备不重新送 3 次。
|
|
||||||
- **开播即计数**:客户端每次要展示浮层时调 `/api/v1/guide-video/start`,服务端当场
|
|
||||||
写一行(status='playing')并返回 play_token;`COUNT(*)` 即已用次数。用户中途 kill
|
|
||||||
App 也算用掉一次(产品选定口径,防反复进出刷金币)。
|
|
||||||
- **发币幂等**靠 play_token 定位 + `status='playing'` 条件更新:并发两次上报只有一次
|
|
||||||
改到行(另一次 rowcount=0),所以只发一次币。光有 play_token 唯一键挡不住 —— 发币走的是
|
|
||||||
UPDATE,不 INSERT,撞不到任何唯一键。
|
|
||||||
- **次数上限**靠 (user_id, seq) 唯一键兜底,防并发 /start 绕过 COUNT 判定(见下)。
|
|
||||||
|
|
||||||
与广告收益(ad_feed_reward_record)彻底分离:引导视频不是广告,不该进广告收益报表。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -26,48 +10,48 @@ from app.db.base import Base
|
|||||||
|
|
||||||
|
|
||||||
class GuideVideoPlay(Base):
|
class GuideVideoPlay(Base):
|
||||||
"""一次引导视频播放一行。开播时建(status='playing'),发币后置 'granted'。"""
|
|
||||||
|
|
||||||
__tablename__ = "guide_video_play"
|
__tablename__ = "guide_video_play"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# 客户端幂等键:同一次播放重复上报奖励只发一次。
|
|
||||||
UniqueConstraint("play_token", name="uq_guide_video_play_token"),
|
UniqueConstraint("play_token", name="uq_guide_video_play_token"),
|
||||||
# 次数上限的**硬约束**:start_play 是无锁 check-then-insert(读 COUNT 算 seq 再插),
|
Index(
|
||||||
# N 个并发 /start 会都读到同一个已用次数、算出同一个 seq,不拦就能各拿一个 token、
|
"uq_guide_video_play_user_scene_seq",
|
||||||
# 各发一次金币,3 次上限形同虚设(改包即可无限刷)。seq 唯一 → 并发同 seq 必撞,
|
"user_id",
|
||||||
# start_play 捕获 IntegrityError 降级成"这次不放视频"。
|
"scene",
|
||||||
# 用 unique Index 而非 UniqueConstraint:与迁移里的 create_index 对齐(SQLite 加约束
|
"seq",
|
||||||
# 要整表重建),autogenerate 才不会每次报一条假 diff。
|
unique=True,
|
||||||
Index("uq_guide_video_play_user_seq", "user_id", "seq", unique=True),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||||
)
|
)
|
||||||
# 服务端生成下发给客户端的幂等键(uuid hex)。
|
|
||||||
play_token: Mapped[str] = mapped_column(String(64), nullable=False)
|
play_token: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
# 触发场景:目前只有 coupon(领券等候浮层);留字段以便日后比价等场景复用。
|
|
||||||
scene: Mapped[str] = mapped_column(String(16), nullable=False, default="coupon")
|
scene: Mapped[str] = mapped_column(String(16), nullable=False, default="coupon")
|
||||||
# 本账号第几次(1-based),= 建行时已有行数 + 1。日常判定仍以 COUNT 为准,但 (user_id, seq)
|
# prepare 不占次数,seq=NULL;start 成功才写入 1-based seq。
|
||||||
# 唯一键让并发 /start 只能成一个 —— 见 __table_args__。
|
seq: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
seq: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
||||||
# 当次下发的视频地址(运营换片后能回溯用户当时看的是哪支)。
|
|
||||||
video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
# 实发金币;未发时 0。
|
|
||||||
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
# playing(已开播未发币) / granted(已发币)。
|
duration_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="playing")
|
config_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
# 客户端上报时是否播完(true=自然播完 / false=中途关闭)。仅留痕:两者都发币。
|
settled_circles: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# prepared / started / completed / legacy_completed / legacy_closed
|
||||||
|
status: Mapped[str] = mapped_column(String(24), nullable=False, default="prepared")
|
||||||
completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
started_at: Mapped[datetime] = mapped_column(
|
prepared_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), index=True, nullable=True
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), index=True, nullable=True
|
||||||
)
|
)
|
||||||
granted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
granted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
return (
|
return (
|
||||||
f"<GuideVideoPlay user={self.user_id} seq={self.seq} "
|
f"<GuideVideoPlay user={self.user_id} scene={self.scene} seq={self.seq} "
|
||||||
f"{self.status} coin={self.coin}>"
|
f"{self.status} circles={self.settled_circles}>"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Per-subject limit policy overrides used by the admin whitelist page."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LimitPolicyOverride(Base):
|
||||||
|
"""One rule override for one phone or device.
|
||||||
|
|
||||||
|
``reset_at`` is a non-destructive usage baseline. Business records and
|
||||||
|
security events remain intact; quota readers only count rows at or after
|
||||||
|
this timestamp.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "limit_policy_override"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"subject_type",
|
||||||
|
"subject_value",
|
||||||
|
"rule_code",
|
||||||
|
name="uq_limit_policy_subject_rule",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_limit_policy_lookup",
|
||||||
|
"subject_type",
|
||||||
|
"subject_value",
|
||||||
|
"rule_code",
|
||||||
|
"enabled",
|
||||||
|
),
|
||||||
|
Index("ix_limit_policy_expires", "expires_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
subject_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
subject_value: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
rule_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
# 产品只保留“临时不限/免告警”。即使有内部脚本绕过 API 直接建 ORM
|
||||||
|
# 对象,也不能再悄悄落成已经下线的 override 模式。
|
||||||
|
mode: Mapped[str] = mapped_column(String(24), nullable=False, default="unlimited")
|
||||||
|
limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True, server_default=true()
|
||||||
|
)
|
||||||
|
starts_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
reset_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
created_by_admin_id: Mapped[int | None] = mapped_column(Integer, 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,
|
||||||
|
)
|
||||||
+8
-1
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Integer, String, false, func
|
from sqlalchemy import Boolean, DateTime, Integer, String, Text, false, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@@ -62,6 +62,13 @@ class User(Base):
|
|||||||
Boolean, nullable=False, default=False, server_default=false()
|
Boolean, nullable=False, default=False, server_default=false()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 运营人工风险标记。与自动风控分值分开:这里表达人工复核结论,并保留可编辑备注,
|
||||||
|
# 供邀请提现、其他提现和用户管理三个页面统一展示。
|
||||||
|
is_high_risk: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default=false(), index=True
|
||||||
|
)
|
||||||
|
high_risk_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ class CoinTransaction(Base):
|
|||||||
sqlite_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
|
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"),
|
postgresql_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
|
||||||
),
|
),
|
||||||
|
Index(
|
||||||
|
"ux_coin_transaction_guide_video_ref",
|
||||||
|
"user_id",
|
||||||
|
"biz_type",
|
||||||
|
"ref_id",
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=text("biz_type = 'guide_video' AND ref_id IS NOT NULL"),
|
||||||
|
postgresql_where=text("biz_type = 'guide_video' AND ref_id IS NOT NULL"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""广告 eCPM 上报 CRUD(内部收益统计/对账)。
|
"""广告 eCPM 上报 CRUD(内部收益统计/对账)。
|
||||||
|
|
||||||
客户端在广告展示后(onAdShow)读到 eCPM,经鉴权接口上报,这里落库。鉴权接口已确保
|
客户端在广告展示后(onAdShow)读到 eCPM,经鉴权接口上报,这里落库。鉴权接口已确保
|
||||||
user 存在(JWT),故不做 UnknownUser 校验。best-effort 上报:丢一两条不影响发奖业务;
|
user 存在(JWT),故不做 UnknownUser 校验。best-effort 上报:丢一两条不影响业务,
|
||||||
汇总收入以 ADN Reporting API 和最终结算单为准。
|
穿山甲后台报表是结算权威兜底。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ def create_ecpm_record(
|
|||||||
db.rollback()
|
db.rollback()
|
||||||
# 撞唯一约束 uq_ad_ecpm_record_session(全局按 ad_session_id、不含 user_id):并发同会话重复上报,
|
# 撞唯一约束 uq_ad_ecpm_record_session(全局按 ad_session_id、不含 user_id):并发同会话重复上报,
|
||||||
# 或同一 ad_session_id 已被先到的上报占用。本接口 fire-and-forget、best-effort —— 丢一条不影响业务
|
# 或同一 ad_session_id 已被先到的上报占用。本接口 fire-and-forget、best-effort —— 丢一条不影响业务
|
||||||
# (收入另由 ADN Reporting API 对账),绝不向客户端抛 500。兜底查找须与唯一约束**同口径**(只按 ad_session_id、
|
# (穿山甲后台才是结算权威),绝不向客户端抛 500。兜底查找须与唯一约束**同口径**(只按 ad_session_id、
|
||||||
# 不带 user_id):否则不同 user 上报了同一 ad_session_id 时,带 user_id 的查找会漏掉那条别人的记录 →
|
# 不带 user_id):否则不同 user 上报了同一 ad_session_id 时,带 user_id 的查找会漏掉那条别人的记录 →
|
||||||
# 旧逻辑在此 raise 成 500(本应静默吞掉)。
|
# 旧逻辑在此 raise 成 500(本应静默吞掉)。
|
||||||
existing = _find_by_session_global(db, ad_session_id)
|
existing = _find_by_session_global(db, ad_session_id)
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core import rewards
|
from app.core import limit_policy, rewards
|
||||||
from app.core.rewards import cn_today
|
from app.core.rewards import cn_today
|
||||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||||
from app.repositories import wallet as crud_wallet
|
from app.repositories import wallet as crud_wallet
|
||||||
@@ -29,8 +31,14 @@ def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | No
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
def _granted_today(
|
||||||
return db.execute(
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
reward_date: str,
|
||||||
|
*,
|
||||||
|
reset_at: datetime | None = None,
|
||||||
|
) -> int:
|
||||||
|
stmt = (
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(AdFeedRewardRecord)
|
.select_from(AdFeedRewardRecord)
|
||||||
.where(
|
.where(
|
||||||
@@ -38,7 +46,10 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
|||||||
AdFeedRewardRecord.reward_date == reward_date,
|
AdFeedRewardRecord.reward_date == reward_date,
|
||||||
AdFeedRewardRecord.status == "granted",
|
AdFeedRewardRecord.status == "granted",
|
||||||
)
|
)
|
||||||
).scalar_one()
|
)
|
||||||
|
if reset_at is not None:
|
||||||
|
stmt = stmt.where(AdFeedRewardRecord.created_at >= reset_at)
|
||||||
|
return db.execute(stmt).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
def granted_unit_total(db: Session, user_id: int) -> int:
|
def granted_unit_total(db: Session, user_id: int) -> int:
|
||||||
@@ -121,7 +132,27 @@ def grant_feed_reward(
|
|||||||
)
|
)
|
||||||
return _commit_record(db, rec, client_event_id)
|
return _commit_record(db, rec, client_event_id)
|
||||||
|
|
||||||
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
|
daily_policy = limit_policy.resolve_for_user(
|
||||||
|
db,
|
||||||
|
"ad.feed.daily",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
daily_limit = (
|
||||||
|
rewards.get_ad_daily_limit(db)
|
||||||
|
if daily_policy.override_id is None
|
||||||
|
and daily_policy.bucket_version == "default"
|
||||||
|
else daily_policy.limit
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
daily_limit is not None
|
||||||
|
and _granted_today(
|
||||||
|
db,
|
||||||
|
user_id,
|
||||||
|
today,
|
||||||
|
reset_at=daily_policy.reset_at,
|
||||||
|
)
|
||||||
|
>= daily_limit
|
||||||
|
):
|
||||||
rec = AdFeedRewardRecord(
|
rec = AdFeedRewardRecord(
|
||||||
client_event_id=client_event_id,
|
client_event_id=client_event_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"""穿山甲 GroMore 天级收益 读写(`ad_pangle_daily_revenue` 表)。
|
"""穿山甲 GroMore 天级收益 读写(`ad_pangle_daily_revenue` 表)。
|
||||||
|
|
||||||
`scripts/sync_pangle_revenue` 拉数后调 `upsert_daily_rows` 落库(同一(日期×应用×代码位×广告源)
|
`scripts/sync_pangle_revenue` 拉数后调 `upsert_daily_rows` 落库(同一(日期×应用×代码位×广告源)
|
||||||
幂等覆盖,T+1 订正可重跑);admin 广告收益报表调 `aggregate_by_date` 取 GroMore/ADN 收益做
|
幂等覆盖,T+1 订正可重跑);admin 广告收益报表调 `aggregate_by_date` 取「穿山甲后台收益」做
|
||||||
汇总/趋势级展示。穿山甲无用户维度,故这里不涉及 user_id。
|
汇总/趋势级展示。穿山甲无用户维度,故这里不涉及 user_id。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Collection
|
from collections.abc import Collection
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, TypedDict
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
@@ -24,7 +23,6 @@ class PangleDateAgg(TypedDict):
|
|||||||
revenue_yuan: float
|
revenue_yuan: float
|
||||||
api_revenue_yuan: float | None
|
api_revenue_yuan: float | None
|
||||||
impressions: int
|
impressions: int
|
||||||
synced_at: datetime | None
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_daily_rows(db: Session, rows: list[dict[str, Any]]) -> dict[str, int]:
|
def upsert_daily_rows(db: Session, rows: list[dict[str, Any]]) -> dict[str, int]:
|
||||||
@@ -89,7 +87,6 @@ def aggregate_by_date(
|
|||||||
func.sum(AdPangleDailyRevenue.revenue_yuan),
|
func.sum(AdPangleDailyRevenue.revenue_yuan),
|
||||||
func.sum(AdPangleDailyRevenue.api_revenue_yuan),
|
func.sum(AdPangleDailyRevenue.api_revenue_yuan),
|
||||||
func.sum(AdPangleDailyRevenue.impressions),
|
func.sum(AdPangleDailyRevenue.impressions),
|
||||||
func.max(AdPangleDailyRevenue.synced_at),
|
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
AdPangleDailyRevenue.report_date >= date_from,
|
AdPangleDailyRevenue.report_date >= date_from,
|
||||||
@@ -106,12 +103,11 @@ def aggregate_by_date(
|
|||||||
stmt = stmt.where(AdPangleDailyRevenue.our_code_id.in_(our_code_ids))
|
stmt = stmt.where(AdPangleDailyRevenue.our_code_id.in_(our_code_ids))
|
||||||
|
|
||||||
out: list[PangleDateAgg] = []
|
out: list[PangleDateAgg] = []
|
||||||
for report_date, rev, api_rev, imp, synced_at in db.execute(stmt).all():
|
for report_date, rev, api_rev, imp in db.execute(stmt).all():
|
||||||
out.append(PangleDateAgg(
|
out.append(PangleDateAgg(
|
||||||
date=report_date,
|
date=report_date,
|
||||||
revenue_yuan=round(float(rev or 0.0), 6),
|
revenue_yuan=round(float(rev or 0.0), 6),
|
||||||
api_revenue_yuan=(round(float(api_rev), 6) if api_rev is not None else None),
|
api_revenue_yuan=(round(float(api_rev), 6) if api_rev is not None else None),
|
||||||
impressions=int(imp or 0),
|
impressions=int(imp or 0),
|
||||||
synced_at=synced_at,
|
|
||||||
))
|
))
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core import rewards
|
from app.core import limit_policy, rewards
|
||||||
from app.core.ad_cooldown import compute_cooldown
|
from app.core.ad_cooldown import compute_cooldown
|
||||||
from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
|
from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
|
||||||
from app.models.ad_reward import AdRewardRecord
|
from app.models.ad_reward import AdRewardRecord
|
||||||
@@ -96,8 +96,14 @@ def round_coin_total(db: Session, user_id: int, boost_round_id: str) -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
def _granted_today(
|
||||||
return db.execute(
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
reward_date: str,
|
||||||
|
*,
|
||||||
|
reset_at: datetime | None = None,
|
||||||
|
) -> int:
|
||||||
|
stmt = (
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(AdRewardRecord)
|
.select_from(AdRewardRecord)
|
||||||
.where(
|
.where(
|
||||||
@@ -106,7 +112,10 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
|||||||
AdRewardRecord.status == "granted",
|
AdRewardRecord.status == "granted",
|
||||||
AdRewardRecord.reward_scene == "reward_video",
|
AdRewardRecord.reward_scene == "reward_video",
|
||||||
)
|
)
|
||||||
).scalar_one()
|
)
|
||||||
|
if reset_at is not None:
|
||||||
|
stmt = stmt.where(AdRewardRecord.created_at >= reset_at)
|
||||||
|
return db.execute(stmt).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
def _granted_cumulative(db: Session, user_id: int) -> int:
|
def _granted_cumulative(db: Session, user_id: int) -> int:
|
||||||
@@ -165,7 +174,27 @@ def grant_ad_reward(
|
|||||||
DAILY_AD_WATCH_SECONDS_LIMIT > 0
|
DAILY_AD_WATCH_SECONDS_LIMIT > 0
|
||||||
and watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT
|
and watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT
|
||||||
)
|
)
|
||||||
over_count = _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db)
|
daily_policy = limit_policy.resolve_for_user(
|
||||||
|
db,
|
||||||
|
"ad.reward_video.daily",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
daily_limit = (
|
||||||
|
rewards.get_ad_daily_limit(db)
|
||||||
|
if daily_policy.override_id is None
|
||||||
|
and daily_policy.bucket_version == "default"
|
||||||
|
else daily_policy.limit
|
||||||
|
)
|
||||||
|
over_count = (
|
||||||
|
daily_limit is not None
|
||||||
|
and _granted_today(
|
||||||
|
db,
|
||||||
|
user_id,
|
||||||
|
today,
|
||||||
|
reset_at=daily_policy.reset_at,
|
||||||
|
)
|
||||||
|
>= daily_limit
|
||||||
|
)
|
||||||
if over_time or over_count:
|
if over_time or over_count:
|
||||||
rec = AdRewardRecord(
|
rec = AdRewardRecord(
|
||||||
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
|
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
|
||||||
@@ -321,19 +350,24 @@ def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardR
|
|||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
def _granted_times_today_desc(db: Session, user_id: int, reward_date: str) -> list[datetime]:
|
def _granted_times_today_desc(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
reward_date: str,
|
||||||
|
*,
|
||||||
|
reset_at: datetime | None = None,
|
||||||
|
) -> list[datetime]:
|
||||||
"""当日 status=granted 记录的 created_at,按时间倒序(最新在前)——冷却策略的输入数据。"""
|
"""当日 status=granted 记录的 created_at,按时间倒序(最新在前)——冷却策略的输入数据。"""
|
||||||
return list(
|
stmt = select(AdRewardRecord.created_at).where(
|
||||||
db.execute(
|
|
||||||
select(AdRewardRecord.created_at)
|
|
||||||
.where(
|
|
||||||
AdRewardRecord.user_id == user_id,
|
AdRewardRecord.user_id == user_id,
|
||||||
AdRewardRecord.reward_date == reward_date,
|
AdRewardRecord.reward_date == reward_date,
|
||||||
AdRewardRecord.status == "granted",
|
AdRewardRecord.status == "granted",
|
||||||
AdRewardRecord.reward_scene == "reward_video",
|
AdRewardRecord.reward_scene == "reward_video",
|
||||||
)
|
)
|
||||||
.order_by(AdRewardRecord.created_at.desc())
|
if reset_at is not None:
|
||||||
).scalars()
|
stmt = stmt.where(AdRewardRecord.created_at >= reset_at)
|
||||||
|
return list(
|
||||||
|
db.execute(stmt.order_by(AdRewardRecord.created_at.desc())).scalars()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -349,16 +383,47 @@ def today_status(
|
|||||||
旧客户端兼容,当前 DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸。
|
旧客户端兼容,当前 DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸。
|
||||||
"""
|
"""
|
||||||
today = cn_today().isoformat()
|
today = cn_today().isoformat()
|
||||||
granted_desc = _granted_times_today_desc(db, user_id, today)
|
daily_policy = limit_policy.resolve_for_user(
|
||||||
|
db,
|
||||||
|
"ad.reward_video.daily",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
cooldown_policy = limit_policy.resolve_for_user(
|
||||||
|
db,
|
||||||
|
"ad.reward_video.cooldown",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
daily_limit = (
|
||||||
|
rewards.get_ad_daily_limit(db)
|
||||||
|
if daily_policy.override_id is None
|
||||||
|
and daily_policy.bucket_version == "default"
|
||||||
|
else daily_policy.limit
|
||||||
|
)
|
||||||
|
cooldown_seconds = (
|
||||||
|
rewards.get_ad_cooldown_sec(db)
|
||||||
|
if cooldown_policy.override_id is None
|
||||||
|
and cooldown_policy.bucket_version == "default"
|
||||||
|
else (cooldown_policy.limit or 0)
|
||||||
|
)
|
||||||
|
granted_desc = _granted_times_today_desc(
|
||||||
|
db,
|
||||||
|
user_id,
|
||||||
|
today,
|
||||||
|
reset_at=daily_policy.reset_at,
|
||||||
|
)
|
||||||
state = compute_cooldown(
|
state = compute_cooldown(
|
||||||
granted_desc,
|
granted_desc,
|
||||||
datetime.now(timezone.utc),
|
datetime.now(UTC),
|
||||||
round_size=rewards.get_ad_round_count(db),
|
round_size=rewards.get_ad_round_count(db),
|
||||||
cooldown_seconds=rewards.get_ad_cooldown_sec(db),
|
cooldown_seconds=cooldown_seconds,
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
len(granted_desc),
|
len(granted_desc),
|
||||||
rewards.get_ad_daily_limit(db),
|
(
|
||||||
|
daily_limit
|
||||||
|
if daily_limit is not None
|
||||||
|
else limit_policy.get_rule("ad.reward_video.daily").max_value
|
||||||
|
),
|
||||||
0,
|
0,
|
||||||
state.round_count,
|
state.round_count,
|
||||||
state.cooldown_until,
|
state.cooldown_until,
|
||||||
|
|||||||
+243
-20
@@ -52,16 +52,100 @@ def _product_names_from_items(items: list | None) -> str | None:
|
|||||||
return joined[:500] or None
|
return joined[:500] or None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 失败记录的展示文案(记录页失败卡「原因」行)------------------------------
|
||||||
|
# information 具体就直出;笼统(_GENERIC_INFO)则从 platform_results 捞一条用户可读的业务
|
||||||
|
# 原因;捞不到 → None(端侧显示品牌兜底「网络开小差…」)。pricebot 把 store_closed /
|
||||||
|
# no_delivery 漏成了 status=failed,这里按 reason 关键字补判;打烊类 reason 常带一坨脏店名
|
||||||
|
# (店名+月售+起送+配送…),统一成简短模板。自动化黑话(搜索失败/读价失败/购物车残留/裸
|
||||||
|
# FAILED…)不给用户看 → 归入品牌兜底。
|
||||||
|
|
||||||
|
# pricebot 组不出具体原因时的笼统 information(线上统计的大头),一律走品牌兜底。
|
||||||
|
_GENERIC_INFO = {
|
||||||
|
"比价过程出错,请稍后重试",
|
||||||
|
"比价出错",
|
||||||
|
"比价未完成",
|
||||||
|
"done 参数缺少可验证的目标平台结果",
|
||||||
|
}
|
||||||
|
# 干净业务结局 status(直接可信),按展示优先级(越靠前越先选)。
|
||||||
|
_BIZ_STATUS_PRIORITY = (
|
||||||
|
"below_minimum",
|
||||||
|
"no_delivery",
|
||||||
|
"store_closed",
|
||||||
|
"items_not_found",
|
||||||
|
"store_not_found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _store_closed_text(reason: str | None) -> str:
|
||||||
|
"""打烊/暂停营业/休息类 reason 常带脏店名元数据 → 只留结论,套简短模板。"""
|
||||||
|
r = reason or ""
|
||||||
|
if "暂停营业" in r:
|
||||||
|
state = "暂停营业"
|
||||||
|
elif "休息" in r:
|
||||||
|
state = "休息中"
|
||||||
|
else:
|
||||||
|
state = "已打烊"
|
||||||
|
return f"门店{state},无法比价"
|
||||||
|
|
||||||
|
|
||||||
|
def _target_display_reason(platform_results: dict | None) -> str | None:
|
||||||
|
"""从逐平台结果里挑一条"可展示给用户"的失败原因;挑不到返回 None。
|
||||||
|
① status 命中干净业务结局集 → 直接采信(打烊套模板,其余用 reason);
|
||||||
|
② 补判 pricebot 漏成 status=failed 的两类:打烊(套模板)、单点不配送(reason 本身干净);
|
||||||
|
自动化黑话(搜索失败/读价失败/购物车残留/裸 FAILED…)一律不展示 → None。"""
|
||||||
|
pr = platform_results or {}
|
||||||
|
targets = [
|
||||||
|
v for v in pr.values() if isinstance(v, dict) and not v.get("is_source")
|
||||||
|
]
|
||||||
|
for want in _BIZ_STATUS_PRIORITY: # ① 干净 status 优先
|
||||||
|
for v in targets:
|
||||||
|
if v.get("status") == want:
|
||||||
|
if want == "store_closed":
|
||||||
|
return _store_closed_text(v.get("reason"))
|
||||||
|
if v.get("reason"):
|
||||||
|
return v["reason"]
|
||||||
|
for v in targets: # ② 漏成 failed 的业务结局补判
|
||||||
|
if v.get("status") != "failed":
|
||||||
|
continue
|
||||||
|
reason = (v.get("reason") or "").strip()
|
||||||
|
if any(k in reason for k in ("打烊", "暂停营业", "休息")):
|
||||||
|
return _store_closed_text(reason)
|
||||||
|
if "单点不配送" in reason:
|
||||||
|
return reason
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_fail_display(
|
||||||
|
information: str | None, platform_results: dict | None
|
||||||
|
) -> str | None:
|
||||||
|
"""失败记录展示文案:information 具体则直出;笼统则从 platform_results 捞/补判;
|
||||||
|
都拿不到 → None(端侧品牌兜底)。仅在 status=failed 时调用。"""
|
||||||
|
info = (information or "").strip()
|
||||||
|
text = info if (info and info not in _GENERIC_INFO) else _target_display_reason(
|
||||||
|
platform_results
|
||||||
|
)
|
||||||
|
return text[:256] if text else None
|
||||||
|
|
||||||
|
|
||||||
def _derive(payload: ComparisonRecordIn) -> dict:
|
def _derive(payload: ComparisonRecordIn) -> dict:
|
||||||
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
|
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
|
||||||
results = payload.comparison_results
|
results = payload.comparison_results
|
||||||
|
_pr = payload.platform_results or {}
|
||||||
|
|
||||||
# 最优 = rank 最小的一条;协议已升序,但不信顺序,显式按 rank/price 兜底取最小价。
|
def _is_short(r) -> bool:
|
||||||
best = None
|
# 缺菜(漏菜)店: 少买了菜总价虚低, 不参与最优评选。逐平台 skipped 在 platform_results, 行里没有。
|
||||||
|
# platform_results 内层结构宽松(pricebot/老客户端透传, 可伪造), 值非 dict 时按"不缺菜"处理, 不崩。
|
||||||
|
info = _pr.get(r.platform_id) if r.platform_id else None
|
||||||
|
return isinstance(info, dict) and (info.get("skipped_dish_count") or 0) > 0
|
||||||
|
|
||||||
|
# 最优 = 非缺菜里 rank 最小(=最便宜)的一条;协议已升序,但不信顺序,显式按 rank/price 取。
|
||||||
|
# 源平台永远全菜, 故全目标缺菜时回落到源(is_source_best、saved=0), 不把虚低价当最低。
|
||||||
priced = [r for r in results if r.price is not None]
|
priced = [r for r in results if r.price is not None]
|
||||||
if priced:
|
clean = [r for r in priced if not _is_short(r)]
|
||||||
|
best = None
|
||||||
|
if clean:
|
||||||
best = min(
|
best = min(
|
||||||
priced,
|
clean,
|
||||||
key=lambda r: (r.rank if r.rank is not None else 10**9, r.price),
|
key=lambda r: (r.rank if r.rank is not None else 10**9, r.price),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,8 +164,9 @@ def _derive(payload: ComparisonRecordIn) -> dict:
|
|||||||
|
|
||||||
is_source_best = best.is_source if best is not None else None
|
is_source_best = best.is_source if best is not None else None
|
||||||
|
|
||||||
# status:客户端显式给了就用;否则有"非源且有价"的结果=success,否则 failed
|
# status:优先 pricebot record_status(区分 below_minimum/store_closed) → 客户端显式 status
|
||||||
status = payload.status
|
# → 兜底"非源且有价"=success/否则 failed。record_status 让"未满起送"不再塌缩成 failed。
|
||||||
|
status = payload.record_status or payload.status
|
||||||
if status is None:
|
if status is None:
|
||||||
has_valid_target = any(
|
has_valid_target = any(
|
||||||
(not r.is_source) and r.price is not None for r in results
|
(not r.is_source) and r.price is not None for r in results
|
||||||
@@ -96,6 +181,11 @@ def _derive(payload: ComparisonRecordIn) -> dict:
|
|||||||
"saved_amount_cents": saved_amount_cents,
|
"saved_amount_cents": saved_amount_cents,
|
||||||
"is_source_best": is_source_best,
|
"is_source_best": is_source_best,
|
||||||
"status": status,
|
"status": status,
|
||||||
|
"fail_reason": (
|
||||||
|
_derive_fail_display(payload.information, _pr)
|
||||||
|
if status == "failed"
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -108,16 +198,35 @@ def upsert_record(
|
|||||||
灰度期老客户端 POST /compare/record 走这条,与后端 harvest 按 trace_id reconcile;
|
灰度期老客户端 POST /compare/record 走这条,与后端 harvest 按 trace_id reconcile;
|
||||||
新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)。
|
新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)。
|
||||||
"""
|
"""
|
||||||
derived = _derive(payload)
|
# 单源派生: 与 harvest_done 一致, payload 带 platforms 时从它派生(唯一真相源
|
||||||
|
# _derive_from_platforms), 老客户端不带 platforms 时回退 _derive(从 comparison_results)。
|
||||||
|
if payload.platforms:
|
||||||
|
derived = _derive_from_platforms(payload.platforms, payload.record_status)
|
||||||
|
# 对齐 _derive 返回键(#189 fail_reason): 两路径 fields 键集一致, 覆盖已有行时不残留旧值
|
||||||
|
derived["fail_reason"] = (
|
||||||
|
_derive_fail_display(payload.information, payload.platform_results or {})
|
||||||
|
if derived["status"] == "failed"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
# 单源派生取自 platforms 源行(常无源平台元数据/店名)→ 空则用 payload 兜底不丢字段。
|
||||||
|
# 下面 fields 不再显式写这四个键, 统一由 derived 提供(否则 dict(store_name=..., **derived)
|
||||||
|
# 与 _derive_from_platforms 同名键撞键 TypeError)。
|
||||||
|
for _k in ("store_name", "source_platform_id", "source_platform_name", "source_package"):
|
||||||
|
if not derived.get(_k):
|
||||||
|
derived[_k] = getattr(payload, _k)
|
||||||
|
else:
|
||||||
|
derived = _derive(payload)
|
||||||
|
# _derive 只从 comparison_results 派生, 不含源平台四件套 / store_name → 从 payload 补,
|
||||||
|
# 与上面 platforms 分支键集对齐(fields 统一靠 **derived 提供这些列)。
|
||||||
|
for _k in ("store_name", "source_platform_id", "source_platform_name", "source_package"):
|
||||||
|
derived[_k] = getattr(payload, _k)
|
||||||
items = [it.model_dump(exclude_none=True) for it in payload.items]
|
items = [it.model_dump(exclude_none=True) for it in payload.items]
|
||||||
fields = dict(
|
fields = dict(
|
||||||
device_id=payload.device_id,
|
device_id=payload.device_id,
|
||||||
business_type=payload.business_type,
|
business_type=payload.business_type,
|
||||||
store_name=payload.store_name,
|
|
||||||
product_names=_product_names_from_items(items),
|
product_names=_product_names_from_items(items),
|
||||||
source_platform_id=payload.source_platform_id,
|
# store_name / source_platform_id / source_platform_name / source_package 统一由
|
||||||
source_platform_name=payload.source_platform_name,
|
# derived 提供(见上方两分支补齐), 不在此显式写 —— 否则与 _derive_from_platforms 撞键。
|
||||||
source_package=payload.source_package,
|
|
||||||
information=payload.information,
|
information=payload.information,
|
||||||
best_deeplink=payload.best_deeplink,
|
best_deeplink=payload.best_deeplink,
|
||||||
trace_url=payload.trace_url,
|
trace_url=payload.trace_url,
|
||||||
@@ -125,6 +234,7 @@ def upsert_record(
|
|||||||
skipped_dish_count=payload.skipped_dish_count,
|
skipped_dish_count=payload.skipped_dish_count,
|
||||||
items=items,
|
items=items,
|
||||||
comparison_results=[r.model_dump() for r in payload.comparison_results],
|
comparison_results=[r.model_dump() for r in payload.comparison_results],
|
||||||
|
platforms=list(payload.platforms or []),
|
||||||
skipped_dish_names=list(payload.skipped_dish_names),
|
skipped_dish_names=list(payload.skipped_dish_names),
|
||||||
# 客户端环境 / 性能(debug,客户端上报;旧客户端为 None)
|
# 客户端环境 / 性能(debug,客户端上报;旧客户端为 None)
|
||||||
device_model=payload.device_model,
|
device_model=payload.device_model,
|
||||||
@@ -196,14 +306,30 @@ def upsert_record(
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
def _derive_from_results(results: list[dict]) -> dict:
|
def _derive_from_results(
|
||||||
|
results: list[dict], platform_results: dict | None = None,
|
||||||
|
record_status: str | None = None,
|
||||||
|
) -> dict:
|
||||||
"""从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。
|
"""从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。
|
||||||
等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象。"""
|
等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象。
|
||||||
|
|
||||||
|
platform_results(done.params.platform_results): 逐平台 skipped_dish_count 在这里(行里没有)。
|
||||||
|
传入则派生 best 时排除缺菜(漏菜)店 —— 少买了菜总价虚低, 不能当记录级"最低价"/算虚假省额;
|
||||||
|
源平台永远全菜, 故全目标缺菜时 best 回落到源(is_source_best、不虚报省)。不传→纯 rank/price, 行为不变。"""
|
||||||
|
_pr = platform_results or {}
|
||||||
|
|
||||||
|
def _is_short(r: dict) -> bool:
|
||||||
|
# platform_results 内层结构宽松(pricebot/客户端透传), 值非 dict 时按"不缺菜"处理, 不崩。
|
||||||
|
pid = r.get("platform_id")
|
||||||
|
info = _pr.get(pid) if pid else None
|
||||||
|
return isinstance(info, dict) and (info.get("skipped_dish_count") or 0) > 0
|
||||||
|
|
||||||
priced = [r for r in results if r.get("price") is not None]
|
priced = [r for r in results if r.get("price") is not None]
|
||||||
|
clean = [r for r in priced if not _is_short(r)] # 缺菜店排除出最优评选
|
||||||
best = None
|
best = None
|
||||||
if priced:
|
if clean:
|
||||||
best = min(
|
best = min(
|
||||||
priced,
|
clean,
|
||||||
key=lambda r: (r.get("rank") if r.get("rank") is not None else 10**9, r["price"]),
|
key=lambda r: (r.get("rank") if r.get("rank") is not None else 10**9, r["price"]),
|
||||||
)
|
)
|
||||||
src_row = next((r for r in results if r.get("is_source")), None)
|
src_row = next((r for r in results if r.get("is_source")), None)
|
||||||
@@ -230,7 +356,63 @@ def _derive_from_results(results: list[dict]) -> dict:
|
|||||||
"saved_amount_cents": saved_amount_cents,
|
"saved_amount_cents": saved_amount_cents,
|
||||||
"is_source_best": best.get("is_source") if best else None,
|
"is_source_best": best.get("is_source") if best else None,
|
||||||
"store_name": (src_row or {}).get("store_name") or None,
|
"store_name": (src_row or {}).get("store_name") or None,
|
||||||
"status": "success" if has_valid_target else "failed",
|
# 记录级结局: 优先用 pricebot 下发的 record_status(区分 below_minimum/store_closed,
|
||||||
|
# 不再把"未满起送"塌缩成 failed → 记录页不再误报"网络开小差"); 旧 pricebot 未下发时
|
||||||
|
# 回退老的 success/failed 二态派生, 向后兼容。
|
||||||
|
"status": record_status or ("success" if has_valid_target else "failed"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_from_platforms(
|
||||||
|
platforms: list, record_status: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""从 done 帧 platforms(每平台一行、渲染就绪)派生结构化列——**单一真相源**。
|
||||||
|
|
||||||
|
best_* 直接取 platforms 里 is_best 的那一行、source_* 取 role=source 行,与前端读的
|
||||||
|
platforms 天然一致(不再像 _derive_from_results 那样从 comparison_results 二次评最优,
|
||||||
|
消除"标量列 vs platforms"双源不一致)。platforms 非空时优先走这里;老 pricebot 无
|
||||||
|
platforms 时调用方回退 _derive_from_results(向后兼容)。"""
|
||||||
|
rows = [p for p in (platforms or []) if isinstance(p, dict)]
|
||||||
|
src = next((p for p in rows if p.get("role") == "source"), None)
|
||||||
|
best = next((p for p in rows if p.get("is_best")), None)
|
||||||
|
if best is None:
|
||||||
|
# pricebot 没标 is_best(如全平台 has_dish_diff「相似替换/仅供参考」→ 不认定权威最低价)
|
||||||
|
# 但仍有有价行 → 兜底取有价行里最低价当参考 best,避免记录级 best_*/saved 整条落 NULL
|
||||||
|
# (否则首页价 0.00 / 记录页无最低红框 / 省额丢失 /「累计发现可省」漏计)。**候选含源**:
|
||||||
|
# 源常年全菜、价可信,源本身最便宜时 best 回落源(saved=0、is_source_best=True),与 _derive
|
||||||
|
# 「全目标缺菜回落源、不虚报省」同一语义;若排除源强选更贵目标,saved 会变负、倒扣「累计
|
||||||
|
# 发现可省」(get_stats 对 saved_amount_cents 求和不带 >0 过滤)。
|
||||||
|
priced = [p for p in rows if p.get("price") is not None]
|
||||||
|
if priced:
|
||||||
|
best = min(priced, key=lambda p: (p["price"], p.get("display_order") or 0))
|
||||||
|
source_price_cents = _yuan_to_cents(src.get("price")) if src else None
|
||||||
|
best_price_cents = _yuan_to_cents(best.get("price")) if best else None
|
||||||
|
saved_amount_cents = None
|
||||||
|
if source_price_cents is not None and best_price_cents is not None:
|
||||||
|
saved_amount_cents = source_price_cents - best_price_cents
|
||||||
|
has_valid_target = any(
|
||||||
|
p.get("role") != "source" and p.get("price") is not None for p in rows
|
||||||
|
)
|
||||||
|
# store_name: 优先源行; recompare 场景源平台自己当目标、源行被目标覆盖(pricebot
|
||||||
|
# _build_platform_rows 有意去重, platforms 无 role=source 行)→ 回退 best 行 → 首个有店名
|
||||||
|
# 的行(显示现场实际比到的店), 免得记录页店名空掉兜底显示成"比价"。正常比价有源行不走回退。
|
||||||
|
store_name = (
|
||||||
|
(src or {}).get("store_name")
|
||||||
|
or (best or {}).get("store_name")
|
||||||
|
or next((p.get("store_name") for p in rows if p.get("store_name")), None)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"source_platform_id": (src or {}).get("platform_id"),
|
||||||
|
"source_platform_name": (src or {}).get("platform_name"),
|
||||||
|
"source_package": (src or {}).get("package"),
|
||||||
|
"source_price_cents": source_price_cents,
|
||||||
|
"best_platform_id": (best or {}).get("platform_id"),
|
||||||
|
"best_platform_name": (best or {}).get("platform_name"),
|
||||||
|
"best_price_cents": best_price_cents,
|
||||||
|
"saved_amount_cents": saved_amount_cents,
|
||||||
|
"is_source_best": (best.get("role") == "source") if best else None,
|
||||||
|
"store_name": store_name or None,
|
||||||
|
"status": record_status or ("success" if has_valid_target else "failed"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -262,6 +444,8 @@ def reserve_daily_start(
|
|||||||
business_type: str = "food",
|
business_type: str = "food",
|
||||||
device_id: str | None = None,
|
device_id: str | None = None,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
|
limit: int | None = DAILY_COMPARE_START_LIMIT,
|
||||||
|
reset_at: datetime | None = None,
|
||||||
) -> tuple[ComparisonRecord, int]:
|
) -> tuple[ComparisonRecord, int]:
|
||||||
"""Atomically reserve one of a user's 100 Beijing-day comparison starts.
|
"""Atomically reserve one of a user's 100 Beijing-day comparison starts.
|
||||||
|
|
||||||
@@ -287,6 +471,11 @@ def reserve_daily_start(
|
|||||||
if existing_at.tzinfo is not None:
|
if existing_at.tzinfo is not None:
|
||||||
existing_at = existing_at.astimezone(CN_TZ).replace(tzinfo=None)
|
existing_at = existing_at.astimezone(CN_TZ).replace(tzinfo=None)
|
||||||
day_start = existing_at.replace(hour=0, minute=0, second=0, microsecond=0)
|
day_start = existing_at.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
if reset_at is not None:
|
||||||
|
reset_start = reset_at
|
||||||
|
if reset_start.tzinfo is not None:
|
||||||
|
reset_start = reset_start.astimezone(CN_TZ).replace(tzinfo=None)
|
||||||
|
day_start = max(day_start, reset_start)
|
||||||
day_end = day_start + timedelta(days=1)
|
day_end = day_start + timedelta(days=1)
|
||||||
used = db.scalar(
|
used = db.scalar(
|
||||||
select(func.count(ComparisonRecord.id)).where(
|
select(func.count(ComparisonRecord.id)).where(
|
||||||
@@ -301,6 +490,11 @@ def reserve_daily_start(
|
|||||||
if current.tzinfo is not None:
|
if current.tzinfo is not None:
|
||||||
current = current.astimezone(CN_TZ).replace(tzinfo=None)
|
current = current.astimezone(CN_TZ).replace(tzinfo=None)
|
||||||
day_start = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
day_start = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
if reset_at is not None:
|
||||||
|
reset_start = reset_at
|
||||||
|
if reset_start.tzinfo is not None:
|
||||||
|
reset_start = reset_start.astimezone(CN_TZ).replace(tzinfo=None)
|
||||||
|
day_start = max(day_start, reset_start)
|
||||||
day_end = day_start + timedelta(days=1)
|
day_end = day_start + timedelta(days=1)
|
||||||
used = db.scalar(
|
used = db.scalar(
|
||||||
select(func.count(ComparisonRecord.id)).where(
|
select(func.count(ComparisonRecord.id)).where(
|
||||||
@@ -309,7 +503,7 @@ def reserve_daily_start(
|
|||||||
ComparisonRecord.created_at < day_end,
|
ComparisonRecord.created_at < day_end,
|
||||||
)
|
)
|
||||||
) or 0
|
) or 0
|
||||||
if used >= DAILY_COMPARE_START_LIMIT:
|
if limit is not None and used >= limit:
|
||||||
raise DailyCompareStartLimitExceeded
|
raise DailyCompareStartLimitExceeded
|
||||||
|
|
||||||
rec = ComparisonRecord(
|
rec = ComparisonRecord(
|
||||||
@@ -389,12 +583,40 @@ def harvest_done(
|
|||||||
返回 (记录, 是否本次**新**落成 success)——供调用方据此幂等发一次邀请奖。
|
返回 (记录, 是否本次**新**落成 success)——供调用方据此幂等发一次邀请奖。
|
||||||
行不存在(理论上帧0已建;防御)则新建。"""
|
行不存在(理论上帧0已建;防御)则新建。"""
|
||||||
results = done_params.get("comparison_results") or []
|
results = done_params.get("comparison_results") or []
|
||||||
derived = _derive_from_results(results)
|
# 展示模型统一数组(pricebot 新增, 每平台一行自带 status/is_best): 原样存, 记录页据此直渲染。
|
||||||
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
|
# record_status: 记录级结局(success/below_minimum/store_closed/failed), 覆盖老二态派生。
|
||||||
items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
|
platforms = done_params.get("platforms") or []
|
||||||
|
record_status = done_params.get("record_status")
|
||||||
|
# 单源派生: platforms(含 pricebot 权威 is_best)是唯一真相源, best_*/source_*/saved/status
|
||||||
|
# 全从它取 → 与前端读的 platforms 天然一致; 菜品也取 platforms 源行。老 pricebot 无
|
||||||
|
# platforms 时回退从 comparison_results 派生(向后兼容)。
|
||||||
|
if platforms:
|
||||||
|
derived = _derive_from_platforms(platforms, record_status)
|
||||||
|
# 菜品优先源行; recompare 无源行 → 回退 best 行 → 首个有菜品的行(同 store_name 回退)
|
||||||
|
_item_row = (
|
||||||
|
next((p for p in platforms if isinstance(p, dict) and p.get("role") == "source"), None)
|
||||||
|
or next((p for p in platforms if isinstance(p, dict) and p.get("is_best")), None)
|
||||||
|
or next((p for p in platforms if isinstance(p, dict) and p.get("items")), None)
|
||||||
|
)
|
||||||
|
items = (_item_row or {}).get("items") or []
|
||||||
|
else:
|
||||||
|
derived = _derive_from_results(
|
||||||
|
results, done_params.get("platform_results"), record_status
|
||||||
|
)
|
||||||
|
# pricebot 已把源单菜品塞进 comparison_results[源行].items
|
||||||
|
items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
|
||||||
|
# 失败展示原因(#189): platforms / results 两个派生分支的 status 都可能 failed, 统一在此算
|
||||||
|
fail_reason = (
|
||||||
|
_derive_fail_display(
|
||||||
|
done_params.get("information"), done_params.get("platform_results")
|
||||||
|
)
|
||||||
|
if derived["status"] == "failed"
|
||||||
|
else None
|
||||||
|
)
|
||||||
fields = dict(
|
fields = dict(
|
||||||
business_type=business_type or "food",
|
business_type=business_type or "food",
|
||||||
information=done_params.get("information") or None,
|
information=done_params.get("information") or None,
|
||||||
|
fail_reason=fail_reason,
|
||||||
# best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补;
|
# best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补;
|
||||||
# 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。
|
# 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。
|
||||||
trace_url=trace_url or done_params.get("trace_url"),
|
trace_url=trace_url or done_params.get("trace_url"),
|
||||||
@@ -402,6 +624,7 @@ def harvest_done(
|
|||||||
skipped_dish_count=done_params.get("skipped_dish_count"),
|
skipped_dish_count=done_params.get("skipped_dish_count"),
|
||||||
skipped_dish_names=list(done_params.get("skipped_dish_names") or []),
|
skipped_dish_names=list(done_params.get("skipped_dish_names") or []),
|
||||||
comparison_results=results,
|
comparison_results=results,
|
||||||
|
platforms=platforms,
|
||||||
items=items,
|
items=items,
|
||||||
product_names=_product_names_from_items(items),
|
product_names=_product_names_from_items(items),
|
||||||
raw_payload=done_params,
|
raw_payload=done_params,
|
||||||
|
|||||||
+117
-14
@@ -1,9 +1,9 @@
|
|||||||
"""device 表读写(设备注册 / 心跳 / 超时扫描)。"""
|
"""device 表读写(设备注册 / 心跳 / 超时扫描)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.device import DeviceLiveness
|
from app.models.device import DeviceLiveness
|
||||||
@@ -24,13 +24,29 @@ def register_or_update(
|
|||||||
registration_id: str | None = None,
|
registration_id: str | None = None,
|
||||||
push_vendor: str | None = None,
|
push_vendor: str | None = None,
|
||||||
push_token: str | None = None,
|
push_token: str | None = None,
|
||||||
|
push_binding_id: str | None = None,
|
||||||
platform: str = "android",
|
platform: str = "android",
|
||||||
app_version: str | None = None,
|
app_version: str | None = None,
|
||||||
) -> DeviceLiveness:
|
) -> DeviceLiveness:
|
||||||
"""注册设备或更新其厂商 push token / 元信息。upsert by (user_id, device_id)。"""
|
"""注册设备或更新推送目标;同一设备/token 的旧用户绑定在同一事务内失效。"""
|
||||||
normalized_vendor = _normalize_push_vendor(push_vendor)
|
normalized_vendor = _normalize_push_vendor(push_vendor)
|
||||||
normalized_token = push_token.strip() if push_token else None
|
normalized_token = push_token.strip() if push_token else None
|
||||||
|
normalized_binding = push_binding_id.strip() if push_binding_id else None
|
||||||
device = _get(db, user_id=user_id, device_id=device_id)
|
device = _get(db, user_id=user_id, device_id=device_id)
|
||||||
|
stale_binding = bool(
|
||||||
|
device
|
||||||
|
and normalized_binding
|
||||||
|
and device.push_binding_id == normalized_binding
|
||||||
|
and device.push_binding_revoked
|
||||||
|
)
|
||||||
|
if not stale_binding:
|
||||||
|
_release_conflicting_push_bindings(
|
||||||
|
db,
|
||||||
|
user_id=user_id,
|
||||||
|
device_id=device_id,
|
||||||
|
push_vendor=normalized_vendor,
|
||||||
|
push_token=normalized_token,
|
||||||
|
)
|
||||||
if device is None:
|
if device is None:
|
||||||
device = DeviceLiveness(
|
device = DeviceLiveness(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -38,17 +54,22 @@ def register_or_update(
|
|||||||
registration_id=registration_id,
|
registration_id=registration_id,
|
||||||
push_vendor=normalized_vendor,
|
push_vendor=normalized_vendor,
|
||||||
push_token=normalized_token,
|
push_token=normalized_token,
|
||||||
|
push_binding_id=normalized_binding,
|
||||||
|
push_binding_revoked=False,
|
||||||
platform=platform or "android",
|
platform=platform or "android",
|
||||||
app_version=app_version,
|
app_version=app_version,
|
||||||
)
|
)
|
||||||
db.add(device)
|
db.add(device)
|
||||||
else:
|
else:
|
||||||
if registration_id:
|
if registration_id and not stale_binding:
|
||||||
device.registration_id = registration_id
|
device.registration_id = registration_id
|
||||||
if normalized_vendor:
|
if normalized_vendor and not stale_binding:
|
||||||
device.push_vendor = normalized_vendor
|
device.push_vendor = normalized_vendor
|
||||||
if normalized_token:
|
if normalized_token and not stale_binding:
|
||||||
device.push_token = normalized_token
|
device.push_token = normalized_token
|
||||||
|
if normalized_binding and not stale_binding:
|
||||||
|
device.push_binding_id = normalized_binding
|
||||||
|
device.push_binding_revoked = False
|
||||||
if platform:
|
if platform:
|
||||||
device.platform = platform
|
device.platform = platform
|
||||||
if app_version:
|
if app_version:
|
||||||
@@ -67,26 +88,45 @@ def touch_heartbeat(
|
|||||||
registration_id: str | None = None,
|
registration_id: str | None = None,
|
||||||
push_vendor: str | None = None,
|
push_vendor: str | None = None,
|
||||||
push_token: str | None = None,
|
push_token: str | None = None,
|
||||||
|
push_binding_id: str | None = None,
|
||||||
) -> DeviceLiveness:
|
) -> DeviceLiveness:
|
||||||
"""处理一次心跳(心跳也能自注册)。
|
"""处理一次心跳(心跳也能自注册)。
|
||||||
|
|
||||||
service 心跳或 accessibility_enabled=true 时,刷新存活并把状态机重置回 alive、
|
service 心跳或 accessibility_enabled=true 时,刷新存活并把状态机重置回 alive、
|
||||||
清掉 notified_at(掉线恢复 → 下次再断才会再推一条)。
|
清掉 notified_at(掉线恢复 → 下次再断才会再推一条)。
|
||||||
"""
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(UTC)
|
||||||
device = _get(db, user_id=user_id, device_id=device_id)
|
device = _get(db, user_id=user_id, device_id=device_id)
|
||||||
|
normalized_vendor = _normalize_push_vendor(push_vendor)
|
||||||
|
normalized_token = push_token.strip() if push_token else None
|
||||||
|
normalized_binding = push_binding_id.strip() if push_binding_id else None
|
||||||
|
stale_binding = bool(
|
||||||
|
device
|
||||||
|
and normalized_binding
|
||||||
|
and device.push_binding_id == normalized_binding
|
||||||
|
and device.push_binding_revoked
|
||||||
|
)
|
||||||
|
if (normalized_token or normalized_binding) and not stale_binding:
|
||||||
|
_release_conflicting_push_bindings(
|
||||||
|
db,
|
||||||
|
user_id=user_id,
|
||||||
|
device_id=device_id,
|
||||||
|
push_vendor=normalized_vendor,
|
||||||
|
push_token=normalized_token,
|
||||||
|
)
|
||||||
if device is None:
|
if device is None:
|
||||||
device = DeviceLiveness(user_id=user_id, device_id=device_id)
|
device = DeviceLiveness(user_id=user_id, device_id=device_id)
|
||||||
db.add(device)
|
db.add(device)
|
||||||
|
|
||||||
if registration_id:
|
if registration_id and not stale_binding:
|
||||||
device.registration_id = registration_id
|
device.registration_id = registration_id
|
||||||
normalized_vendor = _normalize_push_vendor(push_vendor)
|
if normalized_vendor and not stale_binding:
|
||||||
normalized_token = push_token.strip() if push_token else None
|
|
||||||
if normalized_vendor:
|
|
||||||
device.push_vendor = normalized_vendor
|
device.push_vendor = normalized_vendor
|
||||||
if normalized_token:
|
if normalized_token and not stale_binding:
|
||||||
device.push_token = normalized_token
|
device.push_token = normalized_token
|
||||||
|
if normalized_binding and not stale_binding:
|
||||||
|
device.push_binding_id = normalized_binding
|
||||||
|
device.push_binding_revoked = False
|
||||||
device.last_report_protection_on = accessibility_enabled
|
device.last_report_protection_on = accessibility_enabled
|
||||||
|
|
||||||
if accessibility_enabled:
|
if accessibility_enabled:
|
||||||
@@ -107,7 +147,7 @@ def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
|
|||||||
|
|
||||||
即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒。
|
即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒。
|
||||||
"""
|
"""
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
cutoff = datetime.now(UTC) - timedelta(minutes=timeout_minutes)
|
||||||
stmt = select(DeviceLiveness).where(
|
stmt = select(DeviceLiveness).where(
|
||||||
DeviceLiveness.ever_protected.is_(True),
|
DeviceLiveness.ever_protected.is_(True),
|
||||||
DeviceLiveness.liveness_state == "alive",
|
DeviceLiveness.liveness_state == "alive",
|
||||||
@@ -126,7 +166,7 @@ def mark_notified(db: Session, *, device_id_pk: int) -> None:
|
|||||||
device = db.get(DeviceLiveness, device_id_pk)
|
device = db.get(DeviceLiveness, device_id_pk)
|
||||||
if device is not None:
|
if device is not None:
|
||||||
device.liveness_state = "notified"
|
device.liveness_state = "notified"
|
||||||
device.notified_at = datetime.now(timezone.utc)
|
device.notified_at = datetime.now(UTC)
|
||||||
device.kill_alert_pending = True
|
device.kill_alert_pending = True
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -177,6 +217,69 @@ def list_push_targets(db: Session, *, user_id: int) -> list[DeviceLiveness]:
|
|||||||
return targets
|
return targets
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_push_binding(db: Session, *, user_id: int, device_id: str) -> None:
|
||||||
|
"""当前用户主动退出:清推送归属但保留设备存活历史。"""
|
||||||
|
device = _get(db, user_id=user_id, device_id=device_id)
|
||||||
|
if device is not None:
|
||||||
|
_clear_push_binding(device)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_push_binding_deferred(
|
||||||
|
db: Session, *, device_id: str, push_binding_id: str
|
||||||
|
) -> None:
|
||||||
|
"""离线退出补偿;只撤销完全匹配的旧登录会话,且始终幂等。"""
|
||||||
|
stmt = select(DeviceLiveness).where(
|
||||||
|
DeviceLiveness.device_id == device_id,
|
||||||
|
DeviceLiveness.push_binding_id == push_binding_id,
|
||||||
|
)
|
||||||
|
device = db.execute(stmt).scalar_one_or_none()
|
||||||
|
if device is not None:
|
||||||
|
_clear_push_binding(device)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _release_conflicting_push_bindings(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
device_id: str,
|
||||||
|
push_vendor: str | None,
|
||||||
|
push_token: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""把设备或 token 从其他记录转移走,当前 (user,device) 行除外。"""
|
||||||
|
conflicts = [DeviceLiveness.device_id == device_id]
|
||||||
|
if push_vendor and push_token:
|
||||||
|
conflicts.append(
|
||||||
|
and_(
|
||||||
|
DeviceLiveness.push_vendor == push_vendor,
|
||||||
|
DeviceLiveness.push_token == push_token,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stmt = select(DeviceLiveness).where(
|
||||||
|
or_(*conflicts),
|
||||||
|
~and_(
|
||||||
|
DeviceLiveness.user_id == user_id,
|
||||||
|
DeviceLiveness.device_id == device_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
released = False
|
||||||
|
for old in db.execute(stmt).scalars():
|
||||||
|
_clear_push_binding(old)
|
||||||
|
released = True
|
||||||
|
if released:
|
||||||
|
# 唯一约束下必须先落旧行清理,再把同一 token 写给新行。
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_push_binding(device: DeviceLiveness) -> None:
|
||||||
|
device.registration_id = None
|
||||||
|
device.push_vendor = None
|
||||||
|
device.push_token = None
|
||||||
|
# binding_id 留作撤销墓碑;同一旧会话的在途请求不能重新认领 token。
|
||||||
|
device.push_binding_revoked = bool(device.push_binding_id)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_push_vendor(push_vendor: str | None) -> str | None:
|
def _normalize_push_vendor(push_vendor: str | None) -> str | None:
|
||||||
if not push_vendor:
|
if not push_vendor:
|
||||||
return None
|
return None
|
||||||
|
|||||||
+463
-184
@@ -1,95 +1,136 @@
|
|||||||
"""新手引导视频:运营配置读写 + 播放计次 + 发币。
|
"""双场景引导视频配置与 prepare/start/reward 状态机。"""
|
||||||
|
|
||||||
**配置**(开关 / 视频地址 / 前几次 / 每次金币)整体作为一个 JSON 存进通用 app_config 表
|
|
||||||
(key=coupon_guide_video),写法完全对齐 feedback_qr —— 不进 CONFIG_DEFS,所以不会污染
|
|
||||||
系统配置页的通用列表,由本模块独占维护。
|
|
||||||
|
|
||||||
**计次**按账号(user_id)、**开播即计数**:客户端每次要展示领券等候浮层时调
|
|
||||||
`/api/v1/guide-video/start`,命中则当场写一行 guide_video_play(status='playing')。
|
|
||||||
已用次数 = 该账号的行数,达到 max_plays 后不再下发,客户端改放广告(原逻辑)。
|
|
||||||
COUNT 判定本身无锁,真正卡住次数上限的是 (user_id, seq) 唯一键:并发 /start 只能成一个。
|
|
||||||
|
|
||||||
**发币**幂等键是 play_token,落地方式是 `status='playing' → 'granted'` 的**条件更新**:
|
|
||||||
同一次播放重复上报只入账一次(网络重试 / 关闭与播完同时触发都靠它挡住)。
|
|
||||||
中途关闭也照发 —— 产品拍板「中途关闭也算看完」。
|
|
||||||
|
|
||||||
两处都是直接铸币的路径,改动前先看 `start_play` / `grant_play` 上的并发注释。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core import rewards
|
from app.core import limit_policy, media, rewards
|
||||||
|
from app.core.config import settings
|
||||||
from app.models.app_config import AppConfig
|
from app.models.app_config import AppConfig
|
||||||
from app.models.guide_video import GuideVideoPlay
|
from app.models.guide_video import GuideVideoPlay
|
||||||
|
from app.models.wallet import CoinAccount
|
||||||
from app.repositories import wallet as crud_wallet
|
from app.repositories import wallet as crud_wallet
|
||||||
|
|
||||||
_KEY = "coupon_guide_video"
|
SCENES = ("coupon", "comparison")
|
||||||
|
_KEY_BY_SCENE = {
|
||||||
#: 金币流水 biz_type。客户端收益明细按它显示「新手引导视频奖励」。
|
"coupon": "coupon_guide_video",
|
||||||
BIZ_TYPE = "guide_video"
|
"comparison": "comparison_guide_video",
|
||||||
|
|
||||||
# 默认值 = 「运营还没配」时的行为:video_url 为空 → 一律不下发引导视频,浮层维持现状(放广告)。
|
|
||||||
# 所以本功能上线后**不配视频就等于没上线**,不会影响存量用户。
|
|
||||||
_DEFAULTS: dict[str, Any] = {
|
|
||||||
"enabled": True,
|
|
||||||
"video_url": None, # None/空 = 未配片 → 不下发,浮层照旧放广告
|
|
||||||
"max_plays": 3, # 每个账号前 N 次浮层放引导视频
|
|
||||||
"reward_coin": 120, # 每次固定金币
|
|
||||||
}
|
}
|
||||||
|
BIZ_TYPE = "guide_video"
|
||||||
_FIELDS = tuple(_DEFAULTS.keys())
|
CIRCLE_COUNT = 10
|
||||||
|
PLAN_TTL = timedelta(minutes=10)
|
||||||
# 后台可配范围的护栏:防手滑把次数/金币填成天文数字(配置直接决定发币)。
|
# 0 表示全局暂停播放;白名单页与旧引导视频配置页必须接受同一口径,
|
||||||
|
# 否则在白名单页设为 0 后,旧页面连金币/开关等无关字段也无法保存。
|
||||||
|
MIN_PLAYS = 0
|
||||||
MAX_PLAYS_LIMIT = 50
|
MAX_PLAYS_LIMIT = 50
|
||||||
|
MIN_REWARD_COIN = 10
|
||||||
REWARD_COIN_LIMIT = 10_000
|
REWARD_COIN_LIMIT = 10_000
|
||||||
|
|
||||||
|
_DEFAULTS: dict[str, Any] = {
|
||||||
|
"enabled": False,
|
||||||
|
"video_url": None,
|
||||||
|
"max_plays": 3,
|
||||||
|
"reward_coin": 100,
|
||||||
|
"duration_ms": None,
|
||||||
|
"video_codec": None,
|
||||||
|
"audio_codec": None,
|
||||||
|
"analysis_status": "missing",
|
||||||
|
"analysis_error": None,
|
||||||
|
"config_version": 0,
|
||||||
|
}
|
||||||
|
_FIELDS = tuple(_DEFAULTS)
|
||||||
|
|
||||||
# ===== 配置 =====
|
|
||||||
|
class PlayStateError(Exception):
|
||||||
|
def __init__(self, code: str, message: str, *, reprepare_required: bool = False):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.reprepare_required = reprepare_required
|
||||||
|
|
||||||
|
def detail(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": self.code,
|
||||||
|
"message": self.message,
|
||||||
|
"retryable": False,
|
||||||
|
"reprepare_required": self.reprepare_required,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(rewards.CN_TZ).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _config_key(scene: str) -> str:
|
||||||
|
if scene not in _KEY_BY_SCENE:
|
||||||
|
raise ValueError(f"unsupported guide video scene: {scene}")
|
||||||
|
return _KEY_BY_SCENE[scene]
|
||||||
|
|
||||||
|
|
||||||
def _merge(raw: Any) -> dict[str, Any]:
|
def _merge(raw: Any) -> dict[str, Any]:
|
||||||
"""DB 里(可能不全的)dict 叠加到默认上,得到完整配置(4 个字段,无 updated_at)。"""
|
|
||||||
out = dict(_DEFAULTS)
|
out = dict(_DEFAULTS)
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
for k in _FIELDS:
|
for key in _FIELDS:
|
||||||
v = raw.get(k)
|
if key in raw and raw[key] is not None:
|
||||||
if v is not None:
|
out[key] = raw[key]
|
||||||
out[k] = v
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def get_config(db: Session) -> dict[str, Any]:
|
def _public_config(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""完整配置 + updated_at(admin 读 / 业务读共用)。"""
|
duration = int(cfg.get("duration_ms") or 0)
|
||||||
row = db.get(AppConfig, _KEY)
|
reward = int(cfg.get("reward_coin") or 0)
|
||||||
cfg = _merge(row.value if row is not None else None)
|
return {
|
||||||
|
**cfg,
|
||||||
|
"duration_ms": duration or None,
|
||||||
|
"circle_count": CIRCLE_COUNT,
|
||||||
|
"circle_duration_ms": duration / CIRCLE_COUNT if duration else None,
|
||||||
|
"reward_per_circle": reward // CIRCLE_COUNT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_config(db: Session, scene: str = "coupon") -> dict[str, Any]:
|
||||||
|
row = db.get(AppConfig, _config_key(scene))
|
||||||
|
cfg = _public_config(_merge(row.value if row is not None else None))
|
||||||
|
if scene == "coupon":
|
||||||
|
cfg["max_plays"] = limit_policy.resolve(
|
||||||
|
db, "guide.video.lifetime"
|
||||||
|
).global_limit
|
||||||
cfg["updated_at"] = row.updated_at.isoformat() if row is not None and row.updated_at else None
|
cfg["updated_at"] = row.updated_at.isoformat() if row is not None and row.updated_at else None
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def _write(db: Session, value: dict[str, Any], *, admin_id: int, commit: bool) -> dict[str, Any]:
|
def _validate_settings(max_plays: int, reward_coin: int) -> None:
|
||||||
"""整体覆写该行(value 须为完整字段 dict),返回合并后的完整配置(含 updated_at)。"""
|
if not MIN_PLAYS <= max_plays <= MAX_PLAYS_LIMIT:
|
||||||
row = db.get(AppConfig, _KEY)
|
raise ValueError(f"播放次数必须在 {MIN_PLAYS}~{MAX_PLAYS_LIMIT} 之间")
|
||||||
|
if not MIN_REWARD_COIN <= reward_coin <= REWARD_COIN_LIMIT:
|
||||||
|
raise ValueError(f"金币总价必须在 {MIN_REWARD_COIN}~{REWARD_COIN_LIMIT} 之间")
|
||||||
|
if reward_coin % CIRCLE_COUNT:
|
||||||
|
raise ValueError("金币总价必须是 10 的倍数")
|
||||||
|
|
||||||
|
|
||||||
|
def _write(
|
||||||
|
db: Session, value: dict[str, Any], *, scene: str, admin_id: int, commit: bool
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
key = _config_key(scene)
|
||||||
|
row = db.get(AppConfig, key)
|
||||||
if row is None:
|
if row is None:
|
||||||
row = AppConfig(key=_KEY, value=value, updated_by_admin_id=admin_id)
|
row = AppConfig(key=key, value=value, updated_by_admin_id=admin_id)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
else:
|
else:
|
||||||
row.value = value # 整体重新赋值,SQLAlchemy 才侦测得到变更
|
row.value = value
|
||||||
row.updated_by_admin_id = admin_id
|
row.updated_by_admin_id = admin_id
|
||||||
if commit:
|
if commit:
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
else:
|
else:
|
||||||
db.flush()
|
db.flush()
|
||||||
out = _merge(row.value)
|
return get_config(db, scene)
|
||||||
out["updated_at"] = row.updated_at.isoformat() if row.updated_at else None
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def update_config(
|
def update_config(
|
||||||
@@ -98,193 +139,431 @@ def update_config(
|
|||||||
enabled: bool | None = None,
|
enabled: bool | None = None,
|
||||||
max_plays: int | None = None,
|
max_plays: int | None = None,
|
||||||
reward_coin: int | None = None,
|
reward_coin: int | None = None,
|
||||||
|
scene: str = "coupon",
|
||||||
admin_id: int,
|
admin_id: int,
|
||||||
commit: bool = True,
|
commit: bool = True,
|
||||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
"""改开关 / 次数 / 金币(只改传了的字段;视频走 set_video)。返回 (before, after) 供审计。"""
|
before = get_config(db, scene)
|
||||||
row = db.get(AppConfig, _KEY)
|
raw = {key: before[key] for key in _FIELDS}
|
||||||
before = _merge(row.value if row is not None else None)
|
candidate_plays = int(max_plays if max_plays is not None else raw["max_plays"])
|
||||||
new_value = {k: before[k] for k in _FIELDS}
|
candidate_reward = int(reward_coin if reward_coin is not None else raw["reward_coin"])
|
||||||
|
_validate_settings(candidate_plays, candidate_reward)
|
||||||
|
if enabled is True and (
|
||||||
|
not raw.get("video_url")
|
||||||
|
or raw.get("analysis_status") != "valid"
|
||||||
|
or not raw.get("duration_ms")
|
||||||
|
):
|
||||||
|
raise ValueError("请先上传并通过分析的视频,再启用")
|
||||||
if enabled is not None:
|
if enabled is not None:
|
||||||
new_value["enabled"] = enabled
|
raw["enabled"] = enabled
|
||||||
if max_plays is not None:
|
raw["max_plays"] = candidate_plays
|
||||||
new_value["max_plays"] = max(0, min(int(max_plays), MAX_PLAYS_LIMIT))
|
raw["reward_coin"] = candidate_reward
|
||||||
if reward_coin is not None:
|
raw["config_version"] = int(raw.get("config_version") or 0) + 1
|
||||||
new_value["reward_coin"] = max(0, min(int(reward_coin), REWARD_COIN_LIMIT))
|
after = _write(
|
||||||
after = _write(db, new_value, admin_id=admin_id, commit=commit)
|
db,
|
||||||
|
raw,
|
||||||
|
scene=scene,
|
||||||
|
admin_id=admin_id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
if scene == "coupon" and max_plays is not None:
|
||||||
|
# 兼容仍调用旧专用接口的客户端/脚本,并把统一策略全局值一并更新。
|
||||||
|
limit_policy.set_global_limits(
|
||||||
|
db,
|
||||||
|
{"guide.video.lifetime": candidate_plays},
|
||||||
|
admin_id=admin_id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
# set_global_limits 已 flush;即使由 admin 路由统一在外层提交,也要把
|
||||||
|
# 同一事务内的最新统一策略值写进审计 after 快照。
|
||||||
|
after = get_config(db, scene)
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
after = get_config(db, scene)
|
||||||
return before, after
|
return before, after
|
||||||
|
|
||||||
|
|
||||||
def set_video(
|
def set_video(
|
||||||
db: Session, video_url: str | None, *, admin_id: int, commit: bool = True
|
db: Session,
|
||||||
|
video_url: str | None,
|
||||||
|
*,
|
||||||
|
analysis: dict[str, Any] | None = None,
|
||||||
|
scene: str = "coupon",
|
||||||
|
admin_id: int,
|
||||||
|
commit: bool = True,
|
||||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
"""设置/清空引导视频地址。返回 (before, after);before['video_url'] 供调用方删旧文件。"""
|
before = get_config(db, scene)
|
||||||
row = db.get(AppConfig, _KEY)
|
raw = {key: before[key] for key in _FIELDS}
|
||||||
before = _merge(row.value if row is not None else None)
|
raw["video_url"] = video_url
|
||||||
new_value = {k: before[k] for k in _FIELDS}
|
if video_url:
|
||||||
new_value["video_url"] = video_url
|
if not analysis or analysis.get("analysis_status") != "valid":
|
||||||
after = _write(db, new_value, admin_id=admin_id, commit=commit)
|
raise ValueError("视频必须先完成服务端分析")
|
||||||
|
for key in (
|
||||||
|
"duration_ms", "video_codec", "audio_codec",
|
||||||
|
"analysis_status", "analysis_error",
|
||||||
|
):
|
||||||
|
raw[key] = analysis.get(key)
|
||||||
|
else:
|
||||||
|
raw.update(
|
||||||
|
duration_ms=None,
|
||||||
|
video_codec=None,
|
||||||
|
audio_codec=None,
|
||||||
|
analysis_status="missing",
|
||||||
|
analysis_error=None,
|
||||||
|
)
|
||||||
|
raw["enabled"] = False
|
||||||
|
raw["config_version"] = int(raw.get("config_version") or 0) + 1
|
||||||
|
after = _write(db, raw, scene=scene, admin_id=admin_id, commit=commit)
|
||||||
return before, after
|
return before, after
|
||||||
|
|
||||||
|
|
||||||
# ===== 播放计次 =====
|
def used_plays(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
def used_plays(db: Session, user_id: int) -> int:
|
scene: str = "coupon",
|
||||||
"""该账号已用掉的引导视频次数(开播即算,含未发币的)。"""
|
*,
|
||||||
return int(
|
reset_at: datetime | None = None,
|
||||||
db.execute(
|
) -> int:
|
||||||
select(func.count()).select_from(GuideVideoPlay).where(
|
stmt = select(func.count()).select_from(GuideVideoPlay).where(
|
||||||
GuideVideoPlay.user_id == user_id
|
GuideVideoPlay.user_id == user_id,
|
||||||
)
|
GuideVideoPlay.scene == scene,
|
||||||
).scalar_one()
|
GuideVideoPlay.status != "prepared",
|
||||||
)
|
)
|
||||||
|
if reset_at is not None:
|
||||||
|
# guide_video_play 使用北京时间 naive 墙钟;白名单重置点是带时区时间。
|
||||||
|
reset_value = reset_at.astimezone(rewards.CN_TZ).replace(tzinfo=None)
|
||||||
|
stmt = stmt.where(GuideVideoPlay.started_at >= reset_value)
|
||||||
|
return int(db.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def play_stats(db: Session) -> dict[str, int]:
|
def _effective_quota(
|
||||||
"""全站播放统计(admin 页展示):总播放次数 / 其中已发币次数。"""
|
db: Session,
|
||||||
total = int(
|
user_id: int,
|
||||||
db.execute(select(func.count()).select_from(GuideVideoPlay)).scalar_one()
|
scene: str,
|
||||||
|
cfg: dict[str, Any],
|
||||||
|
) -> tuple[int | None, datetime | None]:
|
||||||
|
if scene != "coupon":
|
||||||
|
return int(cfg.get("max_plays") or 0), None
|
||||||
|
policy = limit_policy.resolve_for_user(
|
||||||
|
db, "guide.video.lifetime", user_id
|
||||||
)
|
)
|
||||||
granted = int(
|
return policy.limit, policy.reset_at
|
||||||
db.execute(
|
|
||||||
select(func.count()).select_from(GuideVideoPlay).where(
|
|
||||||
GuideVideoPlay.status == "granted"
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
)
|
|
||||||
return {"total_plays": total, "granted_plays": granted}
|
|
||||||
|
|
||||||
|
|
||||||
def start_play(
|
def _remaining(maximum: int | None, used: int) -> int:
|
||||||
db: Session, user_id: int, *, scene: str = "coupon", commit: bool = True
|
# API 字段保持整数兼容;不限时使用足够大的展示值,不参与服务端判定。
|
||||||
|
exposed_maximum = maximum if maximum is not None else 1_000_000
|
||||||
|
return max(0, exposed_maximum - used)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_miss(
|
||||||
|
scene: str,
|
||||||
|
reason: str,
|
||||||
|
cfg: dict[str, Any],
|
||||||
|
used: int,
|
||||||
|
maximum: int | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""决定这次浮层是否放引导视频;命中则**当场计次**并返回 play_token。
|
return {
|
||||||
|
"should_play": False,
|
||||||
|
"reason": reason,
|
||||||
|
"scene": scene,
|
||||||
|
"video_url": None,
|
||||||
|
"play_token": "",
|
||||||
|
"config_version": int(cfg.get("config_version") or 0),
|
||||||
|
"duration_ms": 0,
|
||||||
|
"circle_count": CIRCLE_COUNT,
|
||||||
|
"circle_duration_ms": 0,
|
||||||
|
"reward_coin": int(cfg.get("reward_coin") or 0),
|
||||||
|
"reward_per_circle": int(cfg.get("reward_coin") or 0) // CIRCLE_COUNT,
|
||||||
|
"seq": used,
|
||||||
|
"remaining": _remaining(maximum, used),
|
||||||
|
"expires_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
返回 dict:
|
|
||||||
should_play 是否放引导视频(False → 客户端照旧放广告)
|
|
||||||
video_url 相对地址(/media/...);客户端自行拼 BASE_URL
|
|
||||||
play_token 发币幂等键(should_play=False 时为空串)
|
|
||||||
reward_coin 播完/中途关闭都发的固定金币
|
|
||||||
seq / remaining 第几次 / 发完这次还剩几次(仅展示与排查用)
|
|
||||||
"""
|
|
||||||
cfg = get_config(db)
|
|
||||||
video_url = (cfg.get("video_url") or "").strip()
|
|
||||||
max_plays = int(cfg.get("max_plays") or 0)
|
|
||||||
reward_coin = int(cfg.get("reward_coin") or 0)
|
|
||||||
used = used_plays(db, user_id)
|
|
||||||
|
|
||||||
def _miss(used_now: int) -> dict[str, Any]:
|
def prepare_play(db: Session, user_id: int, *, scene: str = "coupon") -> dict[str, Any]:
|
||||||
return {
|
cfg = get_config(db, scene)
|
||||||
"should_play": False,
|
maximum, reset_at = _effective_quota(db, user_id, scene, cfg)
|
||||||
"video_url": None,
|
used = used_plays(db, user_id, scene, reset_at=reset_at)
|
||||||
"play_token": "",
|
video_url = str(cfg.get("video_url") or "").strip()
|
||||||
"reward_coin": reward_coin,
|
duration = int(cfg.get("duration_ms") or 0)
|
||||||
"seq": used_now,
|
if not cfg.get("enabled"):
|
||||||
"remaining": max(0, max_plays - used_now),
|
return _prepare_miss(scene, "disabled", cfg, used, maximum)
|
||||||
}
|
if not video_url or cfg.get("analysis_status") != "valid" or duration <= 0:
|
||||||
|
return _prepare_miss(
|
||||||
|
scene, "video_unavailable", cfg, used, maximum
|
||||||
|
)
|
||||||
|
if maximum is not None and used >= maximum:
|
||||||
|
return _prepare_miss(
|
||||||
|
scene, "play_limit_reached", cfg, used, maximum
|
||||||
|
)
|
||||||
|
|
||||||
if not cfg.get("enabled") or not video_url or max_plays <= 0 or used >= max_plays:
|
now = _now()
|
||||||
return _miss(used)
|
|
||||||
|
|
||||||
seq = used + 1
|
|
||||||
play = GuideVideoPlay(
|
play = GuideVideoPlay(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
play_token=uuid.uuid4().hex,
|
play_token=uuid.uuid4().hex,
|
||||||
scene=scene,
|
scene=scene,
|
||||||
seq=seq,
|
seq=None,
|
||||||
video_url=video_url,
|
video_url=video_url,
|
||||||
coin=0,
|
coin=int(cfg["reward_coin"]),
|
||||||
status="playing",
|
duration_ms=duration,
|
||||||
|
config_version=int(cfg["config_version"]),
|
||||||
|
settled_circles=0,
|
||||||
|
status="prepared",
|
||||||
completed=0,
|
completed=0,
|
||||||
started_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
prepared_at=now,
|
||||||
|
expires_at=now + PLAN_TTL,
|
||||||
|
started_at=None,
|
||||||
)
|
)
|
||||||
db.add(play)
|
db.add(play)
|
||||||
# 上面的 COUNT 判定是无锁 check-then-insert:并发 /start 会都算出同一个 seq。
|
db.commit()
|
||||||
# (user_id, seq) 唯一键让只有一个能落库,其余撞键 → 回滚后按"这次不放视频"降级,
|
|
||||||
# 客户端照旧走广告链路。没有它,并发就能绕过 max_plays 无限刷金币。
|
|
||||||
try:
|
|
||||||
if commit:
|
|
||||||
db.commit()
|
|
||||||
else:
|
|
||||||
db.flush()
|
|
||||||
except IntegrityError:
|
|
||||||
db.rollback()
|
|
||||||
return _miss(used_plays(db, user_id))
|
|
||||||
return {
|
return {
|
||||||
"should_play": True,
|
"should_play": True,
|
||||||
|
"reason": "ready",
|
||||||
|
"scene": scene,
|
||||||
"video_url": video_url,
|
"video_url": video_url,
|
||||||
"play_token": play.play_token,
|
"play_token": play.play_token,
|
||||||
"reward_coin": reward_coin,
|
"config_version": play.config_version,
|
||||||
"seq": seq,
|
"duration_ms": duration,
|
||||||
"remaining": max(0, max_plays - seq),
|
"circle_count": CIRCLE_COUNT,
|
||||||
|
"circle_duration_ms": duration / CIRCLE_COUNT,
|
||||||
|
"reward_coin": play.coin,
|
||||||
|
"reward_per_circle": play.coin // CIRCLE_COUNT,
|
||||||
|
"seq": used + 1,
|
||||||
|
"remaining": _remaining(maximum, used + 1),
|
||||||
|
"expires_at": play.expires_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _find_play(db: Session, user_id: int, token: str) -> GuideVideoPlay | None:
|
def _find_play(db: Session, user_id: int, token: str) -> GuideVideoPlay | None:
|
||||||
"""按 (play_token, user_id) 取播放行 —— 带 user_id 是防拿别人的 token 来兑。"""
|
|
||||||
return db.execute(
|
return db.execute(
|
||||||
select(GuideVideoPlay).where(
|
select(GuideVideoPlay).where(
|
||||||
GuideVideoPlay.play_token == token,
|
GuideVideoPlay.play_token == token.strip(),
|
||||||
GuideVideoPlay.user_id == user_id,
|
GuideVideoPlay.user_id == user_id,
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
def grant_play(
|
def _start_out(
|
||||||
db: Session, user_id: int, *, play_token: str, completed: bool
|
play: GuideVideoPlay,
|
||||||
|
maximum: int | None,
|
||||||
|
used: int,
|
||||||
|
status: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""按 play_token 发这次引导视频的金币(幂等)。播完 / 中途关闭都发。
|
assert play.started_at is not None and play.seq is not None and play.video_url
|
||||||
|
return {
|
||||||
|
"started": True,
|
||||||
|
"status": status,
|
||||||
|
"play_token": play.play_token,
|
||||||
|
"scene": play.scene,
|
||||||
|
"video_url": play.video_url,
|
||||||
|
"config_version": play.config_version,
|
||||||
|
"duration_ms": play.duration_ms,
|
||||||
|
"circle_count": CIRCLE_COUNT,
|
||||||
|
"circle_duration_ms": play.duration_ms / CIRCLE_COUNT,
|
||||||
|
"reward_coin": play.coin,
|
||||||
|
"reward_per_circle": play.coin // CIRCLE_COUNT,
|
||||||
|
"seq": play.seq,
|
||||||
|
"remaining": _remaining(maximum, used),
|
||||||
|
"started_at": play.started_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
返回 {granted, coin, status}:granted=True 表示**本次调用真的入账了**;
|
|
||||||
重复上报返回 granted=False + 已发金币(客户端据此不重复累加 toast 金额)。
|
|
||||||
"""
|
|
||||||
token = (play_token or "").strip()
|
|
||||||
# 金币额度以**服务端配置**为准,不信客户端(客户端只上报"播完/关闭")。
|
|
||||||
coin = int(get_config(db).get("reward_coin") or 0)
|
|
||||||
|
|
||||||
# 幂等核心:把 status 放进 WHERE 做条件更新(compare-and-set),而不是"先读再判再写"。
|
def start_play(db: Session, user_id: int, *, play_token: str) -> dict[str, Any]:
|
||||||
# 「播完」与「✕ 关闭」抢跑、或客户端超时重试时,两个请求会都读到 status='playing',
|
play = _find_play(db, user_id, play_token)
|
||||||
# 无锁的话就都往下发币、都 commit,金币入账两次(不用恶意,重试就会中招)。改成条件更新后
|
if play is None:
|
||||||
# 并发里只有一条 rowcount=1,另一条拿 0 → 按已发返回,不二次铸币。
|
raise PlayStateError("play_not_found", "播放计划不存在")
|
||||||
# (PG READ COMMITTED 下后到的 UPDATE 阻塞到对手提交,再按新版本重判 status;SQLite 写串行。)
|
cfg = get_config(db, play.scene)
|
||||||
#
|
maximum, reset_at = _effective_quota(
|
||||||
# 别指望 IntegrityError 兜底:这里只 UPDATE 不 INSERT,撞不到 uq_guide_video_play_token;
|
db, user_id, play.scene, cfg
|
||||||
# 而 biz_type='guide_video' 的金币流水也不在 ux_coin_transaction_task_ref 的谓词
|
)
|
||||||
# (biz_type LIKE 'task%')覆盖范围内 —— 两个唯一键在这条路径上都是不生效的。
|
used = used_plays(
|
||||||
|
db, user_id, play.scene, reset_at=reset_at
|
||||||
|
)
|
||||||
|
if play.status in {"started", "completed"}:
|
||||||
|
return _start_out(play, maximum, used, "already_started")
|
||||||
|
if play.status != "prepared":
|
||||||
|
raise PlayStateError("play_not_found", "播放计划不可用")
|
||||||
|
now = _now()
|
||||||
|
if play.expires_at is None or now > play.expires_at:
|
||||||
|
raise PlayStateError(
|
||||||
|
"plan_expired", "播放计划已过期,请重新获取",
|
||||||
|
reprepare_required=True,
|
||||||
|
)
|
||||||
|
if int(cfg["config_version"]) != play.config_version:
|
||||||
|
raise PlayStateError(
|
||||||
|
"config_changed", "视频配置已变化,请重新获取",
|
||||||
|
reprepare_required=True,
|
||||||
|
)
|
||||||
|
if maximum is not None and used >= maximum:
|
||||||
|
raise PlayStateError("play_limit_reached", "播放次数已用完")
|
||||||
|
# seq 在数据库中是账号+场景生命周期唯一值;白名单重置只重置额度,
|
||||||
|
# 不能从 1 重新编号,否则会与历史记录冲突。
|
||||||
|
play.seq = used_plays(db, user_id, play.scene) + 1
|
||||||
|
play.status = "started"
|
||||||
|
play.started_at = now
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(play)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise PlayStateError("play_limit_reached", "并发起播冲突,请重新获取") from exc
|
||||||
|
return _start_out(play, maximum, used + 1, "started")
|
||||||
|
|
||||||
|
|
||||||
|
def _coin_balance(db: Session, user_id: int) -> int:
|
||||||
|
account = db.get(CoinAccount, user_id)
|
||||||
|
return int(account.coin_balance if account else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def grant_circle(
|
||||||
|
db: Session, user_id: int, *, play_token: str, circle: int
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
play = _find_play(db, user_id, play_token)
|
||||||
|
balance = _coin_balance(db, user_id)
|
||||||
|
base = {
|
||||||
|
"granted": False,
|
||||||
|
"retryable": False,
|
||||||
|
"retry_after_ms": 0,
|
||||||
|
"circle": circle,
|
||||||
|
"granted_coin": 0,
|
||||||
|
"settled_circles": int(play.settled_circles if play else 0),
|
||||||
|
"coin_balance": balance,
|
||||||
|
}
|
||||||
|
if play is None:
|
||||||
|
return {**base, "status": "not_found"}
|
||||||
|
if play.status not in {"started", "completed"} or play.started_at is None:
|
||||||
|
return {**base, "status": "not_started"}
|
||||||
|
settled = int(play.settled_circles)
|
||||||
|
if circle <= settled:
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"status": "already_granted",
|
||||||
|
"granted_coin": play.coin // CIRCLE_COUNT,
|
||||||
|
"settled_circles": settled,
|
||||||
|
}
|
||||||
|
if settled >= CIRCLE_COUNT:
|
||||||
|
return {**base, "status": "finished", "settled_circles": settled}
|
||||||
|
if circle != settled + 1:
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"status": "out_of_order",
|
||||||
|
"retryable": True,
|
||||||
|
"settled_circles": settled,
|
||||||
|
}
|
||||||
|
# 向上取整到毫秒,绝不让第 N 圈早于精确 N/10 边界发奖。
|
||||||
|
boundary_ms = (circle * play.duration_ms + CIRCLE_COUNT - 1) // CIRCLE_COUNT
|
||||||
|
elapsed_ms = max(0, int((_now() - play.started_at).total_seconds() * 1000))
|
||||||
|
if elapsed_ms < boundary_ms:
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"status": "too_early",
|
||||||
|
"retryable": True,
|
||||||
|
"retry_after_ms": boundary_ms - elapsed_ms,
|
||||||
|
"settled_circles": settled,
|
||||||
|
}
|
||||||
|
|
||||||
|
final = circle == CIRCLE_COUNT
|
||||||
won = db.execute(
|
won = db.execute(
|
||||||
update(GuideVideoPlay)
|
update(GuideVideoPlay)
|
||||||
.where(
|
.where(
|
||||||
GuideVideoPlay.play_token == token,
|
GuideVideoPlay.id == play.id,
|
||||||
GuideVideoPlay.user_id == user_id,
|
GuideVideoPlay.status == "started",
|
||||||
GuideVideoPlay.status == "playing",
|
GuideVideoPlay.settled_circles == settled,
|
||||||
)
|
)
|
||||||
.values(
|
.values(
|
||||||
status="granted",
|
settled_circles=circle,
|
||||||
coin=coin,
|
status="completed" if final else "started",
|
||||||
completed=1 if completed else 0,
|
completed=1 if final else 0,
|
||||||
granted_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
granted_at=_now() if final else None,
|
||||||
)
|
)
|
||||||
.execution_options(synchronize_session=False)
|
.execution_options(synchronize_session=False)
|
||||||
).rowcount
|
).rowcount
|
||||||
|
|
||||||
if not won:
|
if not won:
|
||||||
# 没抢到:token 不存在 / 不是本人的 / 已被另一次上报发过。回滚拿干净快照再区分两者
|
|
||||||
# (对手此时必然已提交 —— 我们就是被它挡下的,所以读得到它写的 coin)。
|
|
||||||
db.rollback()
|
db.rollback()
|
||||||
play = _find_play(db, user_id, token)
|
fresh = _find_play(db, user_id, play_token)
|
||||||
if play is None:
|
fresh_settled = int(fresh.settled_circles if fresh else 0)
|
||||||
return {"granted": False, "coin": 0, "status": "not_found"}
|
return {
|
||||||
return {"granted": False, "coin": play.coin, "status": "already_granted"}
|
**base,
|
||||||
|
"status": "already_granted" if circle <= fresh_settled else "out_of_order",
|
||||||
|
"retryable": circle > fresh_settled,
|
||||||
|
"granted_coin": (
|
||||||
|
play.coin // CIRCLE_COUNT if circle <= fresh_settled else 0
|
||||||
|
),
|
||||||
|
"settled_circles": fresh_settled,
|
||||||
|
"coin_balance": _coin_balance(db, user_id),
|
||||||
|
}
|
||||||
|
|
||||||
if coin > 0:
|
per_circle = play.coin // CIRCLE_COUNT
|
||||||
crud_wallet.grant_coins(
|
account, _ = crud_wallet.grant_coins(
|
||||||
db,
|
db,
|
||||||
user_id,
|
user_id,
|
||||||
coin,
|
per_circle,
|
||||||
biz_type=BIZ_TYPE,
|
biz_type=BIZ_TYPE,
|
||||||
ref_id=token,
|
ref_id=f"{play.play_token}:{circle}",
|
||||||
remark="新手引导视频奖励",
|
remark=f"新手引导视频第{circle}圈奖励",
|
||||||
)
|
)
|
||||||
db.commit()
|
try:
|
||||||
return {"granted": True, "coin": coin, "status": "granted"}
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# 流水唯一键是第二道幂等保险;若撞键,回滚后返回权威状态。
|
||||||
|
db.rollback()
|
||||||
|
fresh = _find_play(db, user_id, play_token)
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"status": "already_granted",
|
||||||
|
"granted_coin": play.coin // CIRCLE_COUNT,
|
||||||
|
"settled_circles": int(fresh.settled_circles if fresh else settled),
|
||||||
|
"coin_balance": _coin_balance(db, user_id),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"granted": True,
|
||||||
|
"status": "granted",
|
||||||
|
"granted_coin": per_circle,
|
||||||
|
"settled_circles": circle,
|
||||||
|
"coin_balance": int(account.coin_balance),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_videos(db: Session, *, protection: timedelta = PLAN_TTL) -> int:
|
||||||
|
"""清理超过保护期且未被当前配置/有效计划/近期起播引用的托管旧片。"""
|
||||||
|
now = _now()
|
||||||
|
protected_urls: set[str] = set()
|
||||||
|
for scene in SCENES:
|
||||||
|
url = get_config(db, scene).get("video_url")
|
||||||
|
if url:
|
||||||
|
protected_urls.add(str(url))
|
||||||
|
protected_urls.update(
|
||||||
|
str(url)
|
||||||
|
for url in db.execute(
|
||||||
|
select(GuideVideoPlay.video_url).where(
|
||||||
|
GuideVideoPlay.video_url.is_not(None),
|
||||||
|
(
|
||||||
|
(
|
||||||
|
(GuideVideoPlay.status == "prepared")
|
||||||
|
& (GuideVideoPlay.expires_at >= now)
|
||||||
|
)
|
||||||
|
| (
|
||||||
|
(GuideVideoPlay.status == "started")
|
||||||
|
& (GuideVideoPlay.started_at >= now - protection)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
if url
|
||||||
|
)
|
||||||
|
directory = Path(settings.MEDIA_ROOT) / "guide_video"
|
||||||
|
if not directory.is_dir():
|
||||||
|
return 0
|
||||||
|
cutoff = now.timestamp() - protection.total_seconds()
|
||||||
|
removed = 0
|
||||||
|
for path in directory.glob("guide_*.mp4"):
|
||||||
|
url = f"{settings.MEDIA_URL_PREFIX}/guide_video/{path.name}"
|
||||||
|
try:
|
||||||
|
if url not in protected_urls and path.stat().st_mtime <= cutoff:
|
||||||
|
media.delete_guide_video(url)
|
||||||
|
removed += 1
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return removed
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -10,9 +10,22 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models.phone_rebind_log import PhoneRebindLog
|
from app.models.phone_rebind_log import PhoneRebindLog
|
||||||
|
|
||||||
|
|
||||||
def rebound_within_days(db: Session, phone: str, days: int) -> bool:
|
def rebound_within_days(
|
||||||
|
db: Session,
|
||||||
|
phone: str,
|
||||||
|
days: int,
|
||||||
|
*,
|
||||||
|
reset_at: datetime | None = None,
|
||||||
|
) -> bool:
|
||||||
"""该手机号在最近 days 天内是否换绑过(命中 → 禁止再次换绑)。"""
|
"""该手机号在最近 days 天内是否换绑过(命中 → 禁止再次换绑)。"""
|
||||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
since = datetime.now(UTC) - timedelta(days=days)
|
||||||
|
if reset_at is not None:
|
||||||
|
reset_value = (
|
||||||
|
reset_at.replace(tzinfo=UTC)
|
||||||
|
if reset_at.tzinfo is None
|
||||||
|
else reset_at.astimezone(UTC)
|
||||||
|
)
|
||||||
|
since = max(since, reset_value)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(PhoneRebindLog.id)
|
select(PhoneRebindLog.id)
|
||||||
.where(PhoneRebindLog.phone == phone, PhoneRebindLog.rebound_at >= since)
|
.where(PhoneRebindLog.phone == phone, PhoneRebindLog.rebound_at >= since)
|
||||||
@@ -21,16 +34,25 @@ def rebound_within_days(db: Session, phone: str, days: int) -> bool:
|
|||||||
return db.execute(stmt).first() is not None
|
return db.execute(stmt).first() is not None
|
||||||
|
|
||||||
|
|
||||||
def remaining_block_days(db: Session, phone: str, days: int) -> int:
|
def remaining_block_days(
|
||||||
|
db: Session,
|
||||||
|
phone: str,
|
||||||
|
days: int,
|
||||||
|
*,
|
||||||
|
reset_at: datetime | None = None,
|
||||||
|
) -> int:
|
||||||
"""距离该手机号可再次换绑还剩几天(向上取整;无记录返回 0)。"""
|
"""距离该手机号可再次换绑还剩几天(向上取整;无记录返回 0)。"""
|
||||||
|
conditions = [PhoneRebindLog.phone == phone]
|
||||||
|
if reset_at is not None:
|
||||||
|
conditions.append(PhoneRebindLog.rebound_at >= reset_at)
|
||||||
last = db.execute(
|
last = db.execute(
|
||||||
select(func.max(PhoneRebindLog.rebound_at)).where(PhoneRebindLog.phone == phone)
|
select(func.max(PhoneRebindLog.rebound_at)).where(*conditions)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if last is None:
|
if last is None:
|
||||||
return 0
|
return 0
|
||||||
if last.tzinfo is None: # SQLite 取回 naive datetime,按 UTC 归一
|
if last.tzinfo is None: # SQLite 取回 naive datetime,按 UTC 归一
|
||||||
last = last.replace(tzinfo=timezone.utc)
|
last = last.replace(tzinfo=UTC)
|
||||||
remaining = (last + timedelta(days=days) - datetime.now(timezone.utc)).total_seconds()
|
remaining = (last + timedelta(days=days) - datetime.now(UTC)).total_seconds()
|
||||||
return max(0, math.ceil(remaining / 86400))
|
return max(0, math.ceil(remaining / 86400))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+92
-37
@@ -9,15 +9,10 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config_schema import (
|
from app.core import limit_policy
|
||||||
RISK_COMPARE_DAILY_THRESHOLD_KEY,
|
|
||||||
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
|
||||||
RISK_SMS_HOURLY_THRESHOLD_KEY,
|
|
||||||
)
|
|
||||||
from app.models.app_config import AppConfig
|
from app.models.app_config import AppConfig
|
||||||
from app.models.comparison import ComparisonRecord
|
from app.models.comparison import ComparisonRecord
|
||||||
from app.models.risk import BehaviorEvent, RiskIncident, SubjectRestriction
|
from app.models.risk import BehaviorEvent, RiskIncident, SubjectRestriction
|
||||||
from app.repositories import app_config
|
|
||||||
|
|
||||||
CN_TZ = ZoneInfo("Asia/Shanghai")
|
CN_TZ = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
@@ -41,7 +36,6 @@ class RuleSpec:
|
|||||||
code: str
|
code: str
|
||||||
event_type: str
|
event_type: str
|
||||||
subject_type: str
|
subject_type: str
|
||||||
threshold_key: str
|
|
||||||
window: str
|
window: str
|
||||||
count_outcomes: tuple[str, ...]
|
count_outcomes: tuple[str, ...]
|
||||||
|
|
||||||
@@ -51,7 +45,6 @@ RULES: dict[str, RuleSpec] = {
|
|||||||
code=RULE_SMS_HOURLY,
|
code=RULE_SMS_HOURLY,
|
||||||
event_type=EVENT_SMS_SEND,
|
event_type=EVENT_SMS_SEND,
|
||||||
subject_type="device",
|
subject_type="device",
|
||||||
threshold_key=RISK_SMS_HOURLY_THRESHOLD_KEY,
|
|
||||||
window="hour",
|
window="hour",
|
||||||
count_outcomes=("success",),
|
count_outcomes=("success",),
|
||||||
),
|
),
|
||||||
@@ -59,22 +52,23 @@ RULES: dict[str, RuleSpec] = {
|
|||||||
code=RULE_ONECLICK_DAILY,
|
code=RULE_ONECLICK_DAILY,
|
||||||
event_type=EVENT_ONECLICK_LOGIN,
|
event_type=EVENT_ONECLICK_LOGIN,
|
||||||
subject_type="device",
|
subject_type="device",
|
||||||
threshold_key=RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
|
||||||
window="day",
|
window="day",
|
||||||
count_outcomes=("success", "failed"),
|
count_outcomes=("success", "failed"),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
RULE_THRESHOLD_KEYS: dict[str, str] = {
|
RISK_LIMIT_RULE_CODES: dict[str, str] = {
|
||||||
RULE_SMS_HOURLY: RISK_SMS_HOURLY_THRESHOLD_KEY,
|
RULE_SMS_HOURLY: "risk.sms.hourly",
|
||||||
RULE_ONECLICK_DAILY: RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
RULE_ONECLICK_DAILY: "risk.oneclick.daily",
|
||||||
RULE_COMPARE_DAILY: RISK_COMPARE_DAILY_THRESHOLD_KEY,
|
RULE_COMPARE_DAILY: "risk.compare.daily",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_rule_threshold(db: Session, rule_code: str) -> int:
|
def get_rule_threshold(db: Session, rule_code: str) -> int:
|
||||||
"""读取规则当前阈值;配置表为空时回退上线前的 5/20/100 默认值。"""
|
"""读取规则当前阈值;配置表为空时回退上线前的 5/20/100 默认值。"""
|
||||||
return int(app_config.get_value(db, RULE_THRESHOLD_KEYS[rule_code]))
|
return limit_policy.resolve(
|
||||||
|
db, RISK_LIMIT_RULE_CODES[rule_code]
|
||||||
|
).global_limit
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
def utcnow() -> datetime:
|
||||||
@@ -257,22 +251,34 @@ def _upsert_incident(
|
|||||||
|
|
||||||
|
|
||||||
def evaluate_behavior_rule(
|
def evaluate_behavior_rule(
|
||||||
db: Session, *, rule_code: str, subject_id: str, at: datetime
|
db: Session,
|
||||||
|
*,
|
||||||
|
rule_code: str,
|
||||||
|
subject_id: str,
|
||||||
|
at: datetime,
|
||||||
|
threshold: int | None = None,
|
||||||
|
subject_reset_at: datetime | None = None,
|
||||||
) -> RiskIncident | None:
|
) -> RiskIncident | None:
|
||||||
spec = RULES[rule_code]
|
spec = RULES[rule_code]
|
||||||
threshold = get_rule_threshold(db, rule_code)
|
effective_threshold = threshold or get_rule_threshold(db, rule_code)
|
||||||
window_key, window_start, end = _window_bounds(at, spec.window)
|
window_key, window_start, end = _window_bounds(at, spec.window)
|
||||||
reset_at = get_rule_reset_at(db, rule_code)
|
reset_at = get_rule_reset_at(db, rule_code)
|
||||||
start = max(window_start, reset_at) if reset_at else window_start
|
baselines = [value for value in (reset_at, subject_reset_at) if value is not None]
|
||||||
|
start = max(window_start, *baselines) if baselines else window_start
|
||||||
count, first_at, last_at, triggered_at = _event_stats(
|
count, first_at, last_at, triggered_at = _event_stats(
|
||||||
db,
|
db,
|
||||||
spec,
|
spec,
|
||||||
subject_id=subject_id,
|
subject_id=subject_id,
|
||||||
start=start,
|
start=start,
|
||||||
end=end,
|
end=end,
|
||||||
threshold=threshold,
|
threshold=effective_threshold,
|
||||||
)
|
)
|
||||||
if count < threshold or first_at is None or last_at is None or triggered_at is None:
|
if (
|
||||||
|
count < effective_threshold
|
||||||
|
or first_at is None
|
||||||
|
or last_at is None
|
||||||
|
or triggered_at is None
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
return _upsert_incident(
|
return _upsert_incident(
|
||||||
db,
|
db,
|
||||||
@@ -300,7 +306,6 @@ def reconcile_behavior_rule(
|
|||||||
"""按当前阈值重算短信/一键登录当前窗口,并收起已不再命中的待处理告警。"""
|
"""按当前阈值重算短信/一键登录当前窗口,并收起已不再命中的待处理告警。"""
|
||||||
spec = RULES[rule_code]
|
spec = RULES[rule_code]
|
||||||
current = at or utcnow()
|
current = at or utcnow()
|
||||||
threshold = get_rule_threshold(db, rule_code)
|
|
||||||
window_key, window_start, end = _window_bounds(current, spec.window)
|
window_key, window_start, end = _window_bounds(current, spec.window)
|
||||||
reset_at = get_rule_reset_at(db, rule_code)
|
reset_at = get_rule_reset_at(db, rule_code)
|
||||||
start = max(window_start, reset_at) if reset_at else window_start
|
start = max(window_start, reset_at) if reset_at else window_start
|
||||||
@@ -311,23 +316,39 @@ def reconcile_behavior_rule(
|
|||||||
BehaviorEvent.occurred_at >= start,
|
BehaviorEvent.occurred_at >= start,
|
||||||
BehaviorEvent.occurred_at < end,
|
BehaviorEvent.occurred_at < end,
|
||||||
)
|
)
|
||||||
qualifying_rows = db.execute(
|
subject_rows = db.execute(
|
||||||
select(
|
select(
|
||||||
BehaviorEvent.subject_id,
|
BehaviorEvent.subject_id,
|
||||||
func.max(BehaviorEvent.occurred_at),
|
func.max(BehaviorEvent.occurred_at),
|
||||||
|
func.max(BehaviorEvent.phone),
|
||||||
)
|
)
|
||||||
.where(*filters)
|
.where(*filters)
|
||||||
.group_by(BehaviorEvent.subject_id)
|
.group_by(BehaviorEvent.subject_id)
|
||||||
.having(func.count(BehaviorEvent.id) >= threshold)
|
|
||||||
).all()
|
).all()
|
||||||
qualifying = {str(subject_id) for subject_id, _ in qualifying_rows}
|
qualifying: set[str] = set()
|
||||||
for subject_id, last_at in qualifying_rows:
|
policy_code = {
|
||||||
evaluate_behavior_rule(
|
RULE_SMS_HOURLY: "risk.sms.hourly",
|
||||||
|
RULE_ONECLICK_DAILY: "risk.oneclick.daily",
|
||||||
|
}[rule_code]
|
||||||
|
for subject_id, last_at, phone in subject_rows:
|
||||||
|
policy = limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
policy_code,
|
||||||
|
phone=phone,
|
||||||
|
device=str(subject_id),
|
||||||
|
)
|
||||||
|
if policy.suppressed or policy.limit is None:
|
||||||
|
continue
|
||||||
|
incident = evaluate_behavior_rule(
|
||||||
db,
|
db,
|
||||||
rule_code=rule_code,
|
rule_code=rule_code,
|
||||||
subject_id=str(subject_id),
|
subject_id=str(subject_id),
|
||||||
at=last_at or current,
|
at=last_at or current,
|
||||||
|
threshold=policy.limit,
|
||||||
|
subject_reset_at=policy.reset_at,
|
||||||
)
|
)
|
||||||
|
if incident is not None:
|
||||||
|
qualifying.add(str(subject_id))
|
||||||
|
|
||||||
open_incidents = db.scalars(
|
open_incidents = db.scalars(
|
||||||
select(RiskIncident).where(
|
select(RiskIncident).where(
|
||||||
@@ -383,7 +404,29 @@ def record_behavior_event(
|
|||||||
db.add(event)
|
db.add(event)
|
||||||
db.flush()
|
db.flush()
|
||||||
if evaluate_rule:
|
if evaluate_rule:
|
||||||
evaluate_behavior_rule(db, rule_code=evaluate_rule, subject_id=subject_id, at=at)
|
policy_code = {
|
||||||
|
RULE_SMS_HOURLY: "risk.sms.hourly",
|
||||||
|
RULE_ONECLICK_DAILY: "risk.oneclick.daily",
|
||||||
|
}.get(evaluate_rule)
|
||||||
|
policy = (
|
||||||
|
limit_policy.resolve(
|
||||||
|
db,
|
||||||
|
policy_code,
|
||||||
|
phone=phone,
|
||||||
|
device=device_id or subject_id,
|
||||||
|
)
|
||||||
|
if policy_code
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if policy is None or not policy.suppressed:
|
||||||
|
evaluate_behavior_rule(
|
||||||
|
db,
|
||||||
|
rule_code=evaluate_rule,
|
||||||
|
subject_id=subject_id,
|
||||||
|
at=at,
|
||||||
|
threshold=policy.limit if policy else None,
|
||||||
|
subject_reset_at=policy.reset_at if policy else None,
|
||||||
|
)
|
||||||
if commit:
|
if commit:
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(event)
|
db.refresh(event)
|
||||||
@@ -396,20 +439,33 @@ def sync_compare_incident(
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
at: datetime,
|
at: datetime,
|
||||||
threshold: int | None = None,
|
threshold: int | None = None,
|
||||||
|
device_id: str | None = None,
|
||||||
commit: bool = True,
|
commit: bool = True,
|
||||||
) -> RiskIncident | None:
|
) -> RiskIncident | None:
|
||||||
effective_threshold = threshold or get_rule_threshold(db, RULE_COMPARE_DAILY)
|
policy = limit_policy.resolve_for_user(
|
||||||
|
db,
|
||||||
|
"risk.compare.daily",
|
||||||
|
user_id,
|
||||||
|
device=device_id,
|
||||||
|
)
|
||||||
|
if policy.suppressed:
|
||||||
|
return None
|
||||||
|
effective_threshold = threshold or policy.limit or get_rule_threshold(
|
||||||
|
db, RULE_COMPARE_DAILY
|
||||||
|
)
|
||||||
# comparison_record 的既有写入口统一落“北京时间 naive”时间;这里必须沿用同一
|
# comparison_record 的既有写入口统一落“北京时间 naive”时间;这里必须沿用同一
|
||||||
# 口径,否则 SQLite/PG session timezone 不同时会把凌晨记录算到前一天。
|
# 口径,否则 SQLite/PG session timezone 不同时会把凌晨记录算到前一天。
|
||||||
local = at.astimezone(CN_TZ).replace(tzinfo=None) if at.tzinfo else at
|
local = at.astimezone(CN_TZ).replace(tzinfo=None) if at.tzinfo else at
|
||||||
window_start = local.replace(hour=0, minute=0, second=0, microsecond=0)
|
window_start = local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
end = window_start + timedelta(days=1)
|
end = window_start + timedelta(days=1)
|
||||||
window_key = window_start.strftime("%Y-%m-%d")
|
window_key = window_start.strftime("%Y-%m-%d")
|
||||||
reset_at = get_rule_reset_at(db, RULE_COMPARE_DAILY)
|
global_reset_at = get_rule_reset_at(db, RULE_COMPARE_DAILY)
|
||||||
reset_local = (
|
baselines = [
|
||||||
reset_at.astimezone(CN_TZ).replace(tzinfo=None) if reset_at else None
|
value.astimezone(CN_TZ).replace(tzinfo=None)
|
||||||
)
|
for value in (global_reset_at, policy.reset_at)
|
||||||
start = max(window_start, reset_local) if reset_local else window_start
|
if value is not None
|
||||||
|
]
|
||||||
|
start = max(window_start, *baselines) if baselines else window_start
|
||||||
filters = (
|
filters = (
|
||||||
ComparisonRecord.user_id == user_id,
|
ComparisonRecord.user_id == user_id,
|
||||||
ComparisonRecord.created_at >= start,
|
ComparisonRecord.created_at >= start,
|
||||||
@@ -464,7 +520,6 @@ def reconcile_compare_rule(
|
|||||||
reset_at.astimezone(CN_TZ).replace(tzinfo=None) if reset_at else None
|
reset_at.astimezone(CN_TZ).replace(tzinfo=None) if reset_at else None
|
||||||
)
|
)
|
||||||
start = max(window_start, reset_local) if reset_local else window_start
|
start = max(window_start, reset_local) if reset_local else window_start
|
||||||
threshold = get_rule_threshold(db, RULE_COMPARE_DAILY)
|
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(
|
select(
|
||||||
ComparisonRecord.user_id,
|
ComparisonRecord.user_id,
|
||||||
@@ -476,17 +531,17 @@ def reconcile_compare_rule(
|
|||||||
ComparisonRecord.created_at < end,
|
ComparisonRecord.created_at < end,
|
||||||
)
|
)
|
||||||
.group_by(ComparisonRecord.user_id)
|
.group_by(ComparisonRecord.user_id)
|
||||||
.having(func.count(ComparisonRecord.id) >= threshold)
|
|
||||||
).all()
|
).all()
|
||||||
qualifying = {str(user_id) for user_id, _ in rows}
|
qualifying: set[str] = set()
|
||||||
for user_id, last_at in rows:
|
for user_id, last_at in rows:
|
||||||
sync_compare_incident(
|
incident = sync_compare_incident(
|
||||||
db,
|
db,
|
||||||
user_id=int(user_id),
|
user_id=int(user_id),
|
||||||
at=last_at or current,
|
at=last_at or current,
|
||||||
threshold=threshold,
|
|
||||||
commit=False,
|
commit=False,
|
||||||
)
|
)
|
||||||
|
if incident is not None:
|
||||||
|
qualifying.add(str(user_id))
|
||||||
|
|
||||||
open_incidents = db.scalars(
|
open_incidents = db.scalars(
|
||||||
select(RiskIncident).where(
|
select(RiskIncident).where(
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ def get_user_by_id(db: Session, user_id: int) -> User | None:
|
|||||||
return db.get(User, user_id)
|
return db.get(User, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def user_exists(db: Session, user_id: int) -> bool:
|
||||||
|
"""只查主键判断用户是否存在,避免只读统计接口依赖完整用户表结构。"""
|
||||||
|
return db.scalar(select(User.id).where(User.id == user_id)) is not None
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_phone(db: Session, phone: str) -> User | None:
|
def get_user_by_phone(db: Session, phone: str) -> User | None:
|
||||||
stmt = select(User).where(User.phone == phone)
|
stmt = select(User).where(User.phone == phone)
|
||||||
return db.execute(stmt).scalar_one_or_none()
|
return db.execute(stmt).scalar_one_or_none()
|
||||||
|
|||||||
+155
-15
@@ -6,6 +6,7 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import uuid
|
import uuid
|
||||||
@@ -20,7 +21,6 @@ from app.core.config import settings
|
|||||||
from app.core.rewards import COIN_PER_CENT, coins_to_cents
|
from app.core.rewards import COIN_PER_CENT, coins_to_cents
|
||||||
from app.integrations import wxpay
|
from app.integrations import wxpay
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.user import apply_wechat_display_identity
|
|
||||||
from app.models.wallet import (
|
from app.models.wallet import (
|
||||||
CashTransaction,
|
CashTransaction,
|
||||||
CoinAccount,
|
CoinAccount,
|
||||||
@@ -29,12 +29,55 @@ from app.models.wallet import (
|
|||||||
WechatTransferAuthorization,
|
WechatTransferAuthorization,
|
||||||
WithdrawOrder,
|
WithdrawOrder,
|
||||||
)
|
)
|
||||||
|
from app.repositories.user import apply_wechat_display_identity
|
||||||
from app.services import notification_events
|
from app.services import notification_events
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
|
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
|
||||||
_WX_STATE_SUCCESS = "SUCCESS"
|
_WX_STATE_SUCCESS = "SUCCESS"
|
||||||
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
|
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
|
||||||
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
|
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
|
||||||
|
_WX_FAIL_REASON_LABELS = {
|
||||||
|
"ACCOUNT_FROZEN": "用户微信账户被冻结",
|
||||||
|
"ACCOUNT_NOT_EXIST": "用户微信账户不存在",
|
||||||
|
"BANK_CARD_ACCOUNT_ABNORMAL": "用户银行卡已销户、冻结、作废或挂失",
|
||||||
|
"BANK_CARD_BANK_INFO_WRONG": "用户登记的银行或分支行信息有误",
|
||||||
|
"BANK_CARD_CARD_INFO_WRONG": "用户银行卡户名或卡号有误",
|
||||||
|
"BANK_CARD_COLLECTIONS_ABOVE_QUOTA": "用户银行卡收款达到限额",
|
||||||
|
"BANK_CARD_PARAM_ERROR": "用户收款银行卡信息错误",
|
||||||
|
"BANK_CARD_STATUS_ABNORMAL": "用户银行卡状态异常",
|
||||||
|
"BLOCK_B2C_USERLIMITAMOUNT_BSRULE_MONTH": "用户本月转账收款已达限额",
|
||||||
|
"BLOCK_B2C_USERLIMITAMOUNT_MONTH": "用户账户存在风险,本月收款受限",
|
||||||
|
"DAY_RECEIVED_COUNT_EXCEED": "用户当日收款次数已达上限",
|
||||||
|
"DAY_RECEIVED_QUOTA_EXCEED": "用户当日收款额度已达上限",
|
||||||
|
"EXCEEDED_ESTIMATED_AMOUNT": "转账金额超过预约金额范围",
|
||||||
|
"ID_CARD_NOT_CORRECT": "收款人身份证校验不通过",
|
||||||
|
"MCH_CANCEL": "商户已撤销付款",
|
||||||
|
"MERCHANT_REJECT": "商户转账验密人已驳回",
|
||||||
|
"MERCHANT_NOT_CONFIRM": "商户转账验密人超时未确认",
|
||||||
|
"NAME_NOT_CORRECT": "收款人姓名校验不通过",
|
||||||
|
"OPENID_INVALID": "用户 OpenID 无效或不属于当前 AppID",
|
||||||
|
"OTHER_FAIL_REASON_TYPE": "微信返回其他失败原因",
|
||||||
|
"OVERDUE_CLOSE": "超过微信系统重试期,订单自动关闭",
|
||||||
|
"PAYEE_ACCOUNT_ABNORMAL": "用户微信账户收款异常",
|
||||||
|
"PAYER_ACCOUNT_ABNORMAL": "商户账户付款受限",
|
||||||
|
"PRODUCT_AUTH_CHECK_FAIL": "商户未开通转账权限或权限已冻结",
|
||||||
|
"REALNAME_ACCOUNT_RECEIVED_QUOTA_EXCEED": "用户微信实名账户收款受限",
|
||||||
|
"REAL_NAME_CHECK_FAIL": "用户未完成微信实名认证",
|
||||||
|
"RECEIVE_ACCOUNT_NOT_CONFIGURE": "商户未配置收款用户列表",
|
||||||
|
"RESERVATION_INFO_NOT_MATCH": "转账信息与预约信息不一致",
|
||||||
|
"RESERVATION_SCENE_NOT_MATCH": "转账场景与预约场景不一致",
|
||||||
|
"RESERVATION_STATE_INVALID": "预约转账单状态异常",
|
||||||
|
"TRANSFER_QUOTA_EXCEED": "用户单笔收款额度已达上限",
|
||||||
|
"TRANSFER_REMARK_SET_FAIL": "微信转账备注设置失败",
|
||||||
|
"TRANSFER_RISK": "该笔转账存在风险,已被微信拦截",
|
||||||
|
"TRANSFER_SCENE_INVALID": "商户未获取当前转账场景",
|
||||||
|
"TRANSFER_SCENE_UNAVAILABLE": "当前转账场景暂不可用",
|
||||||
|
"RELATED_ORDER_TRANSFER_AMOUNT_EXCEED": "关联订单累计付款金额超过上限",
|
||||||
|
"RELATED_ORDER_TRANSFER_COUNT_EXCEED": "关联订单累计付款次数超过上限",
|
||||||
|
"BUDGET_NOT_ENOUGH": "商户预算资金不足",
|
||||||
|
}
|
||||||
# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。
|
# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。
|
||||||
# 被拒/转账失败/解绑退回(rejected/failed,均已退款、钱没到手)不在此列 → 新人档恢复可提
|
# 被拒/转账失败/解绑退回(rejected/failed,均已退款、钱没到手)不在此列 → 新人档恢复可提
|
||||||
# (2026-07-16 修正:此前判定不看状态,解绑微信退回后 0.1 被误判已用、资格永久锁死)。
|
# (2026-07-16 修正:此前判定不看状态,解绑微信退回后 0.1 被误判已用、资格永久锁死)。
|
||||||
@@ -89,10 +132,20 @@ def get_or_create_account(
|
|||||||
) -> CoinAccount:
|
) -> CoinAccount:
|
||||||
"""取用户金币账户,不存在则建一个空账户。
|
"""取用户金币账户,不存在则建一个空账户。
|
||||||
|
|
||||||
lock=True 时对已存在的账户行加 SELECT FOR UPDATE(读-算-写余额的调用方串行化,防并发
|
lock=True 时对已存在的账户行加 SELECT FOR UPDATE,并强制刷新 identity map 中可能
|
||||||
双写余额错位,如 admin set 模式连点);默认 False 不改 C 端发奖行为。SQLite 下为 no-op。
|
缓存的旧余额;所有金币写入口都会使用它。SQLite 下 FOR UPDATE 为 no-op。
|
||||||
"""
|
"""
|
||||||
acc = db.get(CoinAccount, user_id, with_for_update=True) if lock else db.get(CoinAccount, user_id)
|
if lock:
|
||||||
|
# populate_existing 很关键:同一 Session 可能早已缓存旧余额。只加 FOR UPDATE 而
|
||||||
|
# 继续复用 identity map 里的旧对象,仍会在另一笔发奖提交后把余额覆盖回去。
|
||||||
|
acc = db.execute(
|
||||||
|
select(CoinAccount)
|
||||||
|
.where(CoinAccount.user_id == user_id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
else:
|
||||||
|
acc = db.get(CoinAccount, user_id)
|
||||||
if acc is None:
|
if acc is None:
|
||||||
acc = CoinAccount(
|
acc = CoinAccount(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -122,7 +175,9 @@ def grant_coins(
|
|||||||
|
|
||||||
返回 (account, transaction)。调用方负责 commit。
|
返回 (account, transaction)。调用方负责 commit。
|
||||||
"""
|
"""
|
||||||
acc = get_or_create_account(db, user_id, commit=False)
|
# 所有金币来源统一遵守账户行锁协议;否则 guide 锁了账户,广告/签到等未锁路径
|
||||||
|
# 仍可拿旧余额做 ORM 读改写,最终余额会小于流水累计。
|
||||||
|
acc = get_or_create_account(db, user_id, commit=False, lock=True)
|
||||||
acc.coin_balance += amount
|
acc.coin_balance += amount
|
||||||
if amount > 0:
|
if amount > 0:
|
||||||
acc.total_coin_earned += amount
|
acc.total_coin_earned += amount
|
||||||
@@ -155,7 +210,7 @@ def grant_cash(
|
|||||||
与 [grant_coins] 同模式(运营手动调现金 / 测试发现金用)。返回 (account, transaction),
|
与 [grant_coins] 同模式(运营手动调现金 / 测试发现金用)。返回 (account, transaction),
|
||||||
调用方负责 commit。不在此校验扣成负——由调用方(admin router)按业务保护。
|
调用方负责 commit。不在此校验扣成负——由调用方(admin router)按业务保护。
|
||||||
"""
|
"""
|
||||||
acc = get_or_create_account(db, user_id, commit=False)
|
acc = get_or_create_account(db, user_id, commit=False, lock=True)
|
||||||
acc.cash_balance_cents += amount_cents
|
acc.cash_balance_cents += amount_cents
|
||||||
|
|
||||||
txn = CashTransaction(
|
txn = CashTransaction(
|
||||||
@@ -185,7 +240,7 @@ def grant_invite_cash(
|
|||||||
invite_cash_transaction,不 commit。与金币兑换的 cash_balance_cents **物理隔离**
|
invite_cash_transaction,不 commit。与金币兑换的 cash_balance_cents **物理隔离**
|
||||||
(产品红线:邀请奖励金 ≠ 金币现金,两本账不可累加)。返回 (account, transaction),
|
(产品红线:邀请奖励金 ≠ 金币现金,两本账不可累加)。返回 (account, transaction),
|
||||||
调用方负责 commit。不在此校验扣成负——由调用方按业务保护。"""
|
调用方负责 commit。不在此校验扣成负——由调用方按业务保护。"""
|
||||||
acc = get_or_create_account(db, user_id, commit=False)
|
acc = get_or_create_account(db, user_id, commit=False, lock=True)
|
||||||
acc.invite_cash_balance_cents += amount_cents
|
acc.invite_cash_balance_cents += amount_cents
|
||||||
|
|
||||||
txn = InviteCashTransaction(
|
txn = InviteCashTransaction(
|
||||||
@@ -243,7 +298,8 @@ def exchange_coins_to_cash(
|
|||||||
if coin_amount < floor_min or coin_amount % COIN_PER_CENT != 0:
|
if coin_amount < floor_min or coin_amount % COIN_PER_CENT != 0:
|
||||||
raise InvalidExchangeAmountError
|
raise InvalidExchangeAmountError
|
||||||
|
|
||||||
acc = get_or_create_account(db, user_id, commit=False)
|
# 余额充足校验必须和扣减遵守同一账户锁,否则并发消费都可能通过旧余额校验。
|
||||||
|
acc = get_or_create_account(db, user_id, commit=False, lock=True)
|
||||||
if acc.coin_balance < coin_amount:
|
if acc.coin_balance < coin_amount:
|
||||||
raise InsufficientCoinError
|
raise InsufficientCoinError
|
||||||
|
|
||||||
@@ -479,6 +535,19 @@ def _try_deduct_cash(db: Session, user_id: int, amount_cents: int, source: str =
|
|||||||
|
|
||||||
def _add_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> int:
|
def _add_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> int:
|
||||||
"""原子增加指定账户余额(退款用),返回加后余额。source 决定退回哪个账户(两账户隔离)。"""
|
"""原子增加指定账户余额(退款用),返回加后余额。source 决定退回哪个账户(两账户隔离)。"""
|
||||||
|
# Normal withdrawals always create the account before deducting funds. This
|
||||||
|
# fallback covers legacy rows, hand-written fixtures, and broken migrations:
|
||||||
|
# a missing balance snapshot must not make a legitimate refund fail with 500.
|
||||||
|
if db.get(CoinAccount, user_id) is None:
|
||||||
|
logger.error(
|
||||||
|
"withdraw refund found missing coin_account; recreating empty account: "
|
||||||
|
"user_id=%s source=%s amount_cents=%s",
|
||||||
|
user_id,
|
||||||
|
source,
|
||||||
|
amount_cents,
|
||||||
|
)
|
||||||
|
get_or_create_account(db, user_id, commit=False)
|
||||||
|
|
||||||
col = _balance_col(source)
|
col = _balance_col(source)
|
||||||
db.execute(
|
db.execute(
|
||||||
update(CoinAccount)
|
update(CoinAccount)
|
||||||
@@ -582,6 +651,32 @@ def _wx_not_found(result: dict) -> bool:
|
|||||||
return "NOT_FOUND" in str(code)
|
return "NOT_FOUND" in str(code)
|
||||||
|
|
||||||
|
|
||||||
|
def _wechat_api_error_reason(data: object) -> str:
|
||||||
|
"""Format a non-200 WeChat API response for operator display."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return f"微信发起转账失败:{data}"
|
||||||
|
code = str(data.get("code") or "").strip()
|
||||||
|
message = str(data.get("message") or "").strip()
|
||||||
|
if code and message:
|
||||||
|
return f"微信发起转账失败:{message}({code})"
|
||||||
|
return f"微信发起转账失败:{message or code or '未知错误'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _wechat_terminal_failure_reason(data: dict, state: str) -> str:
|
||||||
|
"""Translate WeChat query ``fail_reason`` while preserving unknown codes."""
|
||||||
|
code = str(data.get("fail_reason") or "").strip()
|
||||||
|
if code:
|
||||||
|
label = _WX_FAIL_REASON_LABELS.get(code)
|
||||||
|
if label:
|
||||||
|
return f"微信转账失败:{label}({code})"
|
||||||
|
return f"微信转账失败:{code}"
|
||||||
|
if state == "CANCELLED":
|
||||||
|
return "微信转账已撤销(CANCELLED)"
|
||||||
|
if state == "CLOSED":
|
||||||
|
return "微信转账已关闭(CLOSED)"
|
||||||
|
return f"微信转账失败(状态:{state or 'FAIL'})"
|
||||||
|
|
||||||
|
|
||||||
def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> None:
|
def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> None:
|
||||||
"""转账调用结果不明(超时/异常/非200)时,**先查单再决定**,绝不盲目退款(防退款后又到账)。
|
"""转账调用结果不明(超时/异常/非200)时,**先查单再决定**,绝不盲目退款(防退款后又到账)。
|
||||||
- 微信查到 SUCCESS → 钱已出,置 success,不退款
|
- 微信查到 SUCCESS → 钱已出,置 success,不退款
|
||||||
@@ -607,13 +702,17 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N
|
|||||||
|
|
||||||
state = q["data"].get("state", "")
|
state = q["data"].get("state", "")
|
||||||
order.wechat_state = state
|
order.wechat_state = state
|
||||||
|
order.transfer_bill_no = q["data"].get("transfer_bill_no") or order.transfer_bill_no
|
||||||
if state == _WX_STATE_SUCCESS:
|
if state == _WX_STATE_SUCCESS:
|
||||||
order.status = "success"
|
order.status = "success"
|
||||||
order.transfer_bill_no = q["data"].get("transfer_bill_no")
|
|
||||||
db.commit()
|
db.commit()
|
||||||
notification_events.notify_withdraw_success(db, order) # PRD #3 提现到账
|
notification_events.notify_withdraw_success(db, order) # PRD #3 提现到账
|
||||||
elif state in _WX_STATE_FAILED:
|
elif state in _WX_STATE_FAILED:
|
||||||
_refund_withdraw(db, order, reason=reason)
|
_refund_withdraw(
|
||||||
|
db,
|
||||||
|
order,
|
||||||
|
reason=_wechat_terminal_failure_reason(q["data"], state),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
order.package_info = q["data"].get("package_info") or order.package_info
|
order.package_info = q["data"].get("package_info") or order.package_info
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -967,6 +1066,14 @@ def _apply_transfer_result(db: Session, order: WithdrawOrder, data: dict) -> Wit
|
|||||||
order.package_info = data.get("package_info") # 免确认转账无此字段(None);确认模式带它供拉确认页
|
order.package_info = data.get("package_info") # 免确认转账无此字段(None);确认模式带它供拉确认页
|
||||||
if data.get("state") == _WX_STATE_SUCCESS:
|
if data.get("state") == _WX_STATE_SUCCESS:
|
||||||
order.status = "success"
|
order.status = "success"
|
||||||
|
elif data.get("state") in _WX_STATE_FAILED:
|
||||||
|
_refund_withdraw(
|
||||||
|
db,
|
||||||
|
order,
|
||||||
|
reason=_wechat_terminal_failure_reason(data, str(data.get("state") or "")),
|
||||||
|
)
|
||||||
|
db.refresh(order)
|
||||||
|
return order
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(order)
|
db.refresh(order)
|
||||||
if order.status == "success": # 免确认转账直接到账 → PRD #3 提现到账
|
if order.status == "success": # 免确认转账直接到账 → PRD #3 提现到账
|
||||||
@@ -1011,7 +1118,11 @@ def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrde
|
|||||||
return order
|
return order
|
||||||
if result["status_code"] != 200:
|
if result["status_code"] != 200:
|
||||||
# 金额安全:查转账单后定夺,绝不盲退(未创建→退款,已创建→按真实状态)
|
# 金额安全:查转账单后定夺,绝不盲退(未创建→退款,已创建→按真实状态)
|
||||||
_settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"]))
|
_settle_after_ambiguous(
|
||||||
|
db,
|
||||||
|
order,
|
||||||
|
reason=_wechat_api_error_reason(result["data"]),
|
||||||
|
)
|
||||||
# 授权有效性:回查授权单,微信侧已失效(用户关闭/风控)→标 closed,下次提现自动回退方式一重新授权
|
# 授权有效性:回查授权单,微信侧已失效(用户关闭/风控)→标 closed,下次提现自动回退方式一重新授权
|
||||||
_refresh_active_auth(db, order.user_id)
|
_refresh_active_auth(db, order.user_id)
|
||||||
db.refresh(order)
|
db.refresh(order)
|
||||||
@@ -1039,7 +1150,11 @@ def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrde
|
|||||||
return order
|
return order
|
||||||
|
|
||||||
if result["status_code"] != 200:
|
if result["status_code"] != 200:
|
||||||
_settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"]))
|
_settle_after_ambiguous(
|
||||||
|
db,
|
||||||
|
order,
|
||||||
|
reason=_wechat_api_error_reason(result["data"]),
|
||||||
|
)
|
||||||
db.refresh(order)
|
db.refresh(order)
|
||||||
return order
|
return order
|
||||||
|
|
||||||
@@ -1096,19 +1211,24 @@ def refresh_withdraw_status(
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if order is None:
|
if order is None:
|
||||||
raise WithdrawOrderNotFound
|
raise WithdrawOrderNotFound
|
||||||
if order.status != "pending":
|
if order.status not in {"pending", "failed"}:
|
||||||
return order # 已终态,不再查
|
return order
|
||||||
|
enrich_failed_order = order.status == "failed"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = wxpay.query_transfer(out_bill_no)
|
result = wxpay.query_transfer(out_bill_no)
|
||||||
except wxpay.WxPayNotConfiguredError:
|
except wxpay.WxPayNotConfiguredError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc: # noqa: BLE001 - 查单失败不能把运营后台打成 500
|
except Exception as exc: # noqa: BLE001 - 查单失败不能把运营后台打成 500
|
||||||
|
if enrich_failed_order:
|
||||||
|
return order
|
||||||
order.fail_reason = f"微信查单异常,保持pending: {exc}"[:256]
|
order.fail_reason = f"微信查单异常,保持pending: {exc}"[:256]
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(order)
|
db.refresh(order)
|
||||||
return order
|
return order
|
||||||
if result["status_code"] != 200:
|
if result["status_code"] != 200:
|
||||||
|
if enrich_failed_order:
|
||||||
|
return order
|
||||||
if _wx_not_found(result):
|
if _wx_not_found(result):
|
||||||
# 微信明确无此单 → 转账从未创建(如崩溃在扣款后/调用前),退款安全
|
# 微信明确无此单 → 转账从未创建(如崩溃在扣款后/调用前),退款安全
|
||||||
_refund_withdraw(db, order, reason="微信无此单,已退回")
|
_refund_withdraw(db, order, reason="微信无此单,已退回")
|
||||||
@@ -1117,12 +1237,32 @@ def refresh_withdraw_status(
|
|||||||
|
|
||||||
state = result["data"].get("state", "")
|
state = result["data"].get("state", "")
|
||||||
order.wechat_state = state
|
order.wechat_state = state
|
||||||
|
order.transfer_bill_no = (
|
||||||
|
result["data"].get("transfer_bill_no") or order.transfer_bill_no
|
||||||
|
)
|
||||||
|
if enrich_failed_order:
|
||||||
|
if state in _WX_STATE_FAILED:
|
||||||
|
order.fail_reason = _wechat_terminal_failure_reason(
|
||||||
|
result["data"], state
|
||||||
|
)[:256]
|
||||||
|
elif state == _WX_STATE_SUCCESS:
|
||||||
|
order.fail_reason = (
|
||||||
|
"资金状态异常:本地已退款,但微信查单显示已到账,请人工核查"
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
return order
|
||||||
|
|
||||||
if state == _WX_STATE_SUCCESS:
|
if state == _WX_STATE_SUCCESS:
|
||||||
order.status = "success"
|
order.status = "success"
|
||||||
db.commit()
|
db.commit()
|
||||||
notification_events.notify_withdraw_success(db, order) # PRD #3 提现到账
|
notification_events.notify_withdraw_success(db, order) # PRD #3 提现到账
|
||||||
elif state in _WX_STATE_FAILED:
|
elif state in _WX_STATE_FAILED:
|
||||||
_refund_withdraw(db, order, reason=f"微信转账状态 {state}")
|
_refund_withdraw(
|
||||||
|
db,
|
||||||
|
order,
|
||||||
|
reason=_wechat_terminal_failure_reason(result["data"], state),
|
||||||
|
)
|
||||||
elif state == _WX_STATE_WAIT_CONFIRM and cancel_if_unconfirmed:
|
elif state == _WX_STATE_WAIT_CONFIRM and cancel_if_unconfirmed:
|
||||||
# 用户从确认页回来了却仍未确认 → 视为放弃:撤销微信单(防事后确认导致重复打款)后退款。
|
# 用户从确认页回来了却仍未确认 → 视为放弃:撤销微信单(防事后确认导致重复打款)后退款。
|
||||||
# 撤单失败(可能已被确认进 ACCEPTED 的竞态)则保持 pending,等下次查询。
|
# 撤单失败(可能已被确认进 ACCEPTED 的竞态)则保持 pending,等下次查询。
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""客户端运行日志批量上报 schema。
|
||||||
|
|
||||||
|
批级公共字段(device_id/user_id/app_ver/platform/sent_at)发一次;logs 为原始 dict 列表,
|
||||||
|
**不强类型**——尽力而为的日志链路,单条内容异常不该让整批 422。每条的「白名单键 + data
|
||||||
|
兜底」拆分在写入层 [app.core.client_log] 做(见 spec §4/§5)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# 单批条数上限。注意:这是**导入期**常量(Pydantic Field(max_length=) 在类定义时求值),
|
||||||
|
# 改它需重启进程;要运行期可调的上限用 APPLOG_MAX_BODY_BYTES(端点里 call-time 读)。
|
||||||
|
_MAX_BATCH = int(os.getenv("APPLOG_MAX_BATCH", "500"))
|
||||||
|
|
||||||
|
|
||||||
|
class AppLogBatchIn(BaseModel):
|
||||||
|
device_id: str = Field(max_length=64)
|
||||||
|
user_id: int | None = None
|
||||||
|
app_ver: str | None = Field(default=None, max_length=32)
|
||||||
|
platform: str | None = Field(default=None, max_length=16)
|
||||||
|
sent_at: int | None = None
|
||||||
|
logs: list[dict[str, Any]] = Field(min_length=1, max_length=_MAX_BATCH)
|
||||||
|
|
||||||
|
|
||||||
|
class AppLogIngestOut(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
received: int
|
||||||
|
dropped: int = 0
|
||||||
+15
-3
@@ -7,9 +7,19 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_login_phone(phone: str) -> str:
|
||||||
|
"""普通号保持大陆手机号格式;仅显式配置的测试号允许例外。"""
|
||||||
|
if re.fullmatch(r"^1\d{10}$", phone) or phone in settings.test_account_phones:
|
||||||
|
return phone
|
||||||
|
raise ValueError("invalid phone")
|
||||||
|
|
||||||
# ===== 用户对外信息 =====
|
# ===== 用户对外信息 =====
|
||||||
|
|
||||||
@@ -72,7 +82,7 @@ class JverifyLoginRequest(BaseModel):
|
|||||||
# ===== 短信验证码 =====
|
# ===== 短信验证码 =====
|
||||||
|
|
||||||
class SmsSendRequest(BaseModel):
|
class SmsSendRequest(BaseModel):
|
||||||
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
|
phone: str = Field(..., min_length=11, max_length=11)
|
||||||
device_id: str = Field(
|
device_id: str = Field(
|
||||||
"", max_length=64,
|
"", max_length=64,
|
||||||
description="硬件级设备标识(Android ANDROID_ID),用于发码防刷按 设备+IP 限流;空=按 IP 聚一桶",
|
description="硬件级设备标识(Android ANDROID_ID),用于发码防刷按 设备+IP 限流;空=按 IP 聚一桶",
|
||||||
@@ -80,6 +90,7 @@ class SmsSendRequest(BaseModel):
|
|||||||
device_model: str = Field(
|
device_model: str = Field(
|
||||||
"", max_length=128, description="客户端设备型号快照,用于短信安全审计"
|
"", max_length=128, description="客户端设备型号快照,用于短信安全审计"
|
||||||
)
|
)
|
||||||
|
_valid_phone = field_validator("phone")(_validate_login_phone)
|
||||||
|
|
||||||
|
|
||||||
class SmsSendResponse(BaseModel):
|
class SmsSendResponse(BaseModel):
|
||||||
@@ -89,7 +100,7 @@ class SmsSendResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SmsLoginRequest(BaseModel):
|
class SmsLoginRequest(BaseModel):
|
||||||
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
|
phone: str = Field(..., min_length=11, max_length=11)
|
||||||
code: str = Field(..., min_length=4, max_length=8)
|
code: str = Field(..., min_length=4, max_length=8)
|
||||||
device_id: str = Field(
|
device_id: str = Field(
|
||||||
"", max_length=64,
|
"", max_length=64,
|
||||||
@@ -98,6 +109,7 @@ class SmsLoginRequest(BaseModel):
|
|||||||
device_model: str = Field(
|
device_model: str = Field(
|
||||||
"", max_length=128, description="客户端设备型号快照,用于短信验证安全审计"
|
"", max_length=128, description="客户端设备型号快照,用于短信验证安全审计"
|
||||||
)
|
)
|
||||||
|
_valid_phone = field_validator("phone")(_validate_login_phone)
|
||||||
|
|
||||||
|
|
||||||
# ===== Refresh =====
|
# ===== Refresh =====
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ class ComparisonResultIn(BaseModel):
|
|||||||
status: str | None = None
|
status: str | None = None
|
||||||
# 门店打烊原因(price 为 None 时带): 与 status="store_closed" 等价的更早信号, 一并落库供前端兜底判打烊。
|
# 门店打烊原因(price 为 None 时带): 与 status="store_closed" 等价的更早信号, 一并落库供前端兜底判打烊。
|
||||||
store_closed: str | None = None
|
store_closed: str | None = None
|
||||||
|
# 该平台缺菜(漏菜)数量(pricebot 冗余进 comparison_results 行): 有价但少买了菜时 >0。
|
||||||
|
# 记录页三平台网格据此逐格标"缺少 X 个菜品"(网格只拿 comparison_results, 拿不到 platform_results)。
|
||||||
|
# 必须显式声明: 落库走 model_dump(), 不声明会被 pydantic 静默丢弃 → 记录页拿不到数量。
|
||||||
|
skipped_dish_count: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class ComparisonRecordIn(BaseModel):
|
class ComparisonRecordIn(BaseModel):
|
||||||
@@ -103,6 +107,13 @@ class ComparisonRecordIn(BaseModel):
|
|||||||
# 明细
|
# 明细
|
||||||
items: list[ComparisonItemIn] = Field(default_factory=list)
|
items: list[ComparisonItemIn] = Field(default_factory=list)
|
||||||
comparison_results: list[ComparisonResultIn] = Field(default_factory=list)
|
comparison_results: list[ComparisonResultIn] = Field(default_factory=list)
|
||||||
|
# 展示模型统一数组(pricebot done.params.platforms 原样透传): 每平台一行、自带
|
||||||
|
# status/is_best/display/display_order,记录页据此直渲染。宽松 list[dict] 存(结构由
|
||||||
|
# pricebot 定,server 只原样落库),前端读它、老记录空时回退 comparison_results。
|
||||||
|
platforms: list[dict] = Field(default_factory=list)
|
||||||
|
# 记录级结局(pricebot 下发): success/below_minimum/store_closed/failed。让"未满起送"不再
|
||||||
|
# 被塌缩成 failed。_derive 优先用它、其次客户端 status、再兜底二态派生。
|
||||||
|
record_status: str | None = None
|
||||||
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
|
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
|
||||||
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
|
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
|
||||||
# admin「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是
|
# admin「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是
|
||||||
@@ -169,8 +180,13 @@ class ComparisonRecordOut(BaseModel):
|
|||||||
skipped_dish_count: int | None = None
|
skipped_dish_count: int | None = None
|
||||||
status: str
|
status: str
|
||||||
information: str | None = None
|
information: str | None = None
|
||||||
|
# 失败卡「原因」文案:具体失败给具体原因,纯系统失败为 None(端侧品牌兜底)。见模型 fail_reason。
|
||||||
|
fail_reason: str | None = None
|
||||||
items: list = []
|
items: list = []
|
||||||
comparison_results: list = []
|
comparison_results: list = []
|
||||||
|
# 展示模型统一数组(每平台一行、自带 status/is_best/display/display_order): 记录页据此
|
||||||
|
# 直渲染, 不再靠 comparison_results + 前端派生。老记录为空 → 前端回退 comparison_results。
|
||||||
|
platforms: list = []
|
||||||
skipped_dish_names: list = []
|
skipped_dish_names: list = []
|
||||||
total_ms: int | None = None
|
total_ms: int | None = None
|
||||||
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
|
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
|
||||||
@@ -198,17 +214,24 @@ class ComparisonRecordCreatedOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CompareStartReserveIn(BaseModel):
|
class CompareStartReserveIn(BaseModel):
|
||||||
"""Reserve one authenticated comparison start before the agent begins."""
|
"""Reserve one authenticated comparison start before the agent begins.
|
||||||
|
|
||||||
trace_id: str = Field(..., min_length=1, max_length=64)
|
trace_id 可选:不带 = 请服务端签发(统一 trace_id 由后端下发,前端/SLS 日志/
|
||||||
|
pricebot 全链用同一个 id);带 = 沿用客户端值(老客户端兼容 + 网络重试幂等)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
trace_id: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
business_type: str = Field(default="food", min_length=1, max_length=16)
|
business_type: str = Field(default="food", min_length=1, max_length=16)
|
||||||
device_id: str | None = Field(default=None, max_length=64)
|
device_id: str | None = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class CompareStartReserveOut(BaseModel):
|
class CompareStartReserveOut(BaseModel):
|
||||||
limit: int
|
limit: int | None
|
||||||
used: int
|
used: int
|
||||||
remaining: int
|
remaining: int | None
|
||||||
|
# 本次比价全链 trace_id(服务端签发的,或回显客户端带来的)。客户端必须以它为准,
|
||||||
|
# 贯穿 Phase1/Phase2 step、比价记录、trace 收尾与前端运行日志上报。
|
||||||
|
trace_id: str
|
||||||
|
|
||||||
|
|
||||||
class CompareStatsOut(BaseModel):
|
class CompareStatsOut(BaseModel):
|
||||||
|
|||||||
@@ -58,9 +58,13 @@ class CouponSessionIn(BaseModel):
|
|||||||
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)。
|
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)。
|
||||||
- 收尾(completed/failed/abandoned):带 elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count。
|
- 收尾(completed/failed/abandoned):带 elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count。
|
||||||
不鉴权(同领券循环 MVP,按 device_id/trace_id),user_id 登录态带上做留痕(可空)。
|
不鉴权(同领券循环 MVP,按 device_id/trace_id),user_id 登录态带上做留痕(可空)。
|
||||||
|
|
||||||
|
trace_id 可选:started 不带 = 请服务端签发本轮领券 trace_id(统一 trace_id 由后端下发,
|
||||||
|
响应 CouponSessionOut.trace_id 返回,客户端全程用它);带 = 沿用客户端值(老客户端兼容)。
|
||||||
|
非 started 缺 trace_id 不签发(防孤儿行),返回 trace_id=null 且不写库。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
trace_id: str
|
trace_id: str | None = None
|
||||||
device_id: str
|
device_id: str
|
||||||
status: str # started / completed / failed / abandoned
|
status: str # started / completed / failed / abandoned
|
||||||
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
|
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
|
||||||
@@ -74,3 +78,16 @@ class CouponSessionIn(BaseModel):
|
|||||||
platform_elapsed: dict[str, int] | None = None
|
platform_elapsed: dict[str, int] | None = None
|
||||||
claimed_count: int | None = None
|
claimed_count: int | None = None
|
||||||
trace_url: str | None = None
|
trace_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CouponSessionOut(BaseModel):
|
||||||
|
"""POST /api/v1/coupon/session 响应。
|
||||||
|
|
||||||
|
trace_id = 本轮领券全链 id(服务端签发的,或回显客户端带来的);客户端以它为准贯穿
|
||||||
|
/coupon/step 循环、收尾上报与前端运行日志。⚠️ 不能沿用旧的 dict[str, bool] 返回注解——
|
||||||
|
FastAPI 会按注解校验响应,字符串 trace_id 过 bool 校验必炸,故显式建模。
|
||||||
|
非 started 且缺 trace_id 时为 null(不签发防孤儿行)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
ok: bool = True
|
||||||
|
trace_id: str | None = None
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class DeviceRegisterRequest(BaseModel):
|
|||||||
registration_id: str | None = None
|
registration_id: str | None = None
|
||||||
push_vendor: str | None = None
|
push_vendor: str | None = None
|
||||||
push_token: str | None = None
|
push_token: str | None = None
|
||||||
|
push_binding_id: str | None = Field(default=None, max_length=128)
|
||||||
platform: str = "android"
|
platform: str = "android"
|
||||||
app_version: str | None = None
|
app_version: str | None = None
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ class HeartbeatRequest(BaseModel):
|
|||||||
registration_id: str | None = None
|
registration_id: str | None = None
|
||||||
push_vendor: str | None = None
|
push_vendor: str | None = None
|
||||||
push_token: str | None = None
|
push_token: str | None = None
|
||||||
|
push_binding_id: str | None = Field(default=None, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
class DeviceOut(BaseModel):
|
class DeviceOut(BaseModel):
|
||||||
@@ -33,6 +35,7 @@ class DeviceOut(BaseModel):
|
|||||||
registration_id: str | None
|
registration_id: str | None
|
||||||
push_vendor: str | None
|
push_vendor: str | None
|
||||||
push_token: str | None
|
push_token: str | None
|
||||||
|
push_binding_id: str | None
|
||||||
ever_protected: bool
|
ever_protected: bool
|
||||||
liveness_state: str
|
liveness_state: str
|
||||||
last_heartbeat_at: datetime | None
|
last_heartbeat_at: datetime | None
|
||||||
@@ -43,6 +46,14 @@ class OkResponse(BaseModel):
|
|||||||
ok: bool = True
|
ok: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceUnregisterRequest(BaseModel):
|
||||||
|
device_id: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class DeferredDeviceUnregisterRequest(DeviceUnregisterRequest):
|
||||||
|
push_binding_id: str = Field(min_length=32, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
class LivenessOut(BaseModel):
|
class LivenessOut(BaseModel):
|
||||||
"""本机掉线告警状态(后置检测 pull 版)。客户端只需这一个布尔判断要不要弹「开启自启动」引导,
|
"""本机掉线告警状态(后置检测 pull 版)。客户端只需这一个布尔判断要不要弹「开启自启动」引导,
|
||||||
故只返回 kill_alert_pending(不暴露设备详情 / 内部 liveness_state 等)。从未注册过 → 默认 False(无告警)。"""
|
故只返回 kill_alert_pending(不暴露设备详情 / 内部 liveness_state 等)。从未注册过 → 默认 False(无告警)。"""
|
||||||
|
|||||||
+56
-20
@@ -1,36 +1,72 @@
|
|||||||
"""新手引导视频(领券等候浮层前 N 次替代广告)的客户端请求/响应契约。"""
|
"""客户端引导视频三阶段协议。"""
|
||||||
from __future__ import annotations
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
GuideScene = Literal["coupon", "comparison"]
|
||||||
|
|
||||||
|
|
||||||
|
class GuideVideoPrepareIn(BaseModel):
|
||||||
|
scene: GuideScene = "coupon"
|
||||||
|
|
||||||
|
|
||||||
|
class GuideVideoPrepareOut(BaseModel):
|
||||||
|
should_play: bool
|
||||||
|
reason: str
|
||||||
|
scene: GuideScene
|
||||||
|
video_url: str | None = None
|
||||||
|
play_token: str = ""
|
||||||
|
config_version: int = 0
|
||||||
|
duration_ms: int = 0
|
||||||
|
circle_count: int = 10
|
||||||
|
circle_duration_ms: float = 0
|
||||||
|
reward_coin: int = 0
|
||||||
|
reward_per_circle: int = 0
|
||||||
|
seq: int = 0
|
||||||
|
remaining: int = 0
|
||||||
|
expires_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class GuideVideoStartIn(BaseModel):
|
class GuideVideoStartIn(BaseModel):
|
||||||
"""开播询问。scene 目前只有 coupon(领券浮层);预留给日后比价等场景。"""
|
play_token: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
scene: str = Field(default="coupon", max_length=16)
|
|
||||||
|
|
||||||
|
|
||||||
class GuideVideoStartOut(BaseModel):
|
class GuideVideoStartOut(BaseModel):
|
||||||
"""should_play=False 时客户端照旧走广告链路,其余字段无意义。"""
|
started: bool
|
||||||
|
status: Literal["started", "already_started"]
|
||||||
should_play: bool
|
play_token: str
|
||||||
video_url: str | None = None # 相对地址 /media/...;客户端自行拼 BASE_URL
|
scene: GuideScene
|
||||||
play_token: str = "" # 发奖幂等键
|
video_url: str
|
||||||
reward_coin: int = 0 # 播完/中途关闭都发的固定金币
|
config_version: int
|
||||||
seq: int = 0 # 本账号第几次
|
duration_ms: int
|
||||||
remaining: int = 0 # 发完这次还剩几次
|
circle_count: int = 10
|
||||||
|
circle_duration_ms: float
|
||||||
|
reward_coin: int
|
||||||
|
reward_per_circle: int
|
||||||
|
seq: int
|
||||||
|
remaining: int
|
||||||
|
started_at: str
|
||||||
|
|
||||||
|
|
||||||
class GuideVideoRewardIn(BaseModel):
|
class GuideVideoRewardIn(BaseModel):
|
||||||
"""播完或中途关闭都调这个;completed 只做留痕,两者都发币。"""
|
|
||||||
|
|
||||||
play_token: str = Field(min_length=1, max_length=64)
|
play_token: str = Field(min_length=1, max_length=64)
|
||||||
completed: bool = False
|
circle: int = Field(ge=1, le=10)
|
||||||
|
|
||||||
|
|
||||||
class GuideVideoRewardOut(BaseModel):
|
class GuideVideoRewardOut(BaseModel):
|
||||||
"""granted=True 表示本次调用真的入账(重复上报为 False,coin 是已发金额)。"""
|
|
||||||
|
|
||||||
granted: bool
|
granted: bool
|
||||||
coin: int
|
status: Literal[
|
||||||
status: str
|
"granted",
|
||||||
|
"already_granted",
|
||||||
|
"too_early",
|
||||||
|
"out_of_order",
|
||||||
|
"not_started",
|
||||||
|
"not_found",
|
||||||
|
"finished",
|
||||||
|
]
|
||||||
|
retryable: bool = False
|
||||||
|
retry_after_ms: int = 0
|
||||||
|
circle: int
|
||||||
|
granted_coin: int = 0
|
||||||
|
settled_circles: int = 0
|
||||||
|
coin_balance: int = 0
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Persist and repair comparison-record LLM token costs."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.rewards import CN_TZ
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.app_config import AppConfig
|
||||||
|
from app.models.comparison import ComparisonRecord
|
||||||
|
from app.services.llm_cost import compute_llm_cost, get_llm_prices
|
||||||
|
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||||
|
|
||||||
|
logger = logging.getLogger("shagua.comparison_llm_backfill")
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_to_beijing_naive(value: datetime) -> datetime:
|
||||||
|
"""Convert a DB UTC timestamp to comparison_record's Beijing wall-clock."""
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=UTC)
|
||||||
|
return value.astimezone(CN_TZ).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _store_calls(record_id: int, trace_id: str, calls: list[dict]) -> bool:
|
||||||
|
"""Store calls and all derived fields atomically."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
rec = db.get(ComparisonRecord, record_id)
|
||||||
|
if rec is None or rec.trace_id != trace_id:
|
||||||
|
logger.warning(
|
||||||
|
"LLM cost backfill record mismatch record_id=%s trace=%s",
|
||||||
|
record_id,
|
||||||
|
trace_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Never recalculate a frozen historical cost with a newer price config.
|
||||||
|
if rec.llm_cost_yuan is not None and rec.llm_calls:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rec.llm_calls = calls
|
||||||
|
rec.llm_call_count = len(calls)
|
||||||
|
rec.retry_count = sum(1 for call in calls if call.get("error"))
|
||||||
|
rec.input_tokens = sum(
|
||||||
|
(call.get("usage") or {}).get("prompt_tokens") or 0 for call in calls
|
||||||
|
)
|
||||||
|
rec.output_tokens = sum(
|
||||||
|
(call.get("usage") or {}).get("completion_tokens") or 0 for call in calls
|
||||||
|
)
|
||||||
|
rec.llm_cost_yuan, rec.llm_price_snapshot = compute_llm_cost(
|
||||||
|
calls, get_llm_prices(db)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
"LLM cost backfilled trace=%s calls=%d input_tokens=%d "
|
||||||
|
"output_tokens=%d cost=%s",
|
||||||
|
trace_id,
|
||||||
|
len(calls),
|
||||||
|
rec.input_tokens,
|
||||||
|
rec.output_tokens,
|
||||||
|
rec.llm_cost_yuan,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_comparison_llm_cost(
|
||||||
|
record_id: int,
|
||||||
|
trace_id: str,
|
||||||
|
*,
|
||||||
|
attempts: int = 3,
|
||||||
|
retry_delays: tuple[float, ...] = (1.0, 3.0),
|
||||||
|
) -> bool:
|
||||||
|
"""Fetch and persist one record, retrying short-lived upstream races."""
|
||||||
|
if not settings.INTERNAL_API_SECRET or not trace_id:
|
||||||
|
logger.warning(
|
||||||
|
"LLM cost backfill skipped trace=%s: INTERNAL_API_SECRET is not configured",
|
||||||
|
trace_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
total_attempts = max(1, attempts)
|
||||||
|
for attempt in range(total_attempts):
|
||||||
|
calls = fetch_llm_calls(trace_id)
|
||||||
|
if calls:
|
||||||
|
try:
|
||||||
|
return _store_calls(record_id, trace_id, calls)
|
||||||
|
except Exception: # noqa: BLE001 - background repair must stay alive
|
||||||
|
logger.exception(
|
||||||
|
"LLM cost store failed trace=%s record_id=%s",
|
||||||
|
trace_id,
|
||||||
|
record_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if attempt + 1 < total_attempts:
|
||||||
|
delay = retry_delays[min(attempt, len(retry_delays) - 1)] if retry_delays else 0
|
||||||
|
if delay > 0:
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"LLM cost backfill has no calls trace=%s record_id=%s attempts=%d",
|
||||||
|
trace_id,
|
||||||
|
record_id,
|
||||||
|
total_attempts,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def repair_missing_comparison_llm_costs(
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
lookback_days: int = 30,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Repair a bounded batch of recent terminal records with missing cost."""
|
||||||
|
cutoff = datetime.now(CN_TZ).replace(tzinfo=None) - timedelta(
|
||||||
|
days=max(1, lookback_days)
|
||||||
|
)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
# app_config has no price history. Repricing a record from before the
|
||||||
|
# current config became effective would fabricate a historical cost, so
|
||||||
|
# only repair records at/after that timestamp.
|
||||||
|
price_config_updated_at = db.execute(
|
||||||
|
select(AppConfig.updated_at).where(AppConfig.key == "llm_token_price")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
date_conditions = [ComparisonRecord.created_at >= cutoff]
|
||||||
|
if price_config_updated_at is not None:
|
||||||
|
date_conditions.append(
|
||||||
|
ComparisonRecord.created_at
|
||||||
|
>= _utc_to_beijing_naive(price_config_updated_at)
|
||||||
|
)
|
||||||
|
candidates = list(
|
||||||
|
db.execute(
|
||||||
|
select(ComparisonRecord.id, ComparisonRecord.trace_id)
|
||||||
|
.where(
|
||||||
|
*date_conditions,
|
||||||
|
ComparisonRecord.status.in_(("success", "failed", "cancelled")),
|
||||||
|
ComparisonRecord.llm_cost_yuan.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc())
|
||||||
|
.limit(max(1, limit))
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
repaired = 0
|
||||||
|
for record_id, trace_id in candidates:
|
||||||
|
if backfill_comparison_llm_cost(
|
||||||
|
record_id, trace_id, attempts=1, retry_delays=()
|
||||||
|
):
|
||||||
|
repaired += 1
|
||||||
|
return {
|
||||||
|
"candidates": len(candidates),
|
||||||
|
"repaired": repaired,
|
||||||
|
"unresolved": len(candidates) - repaired,
|
||||||
|
}
|
||||||
@@ -83,8 +83,13 @@ def _dispatch(
|
|||||||
extra: dict[str, str] | None = None,
|
extra: dict[str, str] | None = None,
|
||||||
dedup_key: str | None = None,
|
dedup_key: str | None = None,
|
||||||
push_vars: dict[str, str] | None = None,
|
push_vars: dict[str, str] | None = None,
|
||||||
|
push_token_contains: str | None = None,
|
||||||
) -> Notification | None:
|
) -> Notification | None:
|
||||||
"""落一条站内消息并向该用户设备直推。返回落库行;去重命中/失败返回 None。"""
|
"""落一条站内消息并向该用户设备直推。返回落库行;去重命中/失败返回 None。"""
|
||||||
|
logger.info(
|
||||||
|
"notification dispatch started user_id=%s type=%s dedup_key=%s",
|
||||||
|
user_id, type_key, dedup_key,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
row = notif_repo.create_notification(
|
row = notif_repo.create_notification(
|
||||||
db,
|
db,
|
||||||
@@ -111,11 +116,20 @@ def _dispatch(
|
|||||||
logger.exception("rollback after notification failure also failed")
|
logger.exception("rollback after notification failure also failed")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_push_to_user_devices(db, row, push_vars)
|
logger.info(
|
||||||
|
"notification created user_id=%s type=%s notification_id=%s dedup_key=%s",
|
||||||
|
user_id, type_key, row.id, dedup_key,
|
||||||
|
)
|
||||||
|
_push_to_user_devices(db, row, push_vars, push_token_contains)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, str] | None) -> None:
|
def _push_to_user_devices(
|
||||||
|
db: Session,
|
||||||
|
row: Notification,
|
||||||
|
push_vars: dict[str, str] | None,
|
||||||
|
push_token_contains: str | None = None,
|
||||||
|
) -> None:
|
||||||
"""向消息归属用户的全部厂商推送目标直推(best-effort,单设备失败不影响其余)。"""
|
"""向消息归属用户的全部厂商推送目标直推(best-effort,单设备失败不影响其余)。"""
|
||||||
try:
|
try:
|
||||||
title, body = catalog.render_push(row.type, push_vars)
|
title, body = catalog.render_push(row.type, push_vars)
|
||||||
@@ -124,29 +138,103 @@ def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, s
|
|||||||
extras.update({str(k): str(v) for k, v in (row.extra or {}).items()})
|
extras.update({str(k): str(v) for k, v in (row.extra or {}).items()})
|
||||||
extras["notificationId"] = str(row.id)
|
extras["notificationId"] = str(row.id)
|
||||||
|
|
||||||
for dev in device_repo.list_push_targets(db, user_id=row.user_id):
|
targets = device_repo.list_push_targets(db, user_id=row.user_id)
|
||||||
|
if push_token_contains:
|
||||||
|
normalized_filter = push_token_contains.casefold()
|
||||||
|
targets = [
|
||||||
|
target
|
||||||
|
for target in targets
|
||||||
|
if normalized_filter in (target.push_token or "").casefold()
|
||||||
|
]
|
||||||
|
logger.info(
|
||||||
|
"push targets resolved user_id=%s type=%s notification_id=%s "
|
||||||
|
"token_filter=%s target_count=%s",
|
||||||
|
row.user_id, row.type, row.id, bool(push_token_contains), len(targets),
|
||||||
|
)
|
||||||
|
if not targets:
|
||||||
|
logger.warning(
|
||||||
|
"push skipped no targets user_id=%s type=%s notification_id=%s",
|
||||||
|
row.user_id, row.type, row.id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
sent = failed = skipped = data_sent = data_failed = 0
|
||||||
|
for dev in targets:
|
||||||
vendor = vendor_push.normalize_vendor(dev.push_vendor)
|
vendor = vendor_push.normalize_vendor(dev.push_vendor)
|
||||||
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
|
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
|
||||||
continue
|
skipped += 1
|
||||||
if vendor_push.missing_settings(vendor):
|
logger.warning(
|
||||||
# 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致)
|
"push target skipped unsupported vendor user_id=%s type=%s "
|
||||||
logger.info(
|
"notification_id=%s device_id=%s raw_vendor=%s normalized_vendor=%s",
|
||||||
"skip push (vendor %s not configured) user_id=%s type=%s",
|
row.user_id, row.type, row.id, dev.device_id, dev.push_vendor, vendor,
|
||||||
vendor, row.user_id, row.type,
|
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
missing = vendor_push.missing_settings(vendor)
|
||||||
|
if missing:
|
||||||
|
skipped += 1
|
||||||
|
# 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致)
|
||||||
|
logger.warning(
|
||||||
|
"push target skipped vendor not configured user_id=%s type=%s "
|
||||||
|
"notification_id=%s device_id=%s vendor=%s missing_settings=%s",
|
||||||
|
row.user_id, row.type, row.id, dev.device_id, vendor, missing,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
logger.info(
|
||||||
|
"push send started user_id=%s type=%s notification_id=%s "
|
||||||
|
"device_id=%s vendor=%s",
|
||||||
|
row.user_id, row.type, row.id, dev.device_id, vendor,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
vendor_push.send_notification(
|
response = vendor_push.send_notification(
|
||||||
vendor, dev.push_token, title=title, body=body, extras=extras
|
vendor, dev.push_token, title=title, body=body, extras=extras
|
||||||
)
|
)
|
||||||
|
if vendor == "huawei" and row.type in {"feedback_reply", "feedback_reward"}:
|
||||||
|
# 华为反馈推送联调日志:保留厂商返回码/requestId 等排障信息,
|
||||||
|
# 请求本身的 token、Authorization 和应用密钥不会进入 response。
|
||||||
|
logger.info(
|
||||||
|
"huawei feedback push response user_id=%s type=%s "
|
||||||
|
"notification_id=%s response=%s",
|
||||||
|
row.user_id, row.type, row.id, response,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"push sent user_id=%s type=%s vendor=%s notification_id=%s",
|
"push sent user_id=%s type=%s vendor=%s notification_id=%s device_id=%s",
|
||||||
row.user_id, row.type, vendor, row.id,
|
row.user_id, row.type, vendor, row.id, dev.device_id,
|
||||||
|
)
|
||||||
|
sent += 1
|
||||||
|
except vendor_push.VendorPushError as e:
|
||||||
|
failed += 1
|
||||||
|
logger.warning(
|
||||||
|
"push failed user_id=%s type=%s vendor=%s notification_id=%s "
|
||||||
|
"device_id=%s error=%s",
|
||||||
|
row.user_id, row.type, vendor, row.id, dev.device_id, e,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data_response = vendor_push.send_data_event(
|
||||||
|
vendor,
|
||||||
|
dev.push_token,
|
||||||
|
event=vendor_push.DATA_EVENT_NOTIFICATION_CREATED,
|
||||||
|
notification_id=str(row.id),
|
||||||
|
)
|
||||||
|
data_sent += 1
|
||||||
|
logger.info(
|
||||||
|
"push data event completed user_id=%s type=%s vendor=%s "
|
||||||
|
"notification_id=%s device_id=%s response=%s",
|
||||||
|
row.user_id, row.type, vendor, row.id, dev.device_id, data_response,
|
||||||
)
|
)
|
||||||
except vendor_push.VendorPushError as e:
|
except vendor_push.VendorPushError as e:
|
||||||
|
data_failed += 1
|
||||||
|
# 透传只负责前台铃铛实时刷新,失败不能影响通知栏消息或站内消息。
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"push failed user_id=%s type=%s vendor=%s: %s", row.user_id, row.type, vendor, e
|
"push data event failed user_id=%s type=%s vendor=%s notification_id=%s "
|
||||||
|
"device_id=%s error=%s",
|
||||||
|
row.user_id, row.type, vendor, row.id, dev.device_id, e,
|
||||||
)
|
)
|
||||||
|
logger.info(
|
||||||
|
"push dispatch completed user_id=%s type=%s notification_id=%s targets=%s "
|
||||||
|
"sent=%s failed=%s skipped=%s data_sent=%s data_failed=%s",
|
||||||
|
row.user_id, row.type, row.id, len(targets),
|
||||||
|
sent, failed, skipped, data_sent, data_failed,
|
||||||
|
)
|
||||||
except Exception: # noqa: BLE001 — 渲染/查设备等意外失败同样不外抛
|
except Exception: # noqa: BLE001 — 渲染/查设备等意外失败同样不外抛
|
||||||
logger.exception("push notification failed user_id=%s type=%s", row.user_id, row.type)
|
logger.exception("push notification failed user_id=%s type=%s", row.user_id, row.type)
|
||||||
|
|
||||||
@@ -156,7 +244,12 @@ def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, s
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def notify_withdraw_success(db: Session, order: WithdrawOrder) -> None:
|
def notify_withdraw_success(
|
||||||
|
db: Session,
|
||||||
|
order: WithdrawOrder,
|
||||||
|
*,
|
||||||
|
push_token_contains: str | None = None,
|
||||||
|
) -> None:
|
||||||
"""#3 提现成功:款项已存入微信零钱。点击无跳转仅消红点(extra 空)。"""
|
"""#3 提现成功:款项已存入微信零钱。点击无跳转仅消红点(extra 空)。"""
|
||||||
_dispatch(
|
_dispatch(
|
||||||
db,
|
db,
|
||||||
@@ -170,10 +263,16 @@ def notify_withdraw_success(db: Session, order: WithdrawOrder) -> None:
|
|||||||
extra={},
|
extra={},
|
||||||
dedup_key=order.out_bill_no,
|
dedup_key=order.out_bill_no,
|
||||||
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents)},
|
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents)},
|
||||||
|
push_token_contains=push_token_contains,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def notify_withdraw_failed(db: Session, order: WithdrawOrder) -> None:
|
def notify_withdraw_failed(
|
||||||
|
db: Session,
|
||||||
|
order: WithdrawOrder,
|
||||||
|
*,
|
||||||
|
push_token_contains: str | None = None,
|
||||||
|
) -> None:
|
||||||
"""#4 提现失败/退回:含微信侧失败、审核拒绝、解绑退回。点击跳提现页重新提现。
|
"""#4 提现失败/退回:含微信侧失败、审核拒绝、解绑退回。点击跳提现页重新提现。
|
||||||
|
|
||||||
失败原因用 order.fail_reason(与 /withdraw/status 下发的用户可读原因同源)。
|
失败原因用 order.fail_reason(与 /withdraw/status 下发的用户可读原因同源)。
|
||||||
@@ -191,10 +290,16 @@ def notify_withdraw_failed(db: Session, order: WithdrawOrder) -> None:
|
|||||||
extra={"withdrawId": order.out_bill_no},
|
extra={"withdrawId": order.out_bill_no},
|
||||||
dedup_key=order.out_bill_no,
|
dedup_key=order.out_bill_no,
|
||||||
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents), "reason": reason},
|
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents), "reason": reason},
|
||||||
|
push_token_contains=push_token_contains,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def notify_feedback_reply(db: Session, feedback: Feedback) -> None:
|
def notify_feedback_reply(
|
||||||
|
db: Session,
|
||||||
|
feedback: Feedback,
|
||||||
|
*,
|
||||||
|
push_token_contains: str | None = None,
|
||||||
|
) -> None:
|
||||||
"""#9 官方回复:运营审核了反馈且未采纳(用户可见原因/留言落在反馈记录上)。
|
"""#9 官方回复:运营审核了反馈且未采纳(用户可见原因/留言落在反馈记录上)。
|
||||||
点击跳反馈历史页滚动高亮该条(extra.feedbackId)。"""
|
点击跳反馈历史页滚动高亮该条(extra.feedbackId)。"""
|
||||||
_dispatch(
|
_dispatch(
|
||||||
@@ -204,10 +309,16 @@ def notify_feedback_reply(db: Session, feedback: Feedback) -> None:
|
|||||||
info_rows=[{"label": "说明文案", "value": "快去看看官方给您的回复吧~"}],
|
info_rows=[{"label": "说明文案", "value": "快去看看官方给您的回复吧~"}],
|
||||||
extra={"feedbackId": str(feedback.id)},
|
extra={"feedbackId": str(feedback.id)},
|
||||||
dedup_key=str(feedback.id),
|
dedup_key=str(feedback.id),
|
||||||
|
push_token_contains=push_token_contains,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def notify_feedback_reward(db: Session, feedback: Feedback) -> None:
|
def notify_feedback_reward(
|
||||||
|
db: Session,
|
||||||
|
feedback: Feedback,
|
||||||
|
*,
|
||||||
|
push_token_contains: str | None = None,
|
||||||
|
) -> None:
|
||||||
"""#10 反馈奖励:反馈被采纳,金币已到账。PRD 约定发奖必带官方留言(admin_reply);
|
"""#10 反馈奖励:反馈被采纳,金币已到账。PRD 约定发奖必带官方留言(admin_reply);
|
||||||
运营漏填时省略该信息行,不硬造文案。"""
|
运营漏填时省略该信息行,不硬造文案。"""
|
||||||
coins = int(feedback.reward_coins or 0)
|
coins = int(feedback.reward_coins or 0)
|
||||||
@@ -225,10 +336,16 @@ def notify_feedback_reward(db: Session, feedback: Feedback) -> None:
|
|||||||
extra={"feedbackId": str(feedback.id)},
|
extra={"feedbackId": str(feedback.id)},
|
||||||
dedup_key=str(feedback.id),
|
dedup_key=str(feedback.id),
|
||||||
push_vars={"coins": str(coins)},
|
push_vars={"coins": str(coins)},
|
||||||
|
push_token_contains=push_token_contains,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def notify_report_approved(db: Session, report: PriceReport) -> None:
|
def notify_report_approved(
|
||||||
|
db: Session,
|
||||||
|
report: PriceReport,
|
||||||
|
*,
|
||||||
|
push_token_contains: str | None = None,
|
||||||
|
) -> None:
|
||||||
"""#11 爆料审核通过:上报的更低价过审,金币已到账。点击跳爆料记录页高亮该条。"""
|
"""#11 爆料审核通过:上报的更低价过审,金币已到账。点击跳爆料记录页高亮该条。"""
|
||||||
coins = int(report.reward_coins or 0)
|
coins = int(report.reward_coins or 0)
|
||||||
store = (report.store_name or "").strip() or "该店铺"
|
store = (report.store_name or "").strip() or "该店铺"
|
||||||
@@ -244,11 +361,17 @@ def notify_report_approved(db: Session, report: PriceReport) -> None:
|
|||||||
extra={"reportId": str(report.id)},
|
extra={"reportId": str(report.id)},
|
||||||
dedup_key=str(report.id),
|
dedup_key=str(report.id),
|
||||||
push_vars={"store": store, "coins": str(coins)},
|
push_vars={"store": store, "coins": str(coins)},
|
||||||
|
push_token_contains=push_token_contains,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def notify_invite_order_reward(
|
def notify_invite_order_reward(
|
||||||
db: Session, *, inviter_user_id: int, invitee_user_id: int, cash_cents: int
|
db: Session,
|
||||||
|
*,
|
||||||
|
inviter_user_id: int,
|
||||||
|
invitee_user_id: int,
|
||||||
|
cash_cents: int,
|
||||||
|
push_token_contains: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""#12 好友下单到账:被邀请好友完成首次下单(比价),现金奖励已入邀请人账户。
|
"""#12 好友下单到账:被邀请好友完成首次下单(比价),现金奖励已入邀请人账户。
|
||||||
通知发给【邀请人】;每个好友只发一次奖 → dedup 按被邀请人。"""
|
通知发给【邀请人】;每个好友只发一次奖 → dedup 按被邀请人。"""
|
||||||
@@ -266,4 +389,5 @@ def notify_invite_order_reward(
|
|||||||
extra={"inviteeNickname": nickname},
|
extra={"inviteeNickname": nickname},
|
||||||
dedup_key=str(invitee_user_id),
|
dedup_key=str(invitee_user_id),
|
||||||
push_vars={"nickname": nickname, "amount": _yuan_trim(cash_cents)},
|
push_vars={"nickname": nickname, "amount": _yuan_trim(cash_cents)},
|
||||||
|
push_token_contains=push_token_contains,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,19 +20,67 @@ from app.core.pricebot_router import pick_pricebot
|
|||||||
logger = logging.getLogger("shagua.pricebot_llm")
|
logger = logging.getLogger("shagua.pricebot_llm")
|
||||||
|
|
||||||
|
|
||||||
|
def pricebot_llm_auth_ready() -> bool:
|
||||||
|
"""Verify every configured PriceBot instance accepts the shared secret."""
|
||||||
|
secret = settings.INTERNAL_API_SECRET
|
||||||
|
if not secret:
|
||||||
|
logger.error("PriceBot LLM auth check failed: INTERNAL_API_SECRET is empty")
|
||||||
|
return False
|
||||||
|
for base in settings.pricebot_instances:
|
||||||
|
url = f"{base.rstrip('/')}/api/internal/llm_calls/__auth_probe__"
|
||||||
|
try:
|
||||||
|
resp = httpx.get(
|
||||||
|
url, headers={"X-Internal-Secret": secret}, timeout=3.0
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("PriceBot LLM auth check unavailable base=%s: %s", base, exc)
|
||||||
|
return False
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
"PriceBot LLM auth check rejected base=%s status=%s; "
|
||||||
|
"verify both services use the same INTERNAL_API_SECRET",
|
||||||
|
base,
|
||||||
|
resp.status_code,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def fetch_llm_calls(trace_id: str) -> list[dict]:
|
def fetch_llm_calls(trace_id: str) -> list[dict]:
|
||||||
"""返回该次比价的 LLM 调用明细列表(每条 {scene,model,input_messages,output,usage,latency_ms,error});
|
"""返回该次比价的 LLM 调用明细列表(每条 {scene,model,input_messages,output,usage,latency_ms,error});
|
||||||
未配密钥 / 无 trace_id / 拉取失败 → []。"""
|
未配密钥 / 无 trace_id / 拉取失败 → []。"""
|
||||||
secret = settings.INTERNAL_API_SECRET
|
secret = settings.INTERNAL_API_SECRET
|
||||||
if not secret or not trace_id:
|
if not secret or not trace_id:
|
||||||
return []
|
return []
|
||||||
base = pick_pricebot(trace_id).rstrip("/")
|
preferred = pick_pricebot(trace_id)
|
||||||
url = f"{base}/api/internal/llm_calls/{trace_id}"
|
# LLM JSONL is instance-local. If the cluster topology changed after a
|
||||||
try:
|
# historical trace was created, consistent hashing may now point elsewhere;
|
||||||
resp = httpx.get(url, headers={"X-Internal-Secret": secret}, timeout=5.0)
|
# probe the remaining configured instances only when the preferred one is empty.
|
||||||
if resp.status_code == 200:
|
bases = [preferred, *(base for base in settings.pricebot_instances if base != preferred)]
|
||||||
return resp.json().get("calls", []) or []
|
for base in bases:
|
||||||
logger.warning("fetch_llm_calls trace=%s status=%s", trace_id, resp.status_code)
|
url = f"{base.rstrip('/')}/api/internal/llm_calls/{trace_id}"
|
||||||
except Exception as e: # noqa: BLE001 — best-effort,任何异常都不该影响上报
|
try:
|
||||||
logger.warning("fetch_llm_calls trace=%s failed: %s", trace_id, e)
|
resp = httpx.get(url, headers={"X-Internal-Secret": secret}, timeout=5.0)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
calls = resp.json().get("calls", []) or []
|
||||||
|
if calls:
|
||||||
|
return calls
|
||||||
|
continue
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
logger.error(
|
||||||
|
"fetch_llm_calls rejected trace=%s base=%s status=%s; "
|
||||||
|
"INTERNAL_API_SECRET differs between app-server and PriceBot",
|
||||||
|
trace_id,
|
||||||
|
base,
|
||||||
|
resp.status_code,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"fetch_llm_calls trace=%s base=%s status=%s",
|
||||||
|
trace_id,
|
||||||
|
base,
|
||||||
|
resp.status_code,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 — best-effort
|
||||||
|
logger.warning("fetch_llm_calls trace=%s base=%s failed: %s", trace_id, base, e)
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 比价 TOKEN 成本采集与补偿
|
||||||
|
|
||||||
|
## 部署前置
|
||||||
|
|
||||||
|
App Server 与 PriceBot 使用各自独立的 `.env`,但下面的值必须完全一致:
|
||||||
|
|
||||||
|
- `/opt/shaguabijia-app-server/.env`
|
||||||
|
- `/opt/pricebot-backend/.env`
|
||||||
|
- 配置项:`INTERNAL_API_SECRET`
|
||||||
|
|
||||||
|
不要把密钥原文写入日志、命令历史或 Git。修改后同时重启两个服务。
|
||||||
|
|
||||||
|
App Server 启动后会逐个探测 `PRICEBOT_INSTANCES` 的内部读取接口。鉴权不一致时会记录
|
||||||
|
`PriceBot LLM auth check rejected`,并跳过本轮补偿,避免对所有缺失记录重复发送失败请求。
|
||||||
|
|
||||||
|
## 数据链路
|
||||||
|
|
||||||
|
1. 当前客户端由 App Server 在 PriceBot 最终 `done` 帧到达时 harvest 比价记录。
|
||||||
|
2. harvest 成功后立即异步读取同一 `trace_id` 的 LLM 调用,冻结 Token、成本和单价快照。
|
||||||
|
3. 周期 worker 扫描近期 `success/failed` 且 `llm_cost_yuan IS NULL` 的记录进行补偿;
|
||||||
|
为避免用现价伪造历史成本,只处理当前单价配置生效时间之后的记录。
|
||||||
|
4. 管理后台顶部“平均 TOKEN 成本”使用筛选范围内已冻结成本的数据库平均值。
|
||||||
|
|
||||||
|
## 上线验收(只读 SQL)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
(created_at AT TIME ZONE 'Asia/Shanghai')::date AS day,
|
||||||
|
count(*) AS records,
|
||||||
|
count(llm_cost_yuan) AS cost_records,
|
||||||
|
round(avg(llm_cost_yuan)::numeric, 6) AS avg_token_cost
|
||||||
|
FROM comparison_record
|
||||||
|
WHERE created_at >= now() - interval '3 days'
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1 DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
新产生的正常终态比价记录应在短时间内写入 `input_tokens`、`output_tokens` 和
|
||||||
|
`llm_cost_yuan`。历史记录只有在 PriceBot 的对应 trace JSONL 仍保留时才能准确回填;
|
||||||
|
原始调用已经清理的记录不能用估算值冒充真实成本。
|
||||||
+10
-10
@@ -1,13 +1,13 @@
|
|||||||
# 穿山甲 GroMore 收益拉取 定时任务 — 运维手册
|
# 穿山甲 GroMore 收益拉取 定时任务 — 运维手册
|
||||||
|
|
||||||
> 对象:维护「每天拉 GroMore / ADN 收益入库」这套定时任务的同事。
|
> 对象:维护「每天拉穿山甲后台收益入库」这套定时任务的同事。
|
||||||
> 🔒 服务器登录信息见**私密交接清单**,不入库。
|
> 🔒 服务器登录信息见**私密交接清单**,不入库。
|
||||||
|
|
||||||
## 它是什么
|
## 它是什么
|
||||||
admin「广告收益报表」里的 GroMore / ADN 收益读的是**本地表 `ad_pangle_daily_revenue` 的快照,不是实时查询**。GroMore 的 T+1 初值约 10:00 可用,但第三方 ADN Reporting 数据可能到 13:50 才更新,所以需要早晚各拉一次。
|
admin「广告收益报表」里的「穿山甲后台收益(T+1)」读的是**本地表 `ad_pangle_daily_revenue` 的快照,不是实时查穿山甲**。穿山甲只通过 GroMore 数据 API 给数、且 **T+1**(次日约 10:00 出昨天的数),所以每天得拉一次入库,报表才会往前走。
|
||||||
|
|
||||||
- 每天 10:30 拉初值、14:30 拉日终值,均由 `scripts/sync_pangle_revenue.py` 以 `--days 3` 回补近 3 天。
|
- 每天 10:30 跑一轮 `scripts/sync_pangle_revenue.py`,默认 `--days 3` 回补近 3 天。
|
||||||
- 维度 = 日期 × 应用(site_id)× 广告位(ad_unit_id);指标 = `revenue`(排序价预估)+ `api_revenue`(ADN Reporting 回传,更接近结算)。
|
- 维度 = 日期 × 应用(site_id)× 广告位(ad_unit_id);指标 = `revenue`(预估)+ `api_revenue`(结算口径)。
|
||||||
- **幂等 upsert**:同一(日期×应用×代码位)重跑只覆盖、不重复,故回补 / 重跑 / catch-up 都安全。
|
- **幂等 upsert**:同一(日期×应用×代码位)重跑只覆盖、不重复,故回补 / 重跑 / catch-up 都安全。
|
||||||
- 穿山甲无用户/设备维度 → 只能落「汇总/趋势级」,报表带 user_id 过滤时这块收益置空(显示「-」)。
|
- 穿山甲无用户/设备维度 → 只能落「汇总/趋势级」,报表带 user_id 过滤时这块收益置空(显示「-」)。
|
||||||
|
|
||||||
@@ -30,15 +30,15 @@ admin「广告收益报表」里的 GroMore / ADN 收益读的是**本地表 `ad
|
|||||||
```bash
|
```bash
|
||||||
sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
|
sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
|
||||||
sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
|
sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
|
||||||
systemctl list-timers pangle-revenue.timer # 确认下次触发时间(10:30 或 14:30)
|
systemctl list-timers pangle-revenue.timer # 确认下次触发时间(应是次日 10:30)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 怎么看健康 / 手动跑一次
|
## 怎么看健康 / 手动跑一次
|
||||||
```bash
|
```bash
|
||||||
journalctl -u pangle-revenue -n 30 --no-pager # 看日志:拉取区间 / 入库行数 / 新增更新 / 收益合计
|
sudo systemctl start pangle-revenue.service # 立即手动跑一轮(不等 10:30)
|
||||||
sudo systemctl start pangle-revenue.service # 立即手动跑一轮
|
journalctl -u pangle-revenue -n 30 --no-pager # 看日志:拉取区间 / 入库行数 / 新增更新 / 预估收益合计
|
||||||
```
|
```
|
||||||
成功日志形如:`✅ 完成:接口 N 行 → 入库 M 行(跳过 x),新增 a / 更新 b;排序价预估合计 ¥19.42`。
|
成功日志形如:`✅ 完成:接口 N 行 → 入库 M 行(跳过 x),新增 a / 更新 b;预估收益合计 ¥19.42`。
|
||||||
> 看不到收益、提示 `PANGLE_REPORT_* 未配置`→ 回「上线前置」补 `.env`;报 118 → 子账号没授「查看全部数据」。
|
> 看不到收益、提示 `PANGLE_REPORT_* 未配置`→ 回「上线前置」补 `.env`;报 118 → 子账号没授「查看全部数据」。
|
||||||
|
|
||||||
## 本机 Windows 开发(无 systemd)
|
## 本机 Windows 开发(无 systemd)
|
||||||
@@ -57,11 +57,11 @@ sudo systemctl start pangle-revenue.service # 立即手动跑一轮
|
|||||||
- `--start / --end`:指定闭区间(跨度 ≤ 31 天,接口上限 1 个月,超了报 114)。
|
- `--start / --end`:指定闭区间(跨度 ≤ 31 天,接口上限 1 个月,超了报 114)。
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
- **触发时间**:10:30 提供初值,14:30 覆盖为日终值;报表只把 D+1 14:00 后同步的数据标记为日终。
|
- **触发时间**:`OnCalendar=*-*-* 10:30:00`。穿山甲 ~10:00 出数,故别早于 10:00 跑(会拉到空/不全)。
|
||||||
- **catch-up**:`Persistent=true` 补跑错过的那一轮;叠加 `--days 3`,漏一两天重新触发即自愈。
|
- **catch-up**:`Persistent=true` 补跑错过的那一轮;叠加 `--days 3`,漏一两天重新触发即自愈。
|
||||||
- **今天 / 今天以前要分开查**:脚本默认只拉昨天及更早,不混查今天(接口约束),无需关心。
|
- **今天 / 今天以前要分开查**:脚本默认只拉昨天及更早,不混查今天(接口约束),无需关心。
|
||||||
- **join key 是 `ad_unit_id`(我们配的 104xxx)不是 `code_id`**:`code_id` 是底层各 ADN 代码位,对不上口径;`ad_unit_id='-1'` 是未归因桶。改维度时务必注意(详见脚本头注释)。
|
- **join key 是 `ad_unit_id`(我们配的 104xxx)不是 `code_id`**:`code_id` 是底层各 ADN 代码位,对不上口径;`ad_unit_id='-1'` 是未归因桶。改维度时务必注意(详见脚本头注释)。
|
||||||
- **`api_revenue` 依赖 ADN Reporting 配置**:未配置的测试应用可能为空或 0;`revenue` 只是排序价估算,不能当结算收入。
|
- **`api_revenue` 很稀疏**:测试应用 ADN 没配 Reporting → 全 0,仅 prod 个别位有;`revenue`(预估)才是稳的主力。
|
||||||
- **DB 无关**:sqlite / postgres 均可(upsert 逐行 select-then-write,不像美团 ETL 需要 PG)。
|
- **DB 无关**:sqlite / postgres 均可(upsert 逐行 select-then-write,不像美团 ETL 需要 PG)。
|
||||||
- **别和别的触发方式双跑**:本 systemd timer 与「手动 cron / 进程内任务」二选一,虽幂等不会重复入库,纯属多余。
|
- **别和别的触发方式双跑**:本 systemd timer 与「手动 cron / 进程内任务」二选一,虽幂等不会重复入库,纯属多余。
|
||||||
- **改脚本 / 改部署**:走 git + PR,由有 root 的人部署。
|
- **改脚本 / 改部署**:走 git + PR,由有 root 的人部署。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# 拉 GroMore T+1 天级收益入库 —— 单轮跑,由 timer 每天 10:30、14:30 触发。
|
# 每天拉穿山甲 GroMore T+1 天级收益入库 —— 单轮跑,由 pangle-revenue.timer 每天 10:30 触发。
|
||||||
# 落 ad_pangle_daily_revenue 表,供 admin 广告收益报表的 GroMore/ADN 对账区块。
|
# 落 ad_pangle_daily_revenue 表,供 admin 广告收益报表的「穿山甲后台收益(T+1)」区块。
|
||||||
#
|
#
|
||||||
# 仅用于 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
|
# 仅用于 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
|
||||||
# .venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天(北京时间)
|
# .venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天(北京时间)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
# 每天 10:30 首次拉取、14:30 终值复拉 GroMore T+1 收益(Linux 服务器用)。
|
# 每天 10:30 触发一次穿山甲 GroMore T+1 收益拉取入库(Linux 服务器用)。
|
||||||
# 见 pangle-revenue.service 顶部注释的部署步骤。
|
# 见 pangle-revenue.service 顶部注释的部署步骤。
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Run Pangle GroMore daily revenue sync at 10:30 and 14:30
|
Description=Run Pangle GroMore daily revenue sync at 10:30
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
# 10:30 尽早展示初值;第三方 ADN Reporting 最晚约 13:50 更新,14:30 再拉一次作为日终值。
|
# 穿山甲 T+1、次日约 10:00 出数;10:30 触发留 30min 余量。要错开整点扎堆可微调到 10:35。
|
||||||
OnCalendar=*-*-* 10:30:00
|
OnCalendar=*-*-* 10:30:00
|
||||||
OnCalendar=*-*-* 14:30:00
|
|
||||||
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日);叠加 --days 3 回补,漏一两天能自愈。
|
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日);叠加 --days 3 回补,漏一两天能自愈。
|
||||||
Persistent=true
|
Persistent=true
|
||||||
AccuracySec=1min
|
AccuracySec=1min
|
||||||
|
|||||||
+4
-23
@@ -1,13 +1,9 @@
|
|||||||
# 傻瓜比价 App 后端 — API 接口文档(索引)
|
# 傻瓜比价 App 后端 — API 接口文档(索引)
|
||||||
|
|
||||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档)
|
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||||
<<<<<<< HEAD
|
|
||||||
> 最后更新:2026-07-14(新增 **消息通知中心** 3 端点(M1-M3,虚拟数据阶段)与 **厂商推送测试** 3 端点(P1-P3,荣耀/华为/小米/OPPO/vivo);上一次 2026-06-23 补全 device/internal/CPS 短链等整族端点)
|
|
||||||
=======
|
|
||||||
> 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族,及 feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03)
|
> 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族,及 feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03)
|
||||||
>>>>>>> origin/main
|
|
||||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -82,6 +78,7 @@
|
|||||||
| **签到**(前缀 `/api/v1/signin`) |||
|
| **签到**(前缀 `/api/v1/signin`) |||
|
||||||
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin/signin-status.md) |
|
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin/signin-status.md) |
|
||||||
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin/signin-do.md) |
|
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin/signin-do.md) |
|
||||||
|
| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin/signin-boost.md) |
|
||||||
| **任务**(前缀 `/api/v1/tasks`) |||
|
| **任务**(前缀 `/api/v1/tasks`) |||
|
||||||
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) |
|
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) |
|
||||||
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) |
|
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) |
|
||||||
@@ -92,7 +89,6 @@
|
|||||||
| **看广告发奖**(前缀 `/api/v1/ad`) |||
|
| **看广告发奖**(前缀 `/api/v1/ad`) |||
|
||||||
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad/ad-pangle-callback.md) |
|
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad/ad-pangle-callback.md) |
|
||||||
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad/ad-reward-status.md) |
|
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad/ad-reward-status.md) |
|
||||||
| 33a | `GET /api/v1/ad/reward-result/{ad_session_id}` | Bearer | [详情](./ad/ad-reward-result.md)(本次实发金币 + 本轮膨胀累计 `round_coin`,弹窗数字用它) |
|
|
||||||
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad/ad-test-grant.md) |
|
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad/ad-test-grant.md) |
|
||||||
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad/ad-ecpm-report.md) |
|
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad/ad-ecpm-report.md) |
|
||||||
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad/ad-feed-reward.md) |
|
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad/ad-feed-reward.md) |
|
||||||
@@ -107,33 +103,19 @@
|
|||||||
| 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114) |
|
| 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114) |
|
||||||
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
|
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
|
||||||
| **帮助与反馈**(前缀 `/api/v1/feedback`) |||
|
| **帮助与反馈**(前缀 `/api/v1/feedback`) |||
|
||||||
<<<<<<< HEAD
|
|
||||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
|
|
||||||
| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) |
|
|
||||||
| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected)(无单独文档) |
|
|
||||||
| **消息通知中心**(前缀 `/api/v1/notifications`;⚠️ 本族对外 **camelCase**;虚拟数据阶段:内存 mock,重启复位) |||
|
|
||||||
| M1 | `GET /api/v1/notifications` | Bearer | [详情](./notifications.md)(消息列表,分页;13 类型卡片字段 + sentAt/isRead;服务端已按时间倒序排好,不分组) |
|
|
||||||
| M2 | `GET /api/v1/notifications/unread-count` | Bearer | [详情](./notifications.md)(未读总数,首页铃铛角标;>99 → "99+",0 → null 隐藏) |
|
|
||||||
| M3 | `POST /api/v1/notifications/read` | Bearer | [详情](./notifications.md)(标记已读:`{ids:[...]}` 单条/多条 或 `{all:true}` 进通知中心全量清零;幂等) |
|
|
||||||
| **厂商推送测试**(前缀 `/api/v1/push`;荣耀/华为/小米/OPPO/vivo 五通道联调三件套,同为 camelCase) |||
|
|
||||||
| P1 | `GET /api/v1/push/vendors` | Bearer | [详情](./push-vendor-test.md)(5 厂商服务端凭据配置状态,缺哪些 .env 键一目了然) |
|
|
||||||
| P2 | `GET /api/v1/push/templates` | Bearer | [详情](./push-vendor-test.md)(13 类通知的 push 标题/正文模板 + PRD 示例渲染效果) |
|
|
||||||
| P3 | `POST /api/v1/push/test` | Bearer | [详情](./push-vendor-test.md)(测试发送:默认 mock 不真发;mock=false 真发;可联动插一条站内 mock 通知闭环验证已读) |
|
|
||||||
=======
|
|
||||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
|
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
|
||||||
| 38a | `GET /api/v1/feedback/config` | Bearer | [详情](./other/feedback-config.md)(反馈页「加群二维码」卡配置:开关+二维码图+三行文案) |
|
| 38a | `GET /api/v1/feedback/config` | Bearer | [详情](./other/feedback-config.md)(反馈页「加群二维码」卡配置:开关+二维码图+三行文案) |
|
||||||
| 38b | `GET /api/v1/feedback/records` | Bearer | [详情](./other/feedback-records.md)(我的反馈历史,pending/adopted/rejected) |
|
| 38b | `GET /api/v1/feedback/records` | Bearer | [详情](./other/feedback-records.md)(我的反馈历史,pending/adopted/rejected) |
|
||||||
| **埋点 & 订单上报**(前缀分散;全部 Bearer 除 analytics/events 不强制登录) |||
|
| **埋点 & 订单上报 / 客户端日志**(前缀分散;全部 Bearer 除 analytics/events、applog/batch 不强制登录) |||
|
||||||
| E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) |
|
| E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) |
|
||||||
| E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) |
|
| E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) |
|
||||||
>>>>>>> origin/main
|
| E3 | `POST /api/v1/applog/batch` | 无 | [详情](./other/applog-batch.md)(批量上报客户端运行日志,逐条落独立文件供 Logtail 采进 SLS,不强制登录,每批≤500条) |
|
||||||
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
|
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
|
||||||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) |
|
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) |
|
||||||
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) |
|
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) |
|
||||||
| 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) |
|
| 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) |
|
||||||
| 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) |
|
| 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) |
|
||||||
| 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) |
|
| 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) |
|
||||||
| 40d | `GET /api/v1/platform/huawei-review` | 无 | [详情](./platform/platform-huawei-review.md)(华为审核开关:快速设置权限步能否被用户关闭;仅华为 ROM 客户端拉) |
|
|
||||||
| **微信支付回调**(前缀 `/api/v1/wxpay`) |||
|
| **微信支付回调**(前缀 `/api/v1/wxpay`) |||
|
||||||
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
|
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
|
||||||
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
|
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
|
||||||
@@ -172,7 +154,6 @@
|
|||||||
| A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) |
|
| A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) |
|
||||||
| A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) |
|
| A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) |
|
||||||
| A14 | `GET /admin/api/config`、`PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) |
|
| A14 | `GET /admin/api/config`、`PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) |
|
||||||
| A16 | `GET / PATCH /admin/api/huawei-review` | operator/tech | 华为审核开关(快速设置权限步能否被用户关闭,落 `app_config.huawei_review`;C 端只读版见 40d)(无单独文档,见 `app/admin/routers/huawei_review.py`) |
|
|
||||||
| **A·管理员与角色**(super_admin):`GET`/`POST` `/admins`、`PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles`、`GET /roles/catalog`、`PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [建](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) |
|
| **A·管理员与角色**(super_admin):`GET`/`POST` `/admins`、`PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles`、`GET /roles/catalog`、`PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [建](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) |
|
||||||
| A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
|
| A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
|
||||||
| **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links`、`POST /orders/reconcile`(美团+京东 #90)、`GET /orders`、`/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) |
|
| **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links`、`POST /orders/reconcile`(美团+京东 #90)、`GET /orders`、`/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) |
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# POST /api/v1/applog/batch — 批量上报客户端运行日志
|
||||||
|
|
||||||
|
> 所属:客户端日志组(前缀 `/api/v1/applog`) | 鉴权:无(不强制登录,`user_id` 可选带上) | [← 返回 API 索引](../README.md)
|
||||||
|
|
||||||
|
批量接收客户端 App 运行日志(自动化步骤 / 网络 / 崩溃 / 调试等),**逐条**封装成单行 JSON 写入独立滚动文件 `logs/app-client.log`(与服务日志 `app-server.log` 分开),由阿里云 Logtail 采进**独立 SLS logstore**,**不落库**。服务端补 `client_ip`(X-Forwarded-For)与 `time`(接收时间,SLS 主时间)。`trace_id` 提到输出行顶层,便于在 SLS 里跨「客户端 / 服务端」两个 logstore 按 trace 拼出端到端链路。**fire-and-forget**:写失败也返回 2xx,不 500。
|
||||||
|
|
||||||
|
## 入参
|
||||||
|
|
||||||
|
批级公共字段发一次;`logs` 里每条只带日志本身。**每条只有 `client_ts/level/trace_id/tag/msg` 会提到输出顶层,其余自定义字段一律并入输出的 `data`**(防 SLS 索引列爆炸)。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `device_id` | string | ✅(≤64) | 设备 ID(限流 / 分组键) |
|
||||||
|
| `user_id` | int \| null | ❌ | 登录用户 ID(未登录可空) |
|
||||||
|
| `app_ver` | string \| null | ❌(≤32) | App 版本 |
|
||||||
|
| `platform` | string \| null | ❌(≤16) | 平台(`android` / `ios` / `harmony`) |
|
||||||
|
| `sent_at` | int \| null | ❌ | 批次发送时间(epoch ms) |
|
||||||
|
| `logs` | list[object] | ✅(1-500 条) | 日志数组(每条为对象,内部字段**不强类型**) |
|
||||||
|
| `logs[].client_ts` | int | ❌ | 端侧日志时间(epoch ms) |
|
||||||
|
| `logs[].level` | string | ❌ | 级别(服务端归一化大写,截断 ≤16) |
|
||||||
|
| `logs[].trace_id` | string \| null | ❌ | 关联服务端比价链路的 trace(有服务端交互的日志带上,截断 ≤256) |
|
||||||
|
| `logs[].tag` | string \| null | ❌ | 模块 / 分类(截断 ≤128) |
|
||||||
|
| `logs[].msg` | string | ❌ | 消息主体(超 8KB **字节**截断,加 `msg_truncated` 标记) |
|
||||||
|
| `logs[].*` | any | ❌ | 其余任意自定义字段 → 一律并入输出的 `data`(客户端自带的 `data` 对象会被合并进来) |
|
||||||
|
|
||||||
|
Mock 入参:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "android_abc123def456",
|
||||||
|
"user_id": 42,
|
||||||
|
"app_ver": "0.1.5",
|
||||||
|
"platform": "android",
|
||||||
|
"sent_at": 1719993700000,
|
||||||
|
"logs": [
|
||||||
|
{
|
||||||
|
"client_ts": 1719993600000,
|
||||||
|
"level": "info",
|
||||||
|
"tag": "automation",
|
||||||
|
"msg": "compare flow start",
|
||||||
|
"trace_id": "t_ab12cd34"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_ts": 1719993615000,
|
||||||
|
"level": "error",
|
||||||
|
"tag": "network",
|
||||||
|
"msg": "timeout calling /price/step",
|
||||||
|
"trace_id": "t_ab12cd34",
|
||||||
|
"http_status": 504,
|
||||||
|
"retry": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> 上例第二条的 `http_status` / `retry` 不在白名单 → 会被并入落盘行的 `data`:`{"http_status":504,"retry":2}`。
|
||||||
|
|
||||||
|
## 出参
|
||||||
|
|
||||||
|
响应 `200`:`AppLogIngestOut`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ok` | bool | 固定 `true` |
|
||||||
|
| `received` | int | 成功写入文件的条数 |
|
||||||
|
| `dropped` | int | 跳过的条数(服务端处理 / 序列化失败;正常为 `0`) |
|
||||||
|
|
||||||
|
Mock 出参:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"received": 2,
|
||||||
|
"dropped": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误码
|
||||||
|
- `413` 请求体超过上限(默认 2MB;服务端查 `Content-Length`,在 body 校验前拦截。缺该头时由 nginx `client_max_body_size` 兜底)
|
||||||
|
- `422` `logs` 为空或超过 500 条 / `device_id` 缺失 / 字段类型不符
|
||||||
|
- `429` 触发限流(同 IP 每分钟 > 120 次)
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
- **落盘 → SLS**:逐条写独立文件 `logs/app-client.log`(单行 JSON,`propagate=False` 不污染 `app-server.log`),由 Logtail JSON 模式采进**独立 logstore**;不进数据库。落盘行除白名单字段外,服务端另补 `time`(接收时间)、`source="client"`、`service`、`client_ip` 及批级 `device_id/user_id/app_ver/platform/sent_at`。
|
||||||
|
- **`trace_id` 跨层检索**:字段名与服务端日志一致。客户端应给**有服务端交互**的日志带上当初 API 返回的 `trace_id`(如 `/api/v1/price/step` 等透传族返回的 trace),即可在 SLS 里 `trace_id: "xxx"` 一查拼出「客户端视角 + 服务端比价链路」;纯客户端日志不带即可。
|
||||||
|
- **白名单 + `data` 兜底**:只有 `client_ts/level/trace_id/tag/msg` 上顶层,其余键并入 `data`,把 SLS 索引列钉死在固定集合,防客户端任意 key 撑爆索引。索引字段另有长度上限(`level`≤16 / `trace_id`≤256 / `tag`≤128 / `msg`≤8KB)。
|
||||||
|
- **fire-and-forget**:写文件失败也返回 2xx(避免客户端重试风暴);网络重试可能在 SLS 造成重复条目,可接受。
|
||||||
|
- **量级建议**:客户端做等级过滤 / 采样,攒到一定量再批量上报;单批 ≤500 条、body ≤2MB。
|
||||||
|
- `client_ts` 是端侧时间(客户端时钟不可信),`time` 由服务端补(可靠时间轴)。
|
||||||
|
- `user_id` 不靠 JWT:未登录态也要采日志;带上便于按用户排查。
|
||||||
@@ -87,6 +87,7 @@ PRD §5 的 13 条 push 文案(标题固定 ≤11 字不带变量;正文 `{var}`
|
|||||||
|
|
||||||
**真发注意**:
|
**真发注意**:
|
||||||
- 目标手机必须先装 App 且客户端已集成对应厂商 SDK、`/device/register` 上报过 token;
|
- 目标手机必须先装 App 且客户端已集成对应厂商 SDK、`/device/register` 上报过 token;
|
||||||
|
- 荣耀正式环境必须使用正式消息(`HONOR_PUSH_TARGET_USER_TYPE=0`);开发联调测试设备时才设为 `1`;
|
||||||
- vivo 未上架前走测试推送(`VIVO_PUSH_MODE=1`),目标手机需在 vivo 开放平台加入测试设备;
|
- vivo 未上架前走测试推送(`VIVO_PUSH_MODE=1`),目标手机需在 vivo 开放平台加入测试设备;
|
||||||
- 小米新设备需在开放平台把签名/包名配好,token 才有效。
|
- 小米新设备需在开放平台把签名/包名配好,token 才有效。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
CheckSmsVerifyCode - 核验验证码
|
||||||
|
更新时间:2026年3月19日 20:02:53
|
||||||
|
核验短信验证码并返回核验是否成功的结果。
|
||||||
|
|
||||||
|
调试
|
||||||
|
您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。
|
||||||
|
|
||||||
|
调试
|
||||||
|
授权信息
|
||||||
|
下表是API对应的授权信息,可以在RAM权限策略语句的Action元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
|
||||||
|
|
||||||
|
操作:是指具体的权限点。
|
||||||
|
|
||||||
|
访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
|
||||||
|
|
||||||
|
资源类型:是指操作中支持授权的资源类型。具体说明如下:
|
||||||
|
|
||||||
|
对于必选的资源类型,用前面加 * 表示。
|
||||||
|
|
||||||
|
对于不支持资源级授权的操作,用全部资源表示。
|
||||||
|
|
||||||
|
条件关键字:是指云产品自身定义的条件关键字。
|
||||||
|
|
||||||
|
关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
|
||||||
|
|
||||||
|
放大查看
|
||||||
|
操作
|
||||||
|
|
||||||
|
访问级别
|
||||||
|
|
||||||
|
资源类型
|
||||||
|
|
||||||
|
条件关键字
|
||||||
|
|
||||||
|
关联操作
|
||||||
|
|
||||||
|
dypns:CheckSmsVerifyCode
|
||||||
|
|
||||||
|
none
|
||||||
|
|
||||||
|
*全部资源
|
||||||
|
|
||||||
|
*
|
||||||
|
|
||||||
|
无 无
|
||||||
|
请求参数
|
||||||
|
放大查看
|
||||||
|
名称
|
||||||
|
|
||||||
|
类型
|
||||||
|
|
||||||
|
必填
|
||||||
|
|
||||||
|
描述
|
||||||
|
|
||||||
|
示例值
|
||||||
|
|
||||||
|
SchemeName
|
||||||
|
string
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
方案名称,如果不填则为“默认方案”。最多不超过 20 个字符。
|
||||||
|
|
||||||
|
重要 如果发送接口的方案名称不为空,请确保该参数不为空且与发送接口的方案名称参数一致
|
||||||
|
测试方案
|
||||||
|
|
||||||
|
CountryCode
|
||||||
|
string
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
号码国家编码,默认为 86。
|
||||||
|
|
||||||
|
86
|
||||||
|
|
||||||
|
PhoneNumber
|
||||||
|
string
|
||||||
|
|
||||||
|
是
|
||||||
|
|
||||||
|
手机号。
|
||||||
|
|
||||||
|
186****0000
|
||||||
|
|
||||||
|
OutId
|
||||||
|
string
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
外部流水号。
|
||||||
|
|
||||||
|
12123231
|
||||||
|
|
||||||
|
VerifyCode
|
||||||
|
string
|
||||||
|
|
||||||
|
是
|
||||||
|
|
||||||
|
验证码。
|
||||||
|
|
||||||
|
说明
|
||||||
|
SendSmsVerifyCode 接口的字段 TemplateParam,配置方式有 2 种:
|
||||||
|
|
||||||
|
{"code":"##code##","min":"5"}
|
||||||
|
|
||||||
|
{"code":"123456","min":"5"}
|
||||||
|
|
||||||
|
{"code":"##code##","min":"5"}验证码是 api 动态生成的,阿里云接口可以完成校验。
|
||||||
|
|
||||||
|
{"code":"123456","min":"5"}验证码是用户配置的不是 api 动态生成,阿里云接口无法校验。
|
||||||
|
|
||||||
|
请您按照实际情况传入对应的验证码。
|
||||||
|
|
||||||
|
1231
|
||||||
|
|
||||||
|
CaseAuthPolicy
|
||||||
|
integer
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
验证码大小写字母核验策略。取值:
|
||||||
|
|
||||||
|
1:不区分大小写。
|
||||||
|
|
||||||
|
2:区分大小写。
|
||||||
|
|
||||||
|
1
|
||||||
|
|
||||||
|
返回参数
|
||||||
|
放大查看
|
||||||
|
名称
|
||||||
|
|
||||||
|
类型
|
||||||
|
|
||||||
|
描述
|
||||||
|
|
||||||
|
示例值
|
||||||
|
|
||||||
|
object
|
||||||
|
|
||||||
|
AccessDeniedDetail
|
||||||
|
string
|
||||||
|
|
||||||
|
访问被拒绝详细信息。
|
||||||
|
|
||||||
|
无
|
||||||
|
|
||||||
|
Message
|
||||||
|
string
|
||||||
|
|
||||||
|
状态码的描述。
|
||||||
|
|
||||||
|
成功
|
||||||
|
|
||||||
|
Model
|
||||||
|
object
|
||||||
|
|
||||||
|
请求结果数据。
|
||||||
|
|
||||||
|
OutId
|
||||||
|
string
|
||||||
|
|
||||||
|
外部流水号。
|
||||||
|
|
||||||
|
1212312
|
||||||
|
|
||||||
|
VerifyResult
|
||||||
|
string
|
||||||
|
|
||||||
|
短信验证码核验结果。取值:
|
||||||
|
|
||||||
|
PASS:短信验证码核验成功。
|
||||||
|
|
||||||
|
UNKNOWN:短信验证码核验失败。
|
||||||
|
|
||||||
|
PASS
|
||||||
|
|
||||||
|
Code
|
||||||
|
string
|
||||||
|
|
||||||
|
接口请求状态码。
|
||||||
|
|
||||||
|
返回 OK 代表请求成功。
|
||||||
|
|
||||||
|
其他错误码,请参见返回码。
|
||||||
|
|
||||||
|
重要 接口请求成功不代表短信验证码核验成功,短信验证码核验结果仅以Model.VerifyResult参数返回值为准。
|
||||||
|
OK
|
||||||
|
|
||||||
|
Success
|
||||||
|
boolean
|
||||||
|
|
||||||
|
接口调用是否成功。取值:
|
||||||
|
|
||||||
|
true:接口调用成功。
|
||||||
|
|
||||||
|
false:接口调用失败。
|
||||||
|
|
||||||
|
重要 接口调用成功不代表短信验证码核验成功,短信验证码核验结果仅以Model.VerifyResult参数返回值为准。
|
||||||
|
true
|
||||||
|
|
||||||
|
RequestId
|
||||||
|
string
|
||||||
|
|
||||||
|
CF8854E5-DB21-3E5D-A9B1-DDC752FD7384
|
||||||
|
|
||||||
|
示例
|
||||||
|
正常返回示例
|
||||||
|
|
||||||
|
JSON格式
|
||||||
|
|
||||||
|
放大查看复制代码
|
||||||
|
{
|
||||||
|
"AccessDeniedDetail": "无",
|
||||||
|
"Message": "成功",
|
||||||
|
"Model": {
|
||||||
|
"OutId": "1212312",
|
||||||
|
"VerifyResult": "PASS"
|
||||||
|
},
|
||||||
|
"Code": "OK",
|
||||||
|
"Success": true,
|
||||||
|
"RequestId": "CF8854E5-DB21-3E5D-A9B1-DDC752FD7384"
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
SendSmsVerifyCode - 发送短信验证码
|
||||||
|
更新时间:2026年7月3日 09:54:53
|
||||||
|
发送短信验证码。
|
||||||
|
|
||||||
|
接口说明
|
||||||
|
由于运营商近期加强对短信签名的管控。您自定义的签名面临下发失败问题,推荐您使用号码认证控制台赠送的短信签名和模板进行短信认证。系统赠送签名必须搭配系统赠送模板使用。
|
||||||
|
|
||||||
|
请确保在使用该接口前,已充分了解号码认证服务产品的收费方式和价格,短信认证服务仅收取短信发送费用(按运营商回执状态计费,短信提交成功但运营商回执失败时不计费),核验服务免费。
|
||||||
|
|
||||||
|
调试
|
||||||
|
您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。
|
||||||
|
|
||||||
|
调试
|
||||||
|
授权信息
|
||||||
|
下表是API对应的授权信息,可以在RAM权限策略语句的Action元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
|
||||||
|
|
||||||
|
操作:是指具体的权限点。
|
||||||
|
|
||||||
|
访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
|
||||||
|
|
||||||
|
资源类型:是指操作中支持授权的资源类型。具体说明如下:
|
||||||
|
|
||||||
|
对于必选的资源类型,用前面加 * 表示。
|
||||||
|
|
||||||
|
对于不支持资源级授权的操作,用全部资源表示。
|
||||||
|
|
||||||
|
条件关键字:是指云产品自身定义的条件关键字。
|
||||||
|
|
||||||
|
关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
|
||||||
|
|
||||||
|
放大查看
|
||||||
|
操作
|
||||||
|
|
||||||
|
访问级别
|
||||||
|
|
||||||
|
资源类型
|
||||||
|
|
||||||
|
条件关键字
|
||||||
|
|
||||||
|
关联操作
|
||||||
|
|
||||||
|
dypns:SendSmsVerifyCode
|
||||||
|
|
||||||
|
create
|
||||||
|
|
||||||
|
*全部资源
|
||||||
|
|
||||||
|
*
|
||||||
|
|
||||||
|
无 无
|
||||||
|
请求参数
|
||||||
|
放大查看
|
||||||
|
名称
|
||||||
|
|
||||||
|
类型
|
||||||
|
|
||||||
|
必填
|
||||||
|
|
||||||
|
描述
|
||||||
|
|
||||||
|
示例值
|
||||||
|
|
||||||
|
SchemeName
|
||||||
|
string
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
方案名称,如果不填则为“默认方案”。最多不超过 20 个字符。
|
||||||
|
|
||||||
|
测试方案
|
||||||
|
|
||||||
|
CountryCode
|
||||||
|
string
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
号码国家编码。默认为 86,目前也仅支持国内号码发送。
|
||||||
|
|
||||||
|
86
|
||||||
|
|
||||||
|
PhoneNumber
|
||||||
|
string
|
||||||
|
|
||||||
|
是
|
||||||
|
|
||||||
|
短信接收方手机号。
|
||||||
|
|
||||||
|
130****0000
|
||||||
|
|
||||||
|
SignName
|
||||||
|
string
|
||||||
|
|
||||||
|
是
|
||||||
|
|
||||||
|
签名名称。暂不支持使用自定义签名,请使用系统赠送的签名,您可在赠送签名配置页面选择需要下发的签名。
|
||||||
|
|
||||||
|
恒创联众
|
||||||
|
|
||||||
|
TemplateCode
|
||||||
|
string
|
||||||
|
|
||||||
|
是
|
||||||
|
|
||||||
|
短信模板 CODE。参数SignName选择赠送签名时,必须搭配赠送模板下发短信。您可在赠送模板配置页面选择适用您业务场景的模板。
|
||||||
|
|
||||||
|
100001
|
||||||
|
|
||||||
|
TemplateParam
|
||||||
|
string
|
||||||
|
|
||||||
|
是
|
||||||
|
|
||||||
|
短信模板参数。验证码位置有两种传值方式:
|
||||||
|
|
||||||
|
可使用"##code##"替代,由参数 CodeType 指定验证码生成规则;
|
||||||
|
|
||||||
|
也可直接传入具体的验证码值,直接下发至接收方。
|
||||||
|
|
||||||
|
示例:如模板内容为:“您的验证码是${code},有效期${min}分钟,请勿告诉他人。”。
|
||||||
|
|
||||||
|
重要 上文中的 code 请替换成您实际申请的验证码模板中的参数名称
|
||||||
|
该字段可传入{"code":"##code##","min":"5"}由系统根据规则生成验证码;
|
||||||
|
|
||||||
|
或直接传入指定的验证码值{"code":"123456","min":"5"}。
|
||||||
|
|
||||||
|
说明
|
||||||
|
{"code":"##code##","min":"5"}验证码是 api 动态生成的,阿里云接口可以完成校验。
|
||||||
|
|
||||||
|
{"code":"123456","min":"5"}验证码是用户配置的不是 api 动态生成,阿里云接口无法校验。
|
||||||
|
|
||||||
|
说明
|
||||||
|
如果 JSON 中需要带换行符,请参照标准的 JSON 协议处理。
|
||||||
|
|
||||||
|
模板变量规范,请参见短信模板规范。
|
||||||
|
|
||||||
|
{"code":"##code##","min":"5"}
|
||||||
|
|
||||||
|
SmsUpExtendCode
|
||||||
|
string
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
上行短信扩展码。上行短信指发送给通信服务提供商的短信,用于定制某种服务、完成查询,或是办理某种业务等,需要收费,按运营商普通短信资费进行扣费。
|
||||||
|
|
||||||
|
说明
|
||||||
|
扩展码是生成签名时系统自动默认生成的,不支持自行传入。无特殊需要此字段的用户请忽略此字段。如需使用,请联系您的商务经理。
|
||||||
|
|
||||||
|
1213123
|
||||||
|
|
||||||
|
OutId
|
||||||
|
string
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
外部流水号。
|
||||||
|
|
||||||
|
外部流水号(透传)
|
||||||
|
|
||||||
|
CodeLength
|
||||||
|
integer
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
验证码长度支持 4~8 位长度,默认是 4 位。
|
||||||
|
|
||||||
|
4
|
||||||
|
|
||||||
|
ValidTime
|
||||||
|
integer
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
验证码有效时长,单位秒,默认为 300 秒。
|
||||||
|
|
||||||
|
300
|
||||||
|
|
||||||
|
DuplicatePolicy
|
||||||
|
integer
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
核验规则,当有效时间内对同场景内的同号码重复发送验证码时,旧验证码如何处理。
|
||||||
|
|
||||||
|
1:覆盖处理(默认),即旧验证码会失效掉。
|
||||||
|
|
||||||
|
2:保留,即多个验证码都是在有效期内都可以校验通过。
|
||||||
|
|
||||||
|
枚举值:
|
||||||
|
|
||||||
|
1 :
|
||||||
|
覆盖
|
||||||
|
|
||||||
|
2 :
|
||||||
|
保留
|
||||||
|
|
||||||
|
1
|
||||||
|
|
||||||
|
Interval
|
||||||
|
integer
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
时间间隔,单位:秒。即多久间隔可以发送一次验证码,用于频控,默认 60 秒。
|
||||||
|
|
||||||
|
60
|
||||||
|
|
||||||
|
CodeType
|
||||||
|
integer
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
生成的验证码类型。当参数 TemplateParam 传入占位符时,此参数必填,将由系统根据指定的规则生成验证码。取值:
|
||||||
|
|
||||||
|
1:纯数字(默认)。
|
||||||
|
|
||||||
|
2:纯大写字母。
|
||||||
|
|
||||||
|
3:纯小写字母。
|
||||||
|
|
||||||
|
4:大小字母混合。
|
||||||
|
|
||||||
|
5:数字+大写字母混合。
|
||||||
|
|
||||||
|
6:数字+小写字母混合。
|
||||||
|
|
||||||
|
7:数字+大小写字母混合。
|
||||||
|
|
||||||
|
枚举值:
|
||||||
|
|
||||||
|
1 :
|
||||||
|
纯数字
|
||||||
|
|
||||||
|
2 :
|
||||||
|
纯大写字母
|
||||||
|
|
||||||
|
3 :
|
||||||
|
纯小写字母
|
||||||
|
|
||||||
|
4 :
|
||||||
|
大小字母混合
|
||||||
|
|
||||||
|
5 :
|
||||||
|
数字+大写字母混合
|
||||||
|
|
||||||
|
6 :
|
||||||
|
数字+小写字母混合
|
||||||
|
|
||||||
|
7 :
|
||||||
|
数字+大小写字母混合
|
||||||
|
|
||||||
|
1
|
||||||
|
|
||||||
|
ReturnVerifyCode
|
||||||
|
boolean
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
是否返回验证码。取值:
|
||||||
|
|
||||||
|
true:返回。
|
||||||
|
|
||||||
|
false:不返回。
|
||||||
|
|
||||||
|
true
|
||||||
|
|
||||||
|
AutoRetry
|
||||||
|
integer
|
||||||
|
|
||||||
|
否
|
||||||
|
|
||||||
|
是否自动替换签名重试(默认开启),可取值:
|
||||||
|
|
||||||
|
1 开启自动重试功能,开启后,在验证码有效期内,当运营商返回明确的失败状态时,允许阿里云尽可能的尝试使用其他方式发送验证码,以提升发送成功率。其他方式包括且不限于:通过其他运营商重试、更换签名重试等
|
||||||
|
|
||||||
|
0 不开启自动重试
|
||||||
|
|
||||||
|
是否自动重试
|
||||||
|
|
||||||
|
返回参数
|
||||||
|
放大查看
|
||||||
|
名称
|
||||||
|
|
||||||
|
类型
|
||||||
|
|
||||||
|
描述
|
||||||
|
|
||||||
|
示例值
|
||||||
|
|
||||||
|
object
|
||||||
|
|
||||||
|
AccessDeniedDetail
|
||||||
|
string
|
||||||
|
|
||||||
|
访问被拒绝详细信息。
|
||||||
|
|
||||||
|
无
|
||||||
|
|
||||||
|
Message
|
||||||
|
string
|
||||||
|
|
||||||
|
状态码的描述。
|
||||||
|
|
||||||
|
成功
|
||||||
|
|
||||||
|
RequestId
|
||||||
|
string
|
||||||
|
|
||||||
|
请求 ID。
|
||||||
|
|
||||||
|
CC3BB6D2-2FDF-4321-9DCE-B38165CE4C47
|
||||||
|
|
||||||
|
Model
|
||||||
|
object
|
||||||
|
|
||||||
|
请求结果数据。
|
||||||
|
|
||||||
|
VerifyCode
|
||||||
|
string
|
||||||
|
|
||||||
|
验证码。
|
||||||
|
|
||||||
|
4232
|
||||||
|
|
||||||
|
RequestId
|
||||||
|
string
|
||||||
|
|
||||||
|
请求 ID。
|
||||||
|
|
||||||
|
a3671ccf-0102-4c8e-8797-a3678e091d09
|
||||||
|
|
||||||
|
OutId
|
||||||
|
string
|
||||||
|
|
||||||
|
外部流水号。
|
||||||
|
|
||||||
|
1231231313
|
||||||
|
|
||||||
|
BizId
|
||||||
|
string
|
||||||
|
|
||||||
|
业务 ID。
|
||||||
|
|
||||||
|
112231421412414124123^4
|
||||||
|
|
||||||
|
Code
|
||||||
|
string
|
||||||
|
|
||||||
|
请求状态码。返回 OK 代表请求成功。其他错误码,请参见返回码列表。
|
||||||
|
|
||||||
|
OK
|
||||||
|
|
||||||
|
Success
|
||||||
|
boolean
|
||||||
|
|
||||||
|
请求是否成功。
|
||||||
|
|
||||||
|
true:请求成功。
|
||||||
|
|
||||||
|
false:请求失败。
|
||||||
|
|
||||||
|
true
|
||||||
|
|
||||||
|
示例
|
||||||
|
正常返回示例
|
||||||
|
|
||||||
|
JSON格式
|
||||||
|
|
||||||
|
放大查看复制代码
|
||||||
|
{
|
||||||
|
"AccessDeniedDetail": "无",
|
||||||
|
"Message": "成功 ",
|
||||||
|
"RequestId": "CC3BB6D2-2FDF-4321-9DCE-B38165CE4C47",
|
||||||
|
"Model": {
|
||||||
|
"VerifyCode": "4232",
|
||||||
|
"RequestId": "a3671ccf-0102-4c8e-8797-a3678e091d09",
|
||||||
|
"OutId": "1231231313",
|
||||||
|
"BizId": "112231421412414124123^4"
|
||||||
|
},
|
||||||
|
"Code": "OK",
|
||||||
|
"Success": true
|
||||||
|
}
|
||||||
|
错误码
|
||||||
|
放大查看
|
||||||
|
HTTP status code
|
||||||
|
|
||||||
|
错误码
|
||||||
|
|
||||||
|
错误信息
|
||||||
|
|
||||||
|
描述
|
||||||
|
|
||||||
|
400 MOBILE_NUMBER_ILLEGAL The mobile number is illegal. 手机号码格式错误
|
||||||
|
400 BUSINESS_LIMIT_CONTROL The number has exceeded the limit for the day. 触发号码天级流控
|
||||||
|
400 FREQUENCY_FAIL Check frequency fail. 频控校验未通过
|
||||||
|
400 INVALID_PARAMETERS parameter is not valid. 非法参数
|
||||||
|
400 FUNCTION_NOT_OPENED You have not opened this function. 没有开通融合认证功能
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# 创蓝云智(253/蓝创云智)模板短信 v2 发送接口
|
||||||
|
|
||||||
|
> 官方文档:<https://doc.chuanglan.com/document/HAQYSZKH9HT5Z50L>
|
||||||
|
> 用途:手机号 + 验证码登录的**验证码短信下发**(本服务生成码 → 创蓝只负责发送,属自管码 Mode B,与极光同模式)。
|
||||||
|
> 本文件为**接口调研摘要**,供 `app/integrations/sms/chuanglan.py` 实现对照。以线上文档为准。
|
||||||
|
|
||||||
|
## 接口概览
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 请求地址 | `POST https://smssh.253.com/msg/sms/v2/tpl/send` |
|
||||||
|
| Content-Type | `application/json`(UTF-8) |
|
||||||
|
| 协议 | HTTPS |
|
||||||
|
| 鉴权 | HMAC-SHA256 签名头 `X-QA-Hmac-Signature`(推荐)**或** body 明文 `password`(二选一) |
|
||||||
|
|
||||||
|
## 鉴权:两种方式(二选一,不可并用)
|
||||||
|
|
||||||
|
1. **HMAC 签名头(推荐,密码不上行)**:请求头带 `X-QA-Hmac-Signature`,body **不放** `password`。
|
||||||
|
2. **明文密码**:body 放 `password`,不带签名头。
|
||||||
|
|
||||||
|
### HMAC-SHA256 签名算法
|
||||||
|
|
||||||
|
1. `md5Password = MD5(password)` —— 32 位**小写十六进制**。
|
||||||
|
2. 取三个值 `[md5Password, timestamp, nonce]`,**按字典序升序排序**,**无分隔符拼接**,再 `replaceAll("\\s+", "")` 去除所有空白。
|
||||||
|
3. `signature = HmacSHA256(key = md5Password, message = 上一步拼接串)` —— 输出**小写十六进制**。
|
||||||
|
4. 放入请求头:`X-QA-Hmac-Signature: <signature>`。
|
||||||
|
|
||||||
|
> 注意 `key` 就是 `md5Password` 本身(32 位 hex 字符串),不是原始 password。`timestamp` / `nonce` 同时也是 body 字段,必须与签名里用的一致。
|
||||||
|
|
||||||
|
## 请求参数(body,JSON)
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `account` | String | 是 | API 账号;验证码短信用 **`YZM` 前缀**账号(如 `YZM0000001`) |
|
||||||
|
| `timestamp` | String | 是 | Unix 秒级时间戳;**60 秒**内有效,过期报 139 |
|
||||||
|
| `nonce` | String | 是 | 32 位随机串(防重放) |
|
||||||
|
| `phoneNumbers` | String | 是 | 手机号,逗号分隔最多 1000 个;**YZM 验证码账号不支持批量,只能单号** |
|
||||||
|
| `templateId` | String | 是 | 模板 ID(控制台创建 / 模板接口查询) |
|
||||||
|
| `templateParamJson` | String | 条件 | 模板变量,JSON 字符串;模板有 `{s}` 占位符时必填(见下) |
|
||||||
|
| `password` | String | 条件 | 仅在**不使用**签名头时放 body |
|
||||||
|
| `signature` | String | 条件 | **短信签名文案**(如 `【创蓝云智】`);模板未关联签名时必填。**注意与鉴权头 `X-QA-Hmac-Signature` 是两回事** |
|
||||||
|
| `report` | String | 否 | `"true"` 时接收状态回执 |
|
||||||
|
| `callbackUrl` | String | 否 | 回执回调完整 URL |
|
||||||
|
| `uid` | String | 否 | 自定义标识(≤256 字符),回执原样返回 |
|
||||||
|
| `extend` | String | 否 | 数字扩展码(≤5 位),用于上行匹配 |
|
||||||
|
|
||||||
|
### `templateParamJson` 格式与 `{s}` 占位
|
||||||
|
|
||||||
|
- 模板内容用 `{s}` 作占位符,例:`您正在申请手机注册,验证码为:{s},5分钟内有效!`
|
||||||
|
- `templateParamJson` 是 **JSON 数组,元素为对象**,键按 `param1`、`param2`…递增;第 1 个 `{s}` ← `param1`,第 2 个 ← `param2`。
|
||||||
|
- 单条验证码(一个 `{s}` = 验证码)示例:`"[{\"param1\":\"123456\"}]"`
|
||||||
|
|
||||||
|
## 响应格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "000000",
|
||||||
|
"msgId": "25071018345400902898000000000001",
|
||||||
|
"time": "20250710183454",
|
||||||
|
"successNum": "1",
|
||||||
|
"failNum": "0",
|
||||||
|
"errorMsg": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `code` | 状态码,`"000000"` = 成功 |
|
||||||
|
| `msgId` | 消息 ID(32 位) |
|
||||||
|
| `time` | 响应时间戳 |
|
||||||
|
| `successNum` / `failNum` | 提交成功 / 失败条数 |
|
||||||
|
| `errorMsg` | 错误描述(成功为空) |
|
||||||
|
|
||||||
|
## 响应 / 错误码(节选)
|
||||||
|
|
||||||
|
| code | 含义 | 归属 |
|
||||||
|
|---|---|---|
|
||||||
|
| `000000` | 成功 | — |
|
||||||
|
| `101` | 账号不存在 | 客服 |
|
||||||
|
| `102` | 密码错误 | 客服 |
|
||||||
|
| `103` | 提交速度过快(超频) | 客服 |
|
||||||
|
| `107` | 手机号码错误 | 客服 |
|
||||||
|
| `109` | 无发送量(余额/套餐不足) | 销售 |
|
||||||
|
| `110` | 不在发送时段 | 销售 |
|
||||||
|
| `116` | 签名不合法 / 未带签名 | 服务 |
|
||||||
|
| `117` | IP 未加白名单 | 服务 |
|
||||||
|
| `118` | 账号无发送权限 | 服务 |
|
||||||
|
| `124` | 模板内容不匹配 | 服务 |
|
||||||
|
| `129` | JSON 格式错误 | 客服 |
|
||||||
|
| `135` | 相同手机号内容重复 | 客服 |
|
||||||
|
| `139` | 时间戳过期 | 客服 |
|
||||||
|
| `152` | 模板不存在 | 服务 |
|
||||||
|
| `158` | 退订文案不合规 | 客服 |
|
||||||
|
|
||||||
|
## 完整请求示例(单条验证码,明文密码方式省略 password 用签名头)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"account": "YZM0000001",
|
||||||
|
"timestamp": "1752143733",
|
||||||
|
"nonce": "x4zfk0y5foqwx6cbnw3bfmimy98abqs1",
|
||||||
|
"phoneNumbers": "17601337176",
|
||||||
|
"templateId": "1021143438",
|
||||||
|
"templateParamJson": "[{\"param1\":\"123456\"}]",
|
||||||
|
"report": "true"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(HMAC 方式:另加请求头 `X-QA-Hmac-Signature: <算法输出>`,body 不含 `password`。)
|
||||||
|
|
||||||
|
## 接入要点
|
||||||
|
|
||||||
|
- **IP 白名单**:服务器出网 IP 必须在控制台加白,否则 117。
|
||||||
|
- **验证码账号(YZM)**:无发送时段限制;单号发送、不支持批量。
|
||||||
|
- **时间戳 60s**:`timestamp` 与本地时钟偏差过大会 139,注意服务器时间同步。
|
||||||
|
- **退订文案**:仅支持 `拒收请回复R`,且必须在短信末尾(验证码短信一般无需)。
|
||||||
|
- **签名 vs 鉴权头**:`signature`(body)= 短信开头的 `【品牌】` 文案;`X-QA-Hmac-Signature`(header)= 请求鉴权。二者含义完全不同,勿混。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user