Compare commits

...

7 Commits

Author SHA1 Message Date
marco 2e91c9f72f ecpm保底是1 2026-08-01 02:57:45 +08:00
marco a69b7d777d feat(compare/coupon): trace_id 统一由后端签发,前端不再本地生成 (#212)
Reviewed-on: #212
2026-07-31 23:52:19 +08:00
marco 0fc8521c3b feat(compare/coupon): trace_id 统一由后端签发,前端不再本地生成 (#210)
一次比价/领券的 trace_id 改由后端签发,让前端 SLS 运行日志(trace_id 索引列)、
app-server 比价记录与领券流水、pricebot trace 目录/run.log/trace_url 全链共用同一个
id 查到底(此前前端各业务自己 randomUUID,虽同链但非后端签发、也无单一签发点)。

- POST /api/v1/compare/start(预占额度,任务第一个请求,签发与建 running 行合一):
  请求 trace_id 改可选,缺省时服务端签发 uuid;响应新增 trace_id 字段返回(签发的或
  回显客户端带来的)。客户端带值则沿用——老客户端兼容 + 同 trace 重试幂等。
- POST /api/v1/coupon/session:started 帧缺 trace_id 时签发并随响应返回(签发不依赖
  写库成功);非 started 帧缺 trace_id 不签发、不写库(收尾没有 id 只能是异常调用,
  签发新 id 只会造出查不到发起信息的孤儿行)。新增 CouponSessionOut 响应模型——原
  dict[str,bool] 注解无法承载字符串 trace_id,FastAPI 响应校验会炸。
- 测试:更新 2 处旧断言(响应体多出 trace_id 字段),新增 compare 不带 id 签发用例 +
  coupon started 签发/回显、终尾缺 id 跳过写库 3 个用例。全量 30 passed + ruff clean。

配合 shaguabijia-app-android 同名分支 feat-unify-trace-id-backend-issued 的前端换源改动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Reviewed-on: #210
2026-07-31 23:11:35 +08:00
linkeyu 15fb73791f 功能:统一限制策略与白名单管理 (#207)
## 需求背景
将比价、短信与登录、广告、引导与账号、风控免告警等限制统一配置,并支持按手机号或设备设置有有效期的临时白名单。

## 主要改动
- 新增统一限制策略注册表、全局 JSON 配置与白名单覆盖表
- 新增白名单管理、设备检索、批量追加与主体统一编辑接口
- 接入比价、短信登录、广告奖励、引导视频、账号换绑及风险告警调用链
- 保留旧配置接口兼容,并同步统一策略全局值
- 增加单主体唯一有效期、恢复全局、审计日志和风险事件自动处理
- 增加数据库迁移及完整回归测试

## 验证
- 白名单、权限、配置及风控测试 50 项通过
- 短信、登录、比价、广告关联测试 98 项通过
- Ruff 与 Python 编译检查通过
- Alembic 保持单一 head
- 已同步最新 main

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #207
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-31 17:08:06 +08:00
zuochenyong d9177cbb71 fix(推送): 隔离推送设备账号绑定并支持定向联调,新建22222222222测试账号 (#208)
Co-authored-by: exinglang <exinglang@qq.com>
Reviewed-on: #208
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-31 14:34:07 +08:00
guke eeecb5faf0 fix(广告发奖): eCPM 过低单份金币四舍五入成 0 时兜底发 1,避免看了广告被记 too_short 零发 (#205)
## 背景 / 问题

线上 record 4667:用户在比价等候期看满一条低 eCPM(¥0.43 CPM)的信息流广告(≥10s),
但单份金币按公式 `0.43/1000 × 0.1(档位因子) × 1.0(LT因子) × 10000 = 0.43`,四舍五入成 **0**。
在 `grant_feed_reward` 里 `unit_cap=0` → `coin<=0` → 记 `too_short` 零发。

结果:**用户看满了广告却什么都没拿到**,还被标成"时长不足"。

## 改动

`app/core/rewards.py` 的 `calculate_ad_reward_coin`(发奖与后台审计对账的**唯一口径**):

- 有真实正 eCPM 时,单份金币 floor 到 1(`max(0, …)` → `max(1, …)`)。
- eCPM 缺失 / 为 0 / 非法(没有真实广告价值)时提前 `return 0` —— 不凭空铸币、不破坏 `ecpm_missing` 语义。
关键设计
if ecpm_yuan <= 0: return 0 是防铸币防线:没它的话 reward_video 路径 ecpm="0" 会被 floor 成 1。
不影响防刷:上限仍由 AD_ECPM_MAX_FEN(¥500 CPM)钳顶;LT 因子最低 1.0、从不归零,限流靠每日 500 次上限。
正常量级 eCPM 本就 ≥1,下限不改变其取值(纯回归保护)。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #205
2026-07-31 11:18:00 +08:00
guke 31bff63ed4 feat(sms): 短信验证码 极光→创蓝 容灾 fallback (#204)
背景
短信验证码已是可切换 provider 架构(极光 / 创蓝)。极光(默认)一旦供应商侧故障(欠费 / 网络 / 服务异常),/sms/send 直接 503 → 用户收不到码、登录中断。本 PR 把极光设为主、创蓝设为备,在极光供应商不可用时自动转创蓝补发,并让后台可区分每次实际走的渠道。

方案(4 个关键决策)
# | 决策 | 结论
-- | -- | --
A | fallback 触发范围 | 仅主返回「供应商不可用」(SmsError.status_code == 503:网络 / 余额 / 服务故障)才转备。本地冷却 & 超频(429)、手机号无效(400)不转——不绕过防刷、不为无效号白烧
B | 校验路由 | try-both:极光转创蓝后码在创蓝内存,校验遍历「启用的 fallback 链」(主→备),任一命中即通过;关闭 fallback 时链中只有极光、创蓝零参与
C | 后台可见性 | 成功侧 EVENT_SMS_SEND.details 记 provider / fallback + 分派层日志,风控后台可按号/设备查本次走哪家、是否 fallback
D | 默认开关 | SMS_FALLBACK_PROVIDER 默认空=关(保持现状零风险),生产设 chuanglan 开启,置空即秒回退。仅 Mode B(jiguang/chuanglan)互为主备

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #204
2026-07-31 11:17:30 +08:00
63 changed files with 7073 additions and 311 deletions
+13 -1
View File
@@ -45,6 +45,9 @@ SMS_SEND_INTERVAL_SEC=60
# 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=
@@ -69,8 +72,10 @@ CHUANGLAN_SMS_TIMEOUT_SEC=10
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
# 逻辑见 app/core/test_account.py,与其他业务解耦。
# ⚠️ 留空 = 关闭整功能(生产默认);要启用才填号(如 11111111111)。改完重启生效,随时可清空停用
# ⚠️ 两项都留空 = 关闭整功能(生产默认)。多账号用英文逗号分隔,改完重启生效
TEST_ACCOUNT_PHONE=
# 推荐新配置;例如联调环境填 11111111111,22222222222。
TEST_ACCOUNT_PHONES=
# 该测试号每日最多登录次数,当日超过即拒绝(429),次日归零。
TEST_ACCOUNT_DAILY_LIMIT=500
@@ -176,6 +181,13 @@ PANGLE_REPORT_SITE_ID_TEST=5832303
# APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413;运行期可调)
# APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超此字节数截断
# ===== 荣耀 Push =====
HONOR_PUSH_APP_ID=
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,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))
+128
View File
@@ -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")
+2
View File
@@ -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.guide_video import router as guide_video_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.ops_marquee_seed import router as ops_marquee_seed_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(price_report_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(event_logs_router)
admin_app.include_router(analytics_health_router)
+3 -1
View File
@@ -40,6 +40,7 @@ PERMISSION_CATALOG: list[dict] = [
{"key": "analytics-health", "label": "埋点成功率"},
{"key": "event-logs", "label": "埋点日志"},
{"key": "audit-logs", "label": "审计日志"},
{"key": "limit-whitelist", "label": "白名单"},
]},
{"group": "其他", "pages": [
{"key": "admins", "label": "权限管理"},
@@ -58,13 +59,14 @@ BUILTIN_ROLES: list[dict] = [
{"name": "operator", "label": "运营", "pages": [
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
"cps", "risk-monitor", "device-liveness", "price-reports", "feedbacks", "huawei-review",
"limit-whitelist",
]},
{"name": "finance", "label": "财务", "pages": [
"dashboard", "ad-revenue-report", "cps", "invite-withdraws", "withdraws",
]},
{"name": "tech", "label": "技术", "pages": [
"dashboard", "risk-monitor", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review",
"event-logs", "audit-logs",
"event-logs", "audit-logs", "limit-whitelist",
]},
]
+583
View File
@@ -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
+75 -8
View File
@@ -12,9 +12,11 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.config import ConfigItemOut, ConfigUpdateRequest
from app.core import limit_policy
from app.core.config_schema import CONFIG_DEFS
from app.core.rewards import SIGNIN_CYCLE_LEN
from app.models.admin import AdminUser
from app.models.app_config import AppConfig
from app.repositories import app_config
router = APIRouter(
@@ -54,7 +56,35 @@ def _validate(key: str, value: Any) -> None:
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:
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):
if item["key"] == key:
return ConfigItemOut(**item)
@@ -64,11 +94,20 @@ def _item(db, key: str) -> ConfigItemOut:
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)")
def list_config(db: AdminDb) -> list[ConfigItemOut]:
# hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。
return [
ConfigItemOut(**item)
for item in app_config.list_all(db)
if not CONFIG_DEFS[item["key"]].get("hidden")
]
legacy_items = {
item["key"]: item for item in app_config.list_all(db)
}
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="改某项配置(带审计)")
@@ -83,11 +122,39 @@ def update_config(
raise HTTPException(status_code=404, detail="未知配置项")
try:
_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:
db.rollback()
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(
db, admin, action="config.set", target_type="config", target_id=key,
detail={"before": before, "after": body.value}, ip=get_client_ip(request), commit=False,
+838
View File
@@ -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()
+10 -14
View File
@@ -23,13 +23,8 @@ from app.admin.schemas.risk_monitor import (
RiskResetResponse,
RiskRuleConfig,
)
from app.core.config_schema import (
RISK_COMPARE_DAILY_THRESHOLD_KEY,
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RISK_SMS_HOURLY_THRESHOLD_KEY,
)
from app.core import limit_policy
from app.models.risk import RiskIncident, SubjectRestriction
from app.repositories import app_config
from app.repositories import risk as risk_repo
router = APIRouter(
@@ -83,15 +78,16 @@ def update_rules(
) -> RiskRuleConfig:
before = _rule_config(db).model_dump()
after = body.model_dump()
values = (
(RISK_SMS_HOURLY_THRESHOLD_KEY, body.sms_hourly_threshold),
(RISK_ONECLICK_DAILY_THRESHOLD_KEY, body.oneclick_daily_threshold),
(RISK_COMPARE_DAILY_THRESHOLD_KEY, body.compare_daily_threshold),
limit_policy.set_global_limits(
db,
{
"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()
risk_repo.reconcile_behavior_rule(
+150
View File
@@ -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
+2 -2
View File
@@ -21,9 +21,9 @@ class RiskMonitorSummary(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)
compare_daily_threshold: int = Field(ge=1, le=100)
compare_daily_threshold: int = Field(ge=1, le=100_000)
class RiskIncidentItem(BaseModel):
+13 -2
View File
@@ -18,7 +18,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
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.ratelimit import rate_limit
from app.integrations import pangle
@@ -413,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",
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(
granted=(rec.status == "granted"),
status=rec.status,
coin=rec.coin,
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
),
)
+190 -28
View File
@@ -16,7 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
from sqlalchemy.exc import IntegrityError
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 (
RateLimitRule,
check_rate_limits,
@@ -69,6 +69,7 @@ SMS_LOGIN_MAX_PER_HOUR = 5
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸)
UNLIMITED_VERIFY_ATTEMPTS = 2_147_483_647
def _client_ip(request: Request) -> str:
@@ -198,16 +199,54 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
# (超限直接 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 = [
RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600,
"操作过于频繁,请稍后再试"),
RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400,
"今日验证码发送次数过多,请明天再试"),
RateLimitRule("sms-send-device", hourly_limit, 3600,
"操作过于频繁,请稍后再试", hourly_policy.bucket_version),
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:
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:
risk_repo.record_behavior_event(
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
# 发码成功 → 两道闸各 +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 避免循环
@@ -238,7 +277,9 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes
phone=req.phone,
client_ip=_client_ip(request),
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,
)
@@ -256,7 +297,7 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
# 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。
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="测试账号今日使用次数已达上限,请明天再试")
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
risk_repo.record_behavior_event(
@@ -283,17 +324,48 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)。
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
# 退化为该 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(
request,
scope="sms-login-device",
subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR,
subject=subject_id,
limit=login_limit,
window_sec=3600,
detail="登录尝试过于频繁,请稍后再试",
bucket_suffix=login_policy.bucket_version,
)
try:
ok = verify_code(req.phone, req.code)
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:
@@ -394,15 +466,25 @@ def _finish_wechat_bind(
未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。"""
existing = user_repo.get_user_by_phone(db, phone)
if existing is not None:
from app.core.config import settings # 局部 import,避免循环
ticket = create_conflict_ticket(
openid=openid,
wechat_nickname=wechat_nickname,
wechat_avatar_url=wechat_avatar_url,
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(
"wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s",
mask_phone(phone), existing.id, bool(existing.wechat_openid),
@@ -418,7 +500,12 @@ def _finish_wechat_bind(
conflict_ticket=ticket,
rebind_available=not blocked,
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
),
)
@@ -451,18 +538,50 @@ def wechat_bind_phone_sms(
except TokenError as e:
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
subject_id = _device_subject(req.device_id, request)
# 防刷:同 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(
request,
scope="wechat-bind-sms-device",
subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR,
subject=subject_id,
limit=bind_limit,
window_sec=3600,
detail="登录尝试过于频繁,请稍后再试",
bucket_suffix=bind_policy.bucket_version,
)
try:
ok = verify_code(req.phone, req.code)
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:
@@ -522,9 +641,23 @@ def wechat_conflict_continue(
except TokenError as 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(
request, scope="wechat-conflict-device", subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
request, scope="wechat-conflict-device", subject=subject_id,
limit=conflict_limit, window_sec=3600, detail="操作过于频繁,请稍后再试",
bucket_suffix=conflict_policy.bucket_version,
)
user = user_repo.get_user_by_phone(db, claims["phone"])
@@ -562,21 +695,50 @@ def wechat_conflict_continue(
def wechat_conflict_rebind(
req: WechatConflictRebindRequest, request: Request, db: DbSession
) -> WechatBindResultResponse:
from app.core.config import settings # 局部 import,避免循环
try:
claims = decode_conflict_ticket(req.conflict_ticket)
except TokenError as 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(
request, scope="wechat-conflict-device", subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
request, scope="wechat-conflict-device", subject=subject_id,
limit=conflict_limit, window_sec=3600, detail="操作过于频繁,请稍后再试",
bucket_suffix=conflict_policy.bucket_version,
)
phone = claims["phone"]
if rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS):
days = rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
rebind_policy = limit_policy.resolve(
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} 天内已换绑过,暂不能再次换绑")
user = user_repo.rebind_account(
+2 -2
View File
@@ -21,7 +21,6 @@ from __future__ import annotations
import json
import logging
import time
import uuid
from typing import Any
import httpx
@@ -32,6 +31,7 @@ from app.api.deps import DbSession, OptionalUser
from app.core.config import settings
from app.core.logging import trace_id_ctx
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.db.session import SessionLocal
from app.repositories import comparison as crud_compare
@@ -142,7 +142,7 @@ async def _forward(
trace_id = meta.get("trace_id")
minted = False
if not trace_id:
trace_id = str(uuid.uuid4())
trace_id = new_trace_id()
meta["trace_id"] = trace_id
raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes
minted = True
+29 -5
View File
@@ -16,6 +16,8 @@ import logging
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.core import limit_policy
from app.core.trace_ids import new_trace_id
from app.repositories import comparison as crud_compare
from app.repositories import risk as risk_repo
from app.schemas.compare_record import (
@@ -52,18 +54,34 @@ def reserve_compare_start(
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
):
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:
policy = limit_policy.resolve(
db,
"compare.start.daily",
phone=user.phone,
device=payload.device_id,
)
rec, used = crud_compare.reserve_daily_start(
db,
user_id=user.id,
trace_id=payload.trace_id,
trace_id=trace_id,
business_type=payload.business_type,
device_id=payload.device_id,
limit=policy.limit,
reset_at=policy.reset_at,
)
except crud_compare.DailyCompareStartLimitExceeded:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="今日已比价超过100次,请明天再试",
detail=(
f"今日已比价超过{policy.limit}次,请明天再试"
if policy.limit is not None
else "今日比价次数已达上限,请明天再试"
),
) from None
except crud_compare.ComparisonTraceOwnershipError:
raise HTTPException(
@@ -71,11 +89,17 @@ def reserve_compare_start(
detail="比价任务标识冲突,请重新发起",
) from None
# 风控阈值由后台动态配置,不能再只在固定的 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(
limit=crud_compare.DAILY_COMPARE_START_LIMIT,
limit=policy.limit,
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,
)
+31 -5
View File
@@ -21,6 +21,7 @@ from fastapi.concurrency import run_in_threadpool
from app.api.deps import CurrentUser, DbSession
from app.core.config import settings
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.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
@@ -30,6 +31,7 @@ from app.schemas.coupon_state import (
CouponPromptShouldShowOut,
CouponPromptShownIn,
CouponSessionIn,
CouponSessionOut,
CouponStatsOut,
)
@@ -175,6 +177,12 @@ async def coupon_step(
)
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 全量帧均幂等写库。
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
@@ -204,15 +212,33 @@ async def coupon_step(
return resp_json
@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
@router.post(
"/session",
response_model=CouponSessionOut,
summary="领券任务流水上报(admin 领券数据看板数据源;started 兼签发本轮 trace_id)",
)
def coupon_session(payload: CouponSessionIn, db: DbSession) -> CouponSessionOut:
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
到 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:
coupon_repo.upsert_coupon_session(
db,
trace_id=payload.trace_id,
trace_id=trace_id,
device_id=payload.device_id,
status=payload.status,
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
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)")
+33
View File
@@ -19,8 +19,10 @@ from app.api.deps import CurrentUser, DbSession
from app.integrations import vendor_push
from app.repositories import device as device_repo
from app.schemas.device import (
DeferredDeviceUnregisterRequest,
DeviceOut,
DeviceRegisterRequest,
DeviceUnregisterRequest,
HeartbeatRequest,
LivenessAckRequest,
LivenessOut,
@@ -78,6 +80,7 @@ def register_device(
registration_id=req.registration_id,
push_vendor=req.push_vendor,
push_token=req.push_token,
push_binding_id=req.push_binding_id,
platform=req.platform,
app_version=req.app_version,
)
@@ -106,6 +109,36 @@ def report_heartbeat(
registration_id=req.registration_id,
push_vendor=req.push_vendor,
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()
+21 -3
View File
@@ -79,6 +79,8 @@ class Settings(BaseSettings):
HONOR_PUSH_APP_ID: str = ""
HONOR_PUSH_CLIENT_ID: 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_SEND_ENDPOINT_TEMPLATE: str = (
"https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage"
@@ -148,6 +150,9 @@ class Settings(BaseSettings):
# 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 = "" # 系统赠送签名(自定义签名下发易失败)
@@ -191,14 +196,27 @@ class Settings(BaseSettings):
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
# 猜到号后脚本滥用。两个值都能随时改 .env。逻辑全在 app/core/test_account.py,与其他业务解耦。
# ⚠️ TEST_ACCOUNT_PHONE 留空 = 整个功能关闭(生产默认安全;要启用才显式填号)。
TEST_ACCOUNT_PHONE: str = "" # 测试手机号(11 位,如 11111111111);空=关闭整功能
TEST_ACCOUNT_PHONE: str = "" # 兼容旧配置:单个测试手机号
TEST_ACCOUNT_PHONES: str = "" # 多个测试手机号,英文逗号分隔
TEST_ACCOUNT_DAILY_LIMIT: int = 500 # 该测试号每日最多登录次数,当日超过即拒绝登录
@property
def test_account_phone(self) -> str:
"""规整后的测试手机号(去空白);空串=功能关闭"""
"""兼容旧调用:规整后的单个测试手机号。"""
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 =====
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
MT_CPS_APP_KEY: str = ""
@@ -491,7 +509,7 @@ class Settings(BaseSettings):
return self.APP_ENV == "prod"
@model_validator(mode="after")
def _enforce_prod_secrets(self) -> "Settings":
def _enforce_prod_secrets(self) -> Settings:
"""prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。
只校验两个签发凭证:App 用户的 JWT_SECRET_KEY后台的 ADMIN_JWT_SECRET它们沿用默认值
+133 -5
View File
@@ -14,12 +14,138 @@ from app.core import rewards as r
RISK_SMS_HOURLY_THRESHOLD_KEY = "risk_sms_hourly_threshold"
RISK_ONECLICK_DAILY_THRESHOLD_KEY = "risk_oneclick_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
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
# 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。
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": {
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
"group": "签到", "type": "int_list",
@@ -56,7 +182,8 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
},
"ad_daily_limit": {
"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": {
"default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限",
@@ -68,7 +195,8 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
},
"ad_cooldown_sec": {
"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": {
"default": True, "label": "比价/领券期信息流广告",
@@ -118,9 +246,9 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"group": "风控",
"type": "int",
"min": 1,
"max": 5,
"max": 100_000,
"hidden": True,
"help": "同一设备在北京时间同一自然小时内成功下发短信达到该次数时告警;不得高于现有每小时 5 次的发送上限",
"help": "同一设备在北京时间同一自然小时内成功下发短信达到该次数时告警。",
},
RISK_ONECLICK_DAILY_THRESHOLD_KEY: {
"default": 20,
@@ -138,7 +266,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"group": "风控",
"type": "int",
"min": 1,
"max": 100,
"max": 100_000,
"hidden": True,
"help": "同一账户在北京时间同一自然日内发起比价达到该次数时告警。",
},
+623
View File
@@ -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()
+19 -6
View File
@@ -75,10 +75,11 @@ def enforce_rate_limit(
request: Request,
scope: str,
subject: str,
limit: int,
limit: int | None,
window_sec: float,
*,
detail: str = "操作过于频繁,请稍后再试",
bucket_suffix: str = "",
) -> None:
"""在路由内部手动限流,按 (subject, 客户端 IP) 计数。
@@ -87,9 +88,9 @@ def enforce_rate_limit(
key = `scope:subject:client_ip`;同一 (subject, IP) window_sec 内超过 limit 429
[settings.RATE_LIMIT_ENABLED] 总开关控制( [rate_limit] 一致)
"""
if not settings.RATE_LIMIT_ENABLED:
if not settings.RATE_LIMIT_ENABLED or limit is None:
return
key = f"{scope}:{subject}:{_client_ip(request)}"
key = f"{scope}:{subject}:{_client_ip(request)}:{bucket_suffix}"
if not _hit(key, limit, window_sec):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -112,9 +113,10 @@ class RateLimitRule(NamedTuple):
"""
scope: str
limit: int
limit: int | None
window_sec: float
detail: str = "操作过于频繁,请稍后再试"
bucket_suffix: str = ""
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
ip = _client_ip(request)
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(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=rule.detail,
@@ -167,4 +175,9 @@ def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule
return
ip = _client_ip(request)
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
View File
@@ -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 由客户端上报,伪造天价值
会铸天量金币;钳在这唯一入口,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 计数
防刷影响:伪造 eCPM0 每天至多多骗 每日上限×1 金币(500 金币=0.05 ),量级可控;
天价伪造仍由 AD_ECPM_MAX_FEN 钳顶把守
"""
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)
return max(0, round(yuan * COIN_PER_YUAN))
return max(1, round(yuan * COIN_PER_YUAN))
# 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认
+15 -16
View File
@@ -9,9 +9,8 @@
3. **每日使用次数上限**:防被人猜到这个号后写脚本一直刷当天登录数超过上限即拒绝(429),
次日自动归零
手机号与上限都在 .env (`TEST_ACCOUNT_PHONE` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改
`TEST_ACCOUNT_PHONE` 留空 = 整个功能关闭(生产默认态),`is_test_account()` 对任何号都返回
False,登录/短信回到原逻辑,零影响
手机号与上限都在 .env (`TEST_ACCOUNT_PHONES` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改
兼容旧的单号配置 `TEST_ACCOUNT_PHONE`;两项都留空 = 整个功能关闭(生产默认态)
计数存**进程内存**( worker 够用, sms.py 同款约定):重启清零 worker 不共享作为
一个测试号的粗粒度防滥用闸够用;且因所有登录都落同一个 phone 同一个 user,滥用面天然只
@@ -27,9 +26,9 @@ from app.core.config import settings
logger = logging.getLogger("shagua.test_account")
# 进程内每日计数:(date_str, 当日已登录次数)。单 worker 有效,重启清零(见模块 docstring)
# 进程内每日计数:{手机号: (date_str, 当日已登录次数)}。单 worker 有效,重启清零。
_lock = Lock()
_usage: tuple[str, int] = ("", 0)
_usage: dict[str, tuple[str, int]] = {}
def _today() -> str:
@@ -37,16 +36,16 @@ def _today() -> str:
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:
"""该手机号是否为配置的测试账号。功能关闭时对任何号都返回 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。
Returns:
@@ -59,17 +58,17 @@ def try_consume_quota() -> bool:
limit = settings.TEST_ACCOUNT_DAILY_LIMIT
with _lock:
today = _today()
day, cnt = _usage
day, cnt = _usage.get(phone, ("", 0))
if day != today: # 跨天归零
cnt = 0
if cnt >= limit:
_usage = (today, cnt) # 已满,保持不变
_usage[phone] = (today, cnt) # 已满,保持不变
logger.warning(
"测试账号 %s 今日登录数已达上限 %d,拒绝", settings.test_account_phone, limit
"测试账号 %s 今日登录数已达上限 %d,拒绝", phone, limit
)
return False
_usage = (today, cnt + 1)
logger.info("测试账号 %s%d/%d 次登录", settings.test_account_phone, cnt + 1, limit)
_usage[phone] = (today, cnt + 1)
logger.info("测试账号 %s%d/%d 次登录", phone, cnt + 1, limit)
return True
@@ -77,4 +76,4 @@ def _reset_for_test() -> None:
"""仅供单测:清空进程内计数,隔离用例间状态。"""
global _usage
with _lock:
_usage = ("", 0)
_usage = {}
+29
View File
@@ -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/startcoupon/session startedcompare.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]}"
+81 -22
View File
@@ -1,37 +1,96 @@
"""短信验证码服务 —— provider 分派入口。
"""短信验证码服务 —— provider 分派入口(极光主 + 可选创蓝备)
对外暴露 `send_code` / `verify_code` / `SmsError`,api 层无需关心用哪个 provider
provider `settings.SMS_PROVIDER` 选择(**每次调用读取**,支持运行时切换 + 灰度回退):
- `jiguang`(默认):自管码(本服务生成内存存/校验,极光只发) [jiguang.py](jiguang.py)
- `aliyun`:阿里云号码认证(阿里云生成+下发+校验,Mode A) [aliyun.py](aliyun.py)
- `chuanglan`:创蓝云智(253)模板短信,自管码 Mode B(本服务生成内存存/校验,创蓝只发) [chuanglan.py](chuanglan.py)
mock(`SMS_MOCK=true`)与各 provider 的行为差异都封在 provider 内部;本层只做路由
拆包前本模块是单文件 `sms.py`;拆包后极光逻辑迁入 `jiguang` 子模块,行为零改动
对外暴露 `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 SmsError
from .base import SendResult, SmsError
__all__ = ["SmsError", "send_code", "verify_code"]
__all__ = ["SmsError", "SendResult", "send_code", "verify_code"]
# provider 名 -> 模块;未知/缺省值回退 jiguang(默认兜底,防误配把登录打挂)。
_PROVIDERS = {"aliyun": aliyun, "chuanglan": chuanglan}
logger = logging.getLogger("shagua.sms")
# provider 名 <-> 模块。未知/缺省主 provider 回退 jiguang(防误配把登录打挂)。
_ALL = {"jiguang": jiguang, "aliyun": aliyun, "chuanglan": chuanglan}
_NAME = {jiguang: "jiguang", aliyun: "aliyun", chuanglan: "chuanglan"}
def _provider():
"""按配置选 provider 模块(每次调用读 settings,支持运行时切换 / 测试注入)"""
return _PROVIDERS.get(settings.SMS_PROVIDER, jiguang)
def _primary():
"""当前主 provider 模块(未知/缺省回退 jiguang"""
return _ALL.get(settings.SMS_PROVIDER, jiguang)
def send_code(phone: str) -> int:
"""发送验证码,返回距下次可发的冷却秒数;失败抛 SmsError。委托给当前 provider"""
return _provider().send_code(phone)
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 verify_code(phone: str, code: str) -> bool:
"""校验验证码,返回是否通过;provider 异常降级抛 SmsError。委托给当前 provider。"""
return _provider().verify_code(phone, code)
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
+29 -8
View File
@@ -47,19 +47,26 @@ _client = None # 惰性构建的 SDK client(模块级缓存)
# ============================ 对外:发码 / 校验 ============================
def send_code(phone: str) -> int:
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 settings.ALIYUN_SMS_INTERVAL_SEC
return effective_cooldown
if not settings.aliyun_sms_configured:
raise SmsError("短信服务未配置(缺阿里云凭证)", status_code=503)
result = _call_send(phone) # 传输/SDK 异常在内部抛 SmsError(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()
@@ -68,7 +75,7 @@ def send_code(phone: str) -> int:
_verify_attempts.pop(phone, None) # 新码 = 新失败预算
_verify_seen.pop(phone, None)
logger.info("[SMS-aliyun] sent to %s****", phone[:3])
return settings.ALIYUN_SMS_INTERVAL_SEC
return effective_cooldown
code = result["code"]
logger.error("[SMS-aliyun] send failed code=%s msg=%s", code, result["message"])
@@ -78,7 +85,12 @@ def send_code(phone: str) -> int:
raise SmsError(msg, status_code=status)
def verify_code(phone: str, code: str) -> bool:
def verify_code(
phone: str,
code: str,
*,
max_failed_attempts: int | None = None,
) -> bool:
"""校验验证码(阿里云裁决)。
- **mock**:放行任意 N 位数字(provider 无关,同极光)
@@ -92,8 +104,13 @@ def verify_code(phone: str, code: str) -> bool:
# 失败计数是 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) >= settings.SMS_MAX_VERIFY_ATTEMPTS:
if _verify_attempts.get(phone, 0) >= effective_max_attempts:
return False # 已作废:保持计数(直到 send_code 重置),与极光「达上限即作废」一致
result = _call_check(phone, code) # 传输/SDK 异常在内部抛 SmsError(503)
@@ -146,7 +163,7 @@ def _get_client():
return _client
def _call_send(phone: str) -> dict:
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)
@@ -160,7 +177,11 @@ def _call_send(phone: str) -> dict:
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,
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
+16
View File
@@ -2,9 +2,12 @@
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
@@ -18,6 +21,19 @@ class SmsError(Exception):
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()
+19 -6
View File
@@ -84,20 +84,23 @@ def _gc(now: float) -> 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)
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 < settings.SMS_SEND_INTERVAL_SEC:
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
if elapsed < effective_cooldown:
remain = int(effective_cooldown - elapsed)
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
code = _gen_code()
@@ -122,10 +125,15 @@ def send_code(phone: str) -> int:
logger.exception("[SMS-chuanglan] send failed phone=%s****", phone[:3])
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 位数字(测试/开发便利,不真校验)
@@ -136,6 +144,11 @@ def verify_code(phone: str, code: str) -> bool:
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:
@@ -143,7 +156,7 @@ def verify_code(phone: str, code: str) -> bool:
if time.time() > rec.expires_at:
_codes.pop(phone, None)
return False
if rec.attempts >= settings.SMS_MAX_VERIFY_ATTEMPTS:
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")):
+19 -6
View File
@@ -70,20 +70,23 @@ def _gc(now: float) -> 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)
Raises: SmsError(过频 429 / 当日超限 429 / 供应商失败 503 / 手机号无效 400)
"""
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 < settings.SMS_SEND_INTERVAL_SEC:
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
if elapsed < effective_cooldown:
remain = int(effective_cooldown - elapsed)
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
code = _gen_code()
@@ -108,10 +111,15 @@ def send_code(phone: str) -> int:
logger.exception("[SMS] send failed phone=%s****", phone[:3])
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 位数字(测试/开发便利,不真校验)
@@ -122,6 +130,11 @@ def verify_code(phone: str, code: str) -> bool:
logger.info("[SMS-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:
@@ -129,7 +142,7 @@ def verify_code(phone: str, code: str) -> bool:
if time.time() > rec.expires_at:
_codes.pop(phone, None)
return False
if rec.attempts >= settings.SMS_MAX_VERIFY_ATTEMPTS:
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")):
+5 -2
View File
@@ -224,6 +224,10 @@ def send_data_event(
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)。
@@ -233,7 +237,6 @@ def send_data_event(
dispatch: dict[str, Callable[[str, dict[str, str]], dict[str, Any]]] = {
"honor": _send_honor_data,
"huawei": _send_huawei_data,
"xiaomi": _send_xiaomi_data,
}
return dispatch[vendor](token, payload)
@@ -520,7 +523,7 @@ def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> di
"notification": {"title": title, "body": body},
"android": {
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
"targetUserType": 1,
"targetUserType": settings.HONOR_PUSH_TARGET_USER_TYPE,
"notification": {
"title": title,
"body": body,
+1
View File
@@ -36,6 +36,7 @@ from app.models.inactivity import ( # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
from app.models.limit_policy import LimitPolicyOverride # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.notification import Notification # noqa: F401
from app.models.onboarding import OnboardingCompletion # noqa: F401
+10
View File
@@ -34,6 +34,11 @@ class DeviceLiveness(Base):
__tablename__ = "device_liveness"
__table_args__ = (
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)
@@ -48,6 +53,11 @@ class DeviceLiveness(Base):
push_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 厂商 push token / regId / registration_id;不同厂商命名不同,后端统一存这里。
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")
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
+77
View File
@@ -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,
)
+36 -5
View File
@@ -5,11 +5,13 @@
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
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.models.ad_feed_reward import AdFeedRewardRecord
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()
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
return db.execute(
def _granted_today(
db: Session,
user_id: int,
reward_date: str,
*,
reset_at: datetime | None = None,
) -> int:
stmt = (
select(func.count())
.select_from(AdFeedRewardRecord)
.where(
@@ -38,7 +46,10 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
AdFeedRewardRecord.reward_date == reward_date,
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:
@@ -121,7 +132,27 @@ def grant_feed_reward(
)
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(
client_event_id=client_event_id,
user_id=user_id,
+82 -17
View File
@@ -10,13 +10,13 @@
"""
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
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.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
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:
return db.execute(
def _granted_today(
db: Session,
user_id: int,
reward_date: str,
*,
reset_at: datetime | None = None,
) -> int:
stmt = (
select(func.count())
.select_from(AdRewardRecord)
.where(
@@ -106,7 +112,10 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
AdRewardRecord.status == "granted",
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:
@@ -165,7 +174,27 @@ def grant_ad_reward(
DAILY_AD_WATCH_SECONDS_LIMIT > 0
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:
rec = AdRewardRecord(
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
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,按时间倒序(最新在前)——冷却策略的输入数据。"""
return list(
db.execute(
select(AdRewardRecord.created_at)
.where(
stmt = select(AdRewardRecord.created_at).where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_date == reward_date,
AdRewardRecord.status == "granted",
AdRewardRecord.reward_scene == "reward_video",
)
.order_by(AdRewardRecord.created_at.desc())
).scalars()
if reset_at is not None:
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 表示不启用时长闸
"""
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(
granted_desc,
datetime.now(timezone.utc),
datetime.now(UTC),
round_size=rewards.get_ad_round_count(db),
cooldown_seconds=rewards.get_ad_cooldown_sec(db),
cooldown_seconds=cooldown_seconds,
)
return (
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,
state.round_count,
state.cooldown_until,
+13 -1
View File
@@ -444,6 +444,8 @@ def reserve_daily_start(
business_type: str = "food",
device_id: str | None = None,
now: datetime | None = None,
limit: int | None = DAILY_COMPARE_START_LIMIT,
reset_at: datetime | None = None,
) -> tuple[ComparisonRecord, int]:
"""Atomically reserve one of a user's 100 Beijing-day comparison starts.
@@ -469,6 +471,11 @@ def reserve_daily_start(
if existing_at.tzinfo is not None:
existing_at = existing_at.astimezone(CN_TZ).replace(tzinfo=None)
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)
used = db.scalar(
select(func.count(ComparisonRecord.id)).where(
@@ -483,6 +490,11 @@ def reserve_daily_start(
if current.tzinfo is not None:
current = current.astimezone(CN_TZ).replace(tzinfo=None)
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)
used = db.scalar(
select(func.count(ComparisonRecord.id)).where(
@@ -491,7 +503,7 @@ def reserve_daily_start(
ComparisonRecord.created_at < day_end,
)
) or 0
if used >= DAILY_COMPARE_START_LIMIT:
if limit is not None and used >= limit:
raise DailyCompareStartLimitExceeded
rec = ComparisonRecord(
+117 -14
View File
@@ -1,9 +1,9 @@
"""device 表读写(设备注册 / 心跳 / 超时扫描)。"""
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 app.models.device import DeviceLiveness
@@ -24,13 +24,29 @@ def register_or_update(
registration_id: str | None = None,
push_vendor: str | None = None,
push_token: str | None = None,
push_binding_id: str | None = None,
platform: str = "android",
app_version: str | None = None,
) -> DeviceLiveness:
"""注册设备或更新其厂商 push token / 元信息。upsert by (user_id, device_id)"""
"""注册设备或更新推送目标;同一设备/token 的旧用户绑定在同一事务内失效"""
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
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:
device = DeviceLiveness(
user_id=user_id,
@@ -38,17 +54,22 @@ def register_or_update(
registration_id=registration_id,
push_vendor=normalized_vendor,
push_token=normalized_token,
push_binding_id=normalized_binding,
push_binding_revoked=False,
platform=platform or "android",
app_version=app_version,
)
db.add(device)
else:
if registration_id:
if registration_id and not stale_binding:
device.registration_id = registration_id
if normalized_vendor:
if normalized_vendor and not stale_binding:
device.push_vendor = normalized_vendor
if normalized_token:
if normalized_token and not stale_binding:
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:
device.platform = platform
if app_version:
@@ -67,26 +88,45 @@ def touch_heartbeat(
registration_id: str | None = None,
push_vendor: str | None = None,
push_token: str | None = None,
push_binding_id: str | None = None,
) -> DeviceLiveness:
"""处理一次心跳(心跳也能自注册)。
service 心跳或 accessibility_enabled=true ,刷新存活并把状态机重置回 alive
清掉 notified_at(掉线恢复 下次再断才会再推一条)
"""
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
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:
device = DeviceLiveness(user_id=user_id, device_id=device_id)
db.add(device)
if registration_id:
if registration_id and not stale_binding:
device.registration_id = registration_id
normalized_vendor = _normalize_push_vendor(push_vendor)
normalized_token = push_token.strip() if push_token else None
if normalized_vendor:
if normalized_vendor and not stale_binding:
device.push_vendor = normalized_vendor
if normalized_token:
if normalized_token and not stale_binding:
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
if accessibility_enabled:
@@ -107,7 +147,7 @@ def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
即使没有厂商 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(
DeviceLiveness.ever_protected.is_(True),
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)
if device is not None:
device.liveness_state = "notified"
device.notified_at = datetime.now(timezone.utc)
device.notified_at = datetime.now(UTC)
device.kill_alert_pending = True
db.commit()
@@ -177,6 +217,69 @@ def list_push_targets(db: Session, *, user_id: int) -> list[DeviceLiveness]:
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:
if not push_vendor:
return None
+103 -30
View File
@@ -10,7 +10,7 @@ from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import media, 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.guide_video import GuideVideoPlay
@@ -25,7 +25,9 @@ _KEY_BY_SCENE = {
BIZ_TYPE = "guide_video"
CIRCLE_COUNT = 10
PLAN_TTL = timedelta(minutes=10)
MIN_PLAYS = 1
# 0 表示全局暂停播放;白名单页与旧引导视频配置页必须接受同一口径,
# 否则在白名单页设为 0 后,旧页面连金币/开关等无关字段也无法保存。
MIN_PLAYS = 0
MAX_PLAYS_LIMIT = 50
MIN_REWARD_COIN = 10
REWARD_COIN_LIMIT = 10_000
@@ -95,6 +97,10 @@ def _public_config(cfg: dict[str, Any]) -> dict[str, Any]:
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
return cfg
@@ -153,7 +159,27 @@ def update_config(
raw["max_plays"] = candidate_plays
raw["reward_coin"] = candidate_reward
raw["config_version"] = int(raw.get("config_version") or 0) + 1
after = _write(db, raw, scene=scene, admin_id=admin_id, commit=commit)
after = _write(
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
@@ -191,20 +217,52 @@ def set_video(
return before, after
def used_plays(db: Session, user_id: int, scene: str = "coupon") -> int:
return int(
db.execute(
select(func.count()).select_from(GuideVideoPlay).where(
GuideVideoPlay.user_id == user_id,
GuideVideoPlay.scene == scene,
GuideVideoPlay.status != "prepared",
)
).scalar_one()
def used_plays(
db: Session,
user_id: int,
scene: str = "coupon",
*,
reset_at: datetime | None = None,
) -> int:
stmt = select(func.count()).select_from(GuideVideoPlay).where(
GuideVideoPlay.user_id == user_id,
GuideVideoPlay.scene == scene,
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 _prepare_miss(scene: str, reason: str, cfg: dict[str, Any], used: int) -> dict[str, Any]:
maximum = int(cfg.get("max_plays") or 0)
def _effective_quota(
db: Session,
user_id: int,
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
)
return policy.limit, policy.reset_at
def _remaining(maximum: int | None, used: int) -> int:
# 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]:
return {
"should_play": False,
"reason": reason,
@@ -218,23 +276,27 @@ def _prepare_miss(scene: str, reason: str, cfg: dict[str, Any], used: int) -> di
"reward_coin": int(cfg.get("reward_coin") or 0),
"reward_per_circle": int(cfg.get("reward_coin") or 0) // CIRCLE_COUNT,
"seq": used,
"remaining": max(0, maximum - used),
"remaining": _remaining(maximum, used),
"expires_at": None,
}
def prepare_play(db: Session, user_id: int, *, scene: str = "coupon") -> dict[str, Any]:
cfg = get_config(db, scene)
used = used_plays(db, user_id, scene)
maximum, reset_at = _effective_quota(db, user_id, scene, cfg)
used = used_plays(db, user_id, scene, reset_at=reset_at)
video_url = str(cfg.get("video_url") or "").strip()
duration = int(cfg.get("duration_ms") or 0)
maximum = int(cfg.get("max_plays") or 0)
if not cfg.get("enabled"):
return _prepare_miss(scene, "disabled", cfg, used)
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)
if used >= maximum:
return _prepare_miss(scene, "play_limit_reached", cfg, used)
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
)
now = _now()
play = GuideVideoPlay(
@@ -268,7 +330,7 @@ def prepare_play(db: Session, user_id: int, *, scene: str = "coupon") -> dict[st
"reward_coin": play.coin,
"reward_per_circle": play.coin // CIRCLE_COUNT,
"seq": used + 1,
"remaining": max(0, maximum - used - 1),
"remaining": _remaining(maximum, used + 1),
"expires_at": play.expires_at.isoformat(),
}
@@ -282,7 +344,12 @@ def _find_play(db: Session, user_id: int, token: str) -> GuideVideoPlay | None:
).scalar_one_or_none()
def _start_out(play: GuideVideoPlay, maximum: int, status: str) -> dict[str, Any]:
def _start_out(
play: GuideVideoPlay,
maximum: int | None,
used: int,
status: str,
) -> dict[str, Any]:
assert play.started_at is not None and play.seq is not None and play.video_url
return {
"started": True,
@@ -297,7 +364,7 @@ def _start_out(play: GuideVideoPlay, maximum: int, status: str) -> dict[str, Any
"reward_coin": play.coin,
"reward_per_circle": play.coin // CIRCLE_COUNT,
"seq": play.seq,
"remaining": max(0, maximum - play.seq),
"remaining": _remaining(maximum, used),
"started_at": play.started_at.isoformat(),
}
@@ -307,9 +374,14 @@ def start_play(db: Session, user_id: int, *, play_token: str) -> dict[str, Any]:
if play is None:
raise PlayStateError("play_not_found", "播放计划不存在")
cfg = get_config(db, play.scene)
maximum = int(cfg["max_plays"])
maximum, reset_at = _effective_quota(
db, user_id, play.scene, cfg
)
used = used_plays(
db, user_id, play.scene, reset_at=reset_at
)
if play.status in {"started", "completed"}:
return _start_out(play, maximum, "already_started")
return _start_out(play, maximum, used, "already_started")
if play.status != "prepared":
raise PlayStateError("play_not_found", "播放计划不可用")
now = _now()
@@ -323,10 +395,11 @@ def start_play(db: Session, user_id: int, *, play_token: str) -> dict[str, Any]:
"config_changed", "视频配置已变化,请重新获取",
reprepare_required=True,
)
used = used_plays(db, user_id, play.scene)
if used >= maximum:
if maximum is not None and used >= maximum:
raise PlayStateError("play_limit_reached", "播放次数已用完")
play.seq = used + 1
# seq 在数据库中是账号+场景生命周期唯一值;白名单重置只重置额度,
# 不能从 1 重新编号,否则会与历史记录冲突。
play.seq = used_plays(db, user_id, play.scene) + 1
play.status = "started"
play.started_at = now
try:
@@ -335,7 +408,7 @@ def start_play(db: Session, user_id: int, *, play_token: str) -> dict[str, Any]:
except IntegrityError as exc:
db.rollback()
raise PlayStateError("play_limit_reached", "并发起播冲突,请重新获取") from exc
return _start_out(play, maximum, "started")
return _start_out(play, maximum, used + 1, "started")
def _coin_balance(db: Session, user_id: int) -> int:
+29 -7
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import math
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -10,9 +10,22 @@ from sqlalchemy.orm import Session
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 天内是否换绑过(命中 → 禁止再次换绑)。"""
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 = (
select(PhoneRebindLog.id)
.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
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)。"""
conditions = [PhoneRebindLog.phone == phone]
if reset_at is not None:
conditions.append(PhoneRebindLog.rebound_at >= reset_at)
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()
if last is None:
return 0
if last.tzinfo is None: # SQLite 取回 naive datetime,按 UTC 归一
last = last.replace(tzinfo=timezone.utc)
remaining = (last + timedelta(days=days) - datetime.now(timezone.utc)).total_seconds()
last = last.replace(tzinfo=UTC)
remaining = (last + timedelta(days=days) - datetime.now(UTC)).total_seconds()
return max(0, math.ceil(remaining / 86400))
+92 -37
View File
@@ -9,15 +9,10 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.config_schema import (
RISK_COMPARE_DAILY_THRESHOLD_KEY,
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RISK_SMS_HOURLY_THRESHOLD_KEY,
)
from app.core import limit_policy
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord
from app.models.risk import BehaviorEvent, RiskIncident, SubjectRestriction
from app.repositories import app_config
CN_TZ = ZoneInfo("Asia/Shanghai")
@@ -41,7 +36,6 @@ class RuleSpec:
code: str
event_type: str
subject_type: str
threshold_key: str
window: str
count_outcomes: tuple[str, ...]
@@ -51,7 +45,6 @@ RULES: dict[str, RuleSpec] = {
code=RULE_SMS_HOURLY,
event_type=EVENT_SMS_SEND,
subject_type="device",
threshold_key=RISK_SMS_HOURLY_THRESHOLD_KEY,
window="hour",
count_outcomes=("success",),
),
@@ -59,22 +52,23 @@ RULES: dict[str, RuleSpec] = {
code=RULE_ONECLICK_DAILY,
event_type=EVENT_ONECLICK_LOGIN,
subject_type="device",
threshold_key=RISK_ONECLICK_DAILY_THRESHOLD_KEY,
window="day",
count_outcomes=("success", "failed"),
),
}
RULE_THRESHOLD_KEYS: dict[str, str] = {
RULE_SMS_HOURLY: RISK_SMS_HOURLY_THRESHOLD_KEY,
RULE_ONECLICK_DAILY: RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RULE_COMPARE_DAILY: RISK_COMPARE_DAILY_THRESHOLD_KEY,
RISK_LIMIT_RULE_CODES: dict[str, str] = {
RULE_SMS_HOURLY: "risk.sms.hourly",
RULE_ONECLICK_DAILY: "risk.oneclick.daily",
RULE_COMPARE_DAILY: "risk.compare.daily",
}
def get_rule_threshold(db: Session, rule_code: str) -> int:
"""读取规则当前阈值;配置表为空时回退上线前的 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:
@@ -257,22 +251,34 @@ def _upsert_incident(
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:
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)
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(
db,
spec,
subject_id=subject_id,
start=start,
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 _upsert_incident(
db,
@@ -300,7 +306,6 @@ def reconcile_behavior_rule(
"""按当前阈值重算短信/一键登录当前窗口,并收起已不再命中的待处理告警。"""
spec = RULES[rule_code]
current = at or utcnow()
threshold = get_rule_threshold(db, rule_code)
window_key, window_start, end = _window_bounds(current, spec.window)
reset_at = get_rule_reset_at(db, rule_code)
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 < end,
)
qualifying_rows = db.execute(
subject_rows = db.execute(
select(
BehaviorEvent.subject_id,
func.max(BehaviorEvent.occurred_at),
func.max(BehaviorEvent.phone),
)
.where(*filters)
.group_by(BehaviorEvent.subject_id)
.having(func.count(BehaviorEvent.id) >= threshold)
).all()
qualifying = {str(subject_id) for subject_id, _ in qualifying_rows}
for subject_id, last_at in qualifying_rows:
evaluate_behavior_rule(
qualifying: set[str] = set()
policy_code = {
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,
rule_code=rule_code,
subject_id=str(subject_id),
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(
select(RiskIncident).where(
@@ -383,7 +404,29 @@ def record_behavior_event(
db.add(event)
db.flush()
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:
db.commit()
db.refresh(event)
@@ -396,20 +439,33 @@ def sync_compare_incident(
user_id: int,
at: datetime,
threshold: int | None = None,
device_id: str | None = None,
commit: bool = True,
) -> 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”时间;这里必须沿用同一
# 口径,否则 SQLite/PG session timezone 不同时会把凌晨记录算到前一天。
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)
end = window_start + timedelta(days=1)
window_key = window_start.strftime("%Y-%m-%d")
reset_at = get_rule_reset_at(db, RULE_COMPARE_DAILY)
reset_local = (
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
global_reset_at = get_rule_reset_at(db, RULE_COMPARE_DAILY)
baselines = [
value.astimezone(CN_TZ).replace(tzinfo=None)
for value in (global_reset_at, policy.reset_at)
if value is not None
]
start = max(window_start, *baselines) if baselines else window_start
filters = (
ComparisonRecord.user_id == user_id,
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
)
start = max(window_start, reset_local) if reset_local else window_start
threshold = get_rule_threshold(db, RULE_COMPARE_DAILY)
rows = db.execute(
select(
ComparisonRecord.user_id,
@@ -476,17 +531,17 @@ def reconcile_compare_rule(
ComparisonRecord.created_at < end,
)
.group_by(ComparisonRecord.user_id)
.having(func.count(ComparisonRecord.id) >= threshold)
).all()
qualifying = {str(user_id) for user_id, _ in rows}
qualifying: set[str] = set()
for user_id, last_at in rows:
sync_compare_incident(
incident = sync_compare_incident(
db,
user_id=int(user_id),
at=last_at or current,
threshold=threshold,
commit=False,
)
if incident is not None:
qualifying.add(str(user_id))
open_incidents = db.scalars(
select(RiskIncident).where(
+15 -3
View File
@@ -7,9 +7,19 @@
"""
from __future__ import annotations
import re
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):
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(
"", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于发码防刷按 设备+IP 限流;空=按 IP 聚一桶",
@@ -80,6 +90,7 @@ class SmsSendRequest(BaseModel):
device_model: str = Field(
"", max_length=128, description="客户端设备型号快照,用于短信安全审计"
)
_valid_phone = field_validator("phone")(_validate_login_phone)
class SmsSendResponse(BaseModel):
@@ -89,7 +100,7 @@ class SmsSendResponse(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)
device_id: str = Field(
"", max_length=64,
@@ -98,6 +109,7 @@ class SmsLoginRequest(BaseModel):
device_model: str = Field(
"", max_length=128, description="客户端设备型号快照,用于短信验证安全审计"
)
_valid_phone = field_validator("phone")(_validate_login_phone)
# ===== Refresh =====
+11 -4
View File
@@ -214,17 +214,24 @@ class ComparisonRecordCreatedOut(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)
device_id: str | None = Field(default=None, max_length=64)
class CompareStartReserveOut(BaseModel):
limit: int
limit: int | None
used: int
remaining: int
remaining: int | None
# 本次比价全链 trace_id(服务端签发的,或回显客户端带来的)。客户端必须以它为准,
# 贯穿 Phase1/Phase2 step、比价记录、trace 收尾与前端运行日志上报。
trace_id: str
class CompareStatsOut(BaseModel):
+18 -1
View File
@@ -58,9 +58,13 @@ class CouponSessionIn(BaseModel):
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)
- 收尾(completed/failed/abandoned): elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count
不鉴权(同领券循环 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
status: str # started / completed / failed / abandoned
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
@@ -74,3 +78,16 @@ class CouponSessionIn(BaseModel):
platform_elapsed: dict[str, int] | None = None
claimed_count: int | 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
+11
View File
@@ -12,6 +12,7 @@ class DeviceRegisterRequest(BaseModel):
registration_id: str | None = None
push_vendor: str | None = None
push_token: str | None = None
push_binding_id: str | None = Field(default=None, max_length=128)
platform: str = "android"
app_version: str | None = None
@@ -23,6 +24,7 @@ class HeartbeatRequest(BaseModel):
registration_id: str | None = None
push_vendor: str | None = None
push_token: str | None = None
push_binding_id: str | None = Field(default=None, max_length=128)
class DeviceOut(BaseModel):
@@ -33,6 +35,7 @@ class DeviceOut(BaseModel):
registration_id: str | None
push_vendor: str | None
push_token: str | None
push_binding_id: str | None
ever_protected: bool
liveness_state: str
last_heartbeat_at: datetime | None
@@ -43,6 +46,14 @@ class OkResponse(BaseModel):
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):
"""本机掉线告警状态(后置检测 pull 版)。客户端只需这一个布尔判断要不要弹「开启自启动」引导,
故只返回 kill_alert_pending(不暴露设备详情 / 内部 liveness_state )从未注册过 默认 False(无告警)"""
+60 -10
View File
@@ -83,6 +83,7 @@ def _dispatch(
extra: dict[str, str] | None = None,
dedup_key: str | None = None,
push_vars: dict[str, str] | None = None,
push_token_contains: str | None = None,
) -> Notification | None:
"""落一条站内消息并向该用户设备直推。返回落库行;去重命中/失败返回 None。"""
logger.info(
@@ -119,11 +120,16 @@ def _dispatch(
"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_to_user_devices(db, row, push_vars, push_token_contains)
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,单设备失败不影响其余)。"""
try:
title, body = catalog.render_push(row.type, push_vars)
@@ -133,9 +139,17 @@ def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, s
extras["notificationId"] = str(row.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 target_count=%s",
row.user_id, row.type, row.id, len(targets),
"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(
@@ -230,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 空)。"""
_dispatch(
db,
@@ -244,10 +263,16 @@ def notify_withdraw_success(db: Session, order: WithdrawOrder) -> None:
extra={},
dedup_key=order.out_bill_no,
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 提现失败/退回:含微信侧失败、审核拒绝、解绑退回。点击跳提现页重新提现。
失败原因用 order.fail_reason( /withdraw/status 下发的用户可读原因同源)
@@ -265,10 +290,16 @@ def notify_withdraw_failed(db: Session, order: WithdrawOrder) -> None:
extra={"withdrawId": order.out_bill_no},
dedup_key=order.out_bill_no,
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 官方回复:运营审核了反馈且未采纳(用户可见原因/留言落在反馈记录上)。
点击跳反馈历史页滚动高亮该条(extra.feedbackId)"""
_dispatch(
@@ -278,10 +309,16 @@ def notify_feedback_reply(db: Session, feedback: Feedback) -> None:
info_rows=[{"label": "说明文案", "value": "快去看看官方给您的回复吧~"}],
extra={"feedbackId": 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);
运营漏填时省略该信息行,不硬造文案"""
coins = int(feedback.reward_coins or 0)
@@ -299,10 +336,16 @@ def notify_feedback_reward(db: Session, feedback: Feedback) -> None:
extra={"feedbackId": str(feedback.id)},
dedup_key=str(feedback.id),
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 爆料审核通过:上报的更低价过审,金币已到账。点击跳爆料记录页高亮该条。"""
coins = int(report.reward_coins or 0)
store = (report.store_name or "").strip() or "该店铺"
@@ -318,11 +361,17 @@ def notify_report_approved(db: Session, report: PriceReport) -> None:
extra={"reportId": str(report.id)},
dedup_key=str(report.id),
push_vars={"store": store, "coins": str(coins)},
push_token_contains=push_token_contains,
)
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:
"""#12 好友下单到账:被邀请好友完成首次下单(比价),现金奖励已入邀请人账户。
通知发给邀请人;每个好友只发一次奖 dedup 按被邀请人"""
@@ -340,4 +389,5 @@ def notify_invite_order_reward(
extra={"inviteeNickname": nickname},
dedup_key=str(invitee_user_id),
push_vars={"nickname": nickname, "amount": _yuan_trim(cash_cents)},
push_token_contains=push_token_contains,
)
+1
View File
@@ -87,6 +87,7 @@ PRD §5 的 13 条 push 文案(标题固定 ≤11 字不带变量;正文 `{var}`
**真发注意**:
- 目标手机必须先装 App 且客户端已集成对应厂商 SDK、`/device/register` 上报过 token;
- 荣耀正式环境必须使用正式消息(`HONOR_PUSH_TARGET_USER_TYPE=0`);开发联调测试设备时才设为 `1`;
- vivo 未上架前走测试推送(`VIVO_PUSH_MODE=1`),目标手机需在 vivo 开放平台加入测试设备;
- 小米新设备需在开放平台把签名/包名配好,token 才有效。
@@ -0,0 +1,639 @@
# 短信 极光→创蓝 容灾 fallback 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 极光为主、创蓝为备的短信验证码容灾——极光供应商不可用(503)时自动转创蓝补发,发/校验全程后台可区分实际渠道,由配置开关控制、可秒回退。
**Architecture:** 全部逻辑收在分派层 `app/integrations/sms/__init__.py`provider 文件 jiguang/chuanglan/aliyun 一行不动)。`send_code` 返回携带渠道的 `SendResult``verify_code` 用 try-both 遍历「启用的 fallback 链」。开关 `SMS_FALLBACK_PROVIDER` 默认空=关。
**Tech Stack:** FastAPI + pydantic-settings + pytest(monkeypatch)。SMS provider 均 Mode B(本地内存自管码)。
设计依据:`docs/superpowers/specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md`
---
## 文件结构
| 文件 | 职责 | 动作 |
|---|---|---|
| `app/integrations/sms/base.py` | 加 `SendResult` 结果类型(provider 无关,与 `SmsError` 同层) | 修改 |
| `app/core/config.py` | 加 `SMS_FALLBACK_PROVIDER` 开关 | 修改 |
| `app/integrations/sms/__init__.py` | 分派层:主/备解析 + `send_code` fallback + `verify_code` try-both | 修改(核心) |
| `app/api/v1/auth.py` | `sms_send``SendResult.cooldown_sec` + `details``provider`/`fallback` | 修改(一处) |
| `.env.example` | 加 `SMS_FALLBACK_PROVIDER=` 注释 | 修改 |
| `tests/test_sms_dispatch.py` | 现有 3 处 `== 60` 断言改读 `.cooldown_sec` | 修改 |
| `tests/test_sms_fallback.py` | fallback + try-both 全量新测试 | 新建 |
| `tests/test_auth.py` | `sms_send` 成功 `details` 含 provider/fallback | 修改(加 1 用例) |
`jiguang.py` / `chuanglan.py` / `aliyun.py` **不动**
---
## Task 1: `SendResult` 结果类型(base.py
**Files:**
- Modify: `app/integrations/sms/base.py`
- Test: `tests/test_sms_base.py`(新建)
- [ ] **Step 1: 写失败测试**
Create `tests/test_sms_base.py`:
```python
"""SMS 共享基座类型 SendResult。"""
from __future__ import annotations
import dataclasses
import pytest
from app.integrations.sms.base import SendResult
def test_send_result_fields():
r = SendResult(cooldown_sec=60, provider="jiguang", fallback=True)
assert r.cooldown_sec == 60
assert r.provider == "jiguang"
assert r.fallback is True
def test_send_result_fallback_defaults_false():
r = SendResult(cooldown_sec=60, provider="jiguang")
assert r.fallback is False
def test_send_result_is_frozen():
r = SendResult(cooldown_sec=60, provider="jiguang")
with pytest.raises(dataclasses.FrozenInstanceError):
r.provider = "chuanglan"
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_base.py -q`
Expected: FAIL — `ImportError: cannot import name 'SendResult'`
- [ ] **Step 3: 最小实现**
`app/integrations/sms/base.py` 顶部 import 区加 `from dataclasses import dataclass`,并在 `SmsError` 类后新增:
```python
@dataclass(frozen=True)
class SendResult:
"""发码结果:距下次可发秒数 + 实际发码的 provider 名 + 是否走了备(fallback)。
分派层 send_code 的返回类型(provider 各自的 send_code 仍返回 int cooldown
由分派层包装)。auth 层据此把渠道记入风控流水 details。
"""
cooldown_sec: int
provider: str
fallback: bool = False
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_base.py -q`
Expected: PASS (3 passed)
- [ ] **Step 5: 提交**
```bash
git add app/integrations/sms/base.py tests/test_sms_base.py
git commit -m "feat(sms): 加 SendResult 结果类型(cooldown+provider+fallback)"
```
---
## Task 2: `SMS_FALLBACK_PROVIDER` 配置开关(config.py
**Files:**
- Modify: `app/core/config.py``SMS_PROVIDER` 定义附近,约 line 150
- Test: `tests/test_sms_fallback.py`(新建,本 Task 起逐步补全)
- [ ] **Step 1: 写失败测试**
Create `tests/test_sms_fallback.py`:
```python
"""极光→创蓝 容灾 fallback(分派层 send_code / verify_code)。
monkeypatch 各 provider 的 send_code/verify_code 为标记函数,不发真网络;
断言:仅主 503 才转备;429/400 不转;verify try-both 只遍历启用的链。
"""
from __future__ import annotations
import pytest
from app.core.config import settings
from app.integrations import sms
from app.integrations.sms import aliyun, chuanglan, jiguang
from app.integrations.sms.base import SendResult, SmsError
PHONE = "13800138000"
def _raiser(exc: Exception):
"""返回一个调用即 raise exc 的 send_code 替身(lambda 不能 raise)。"""
def _f(phone):
raise exc
return _f
def test_fallback_provider_defaults_empty():
assert settings.SMS_FALLBACK_PROVIDER == ""
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_fallback.py::test_fallback_provider_defaults_empty -q`
Expected: FAIL — `AttributeError: 'Settings' object has no attribute 'SMS_FALLBACK_PROVIDER'`
- [ ] **Step 3: 最小实现**
`app/core/config.py``SMS_PROVIDER` 定义行下方新增:
```python
# 主 provider 供应商不可用(SmsError 503)时转投的备 provider;空=关闭 fallback(默认,
# 保持现状零风险)。仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。
SMS_FALLBACK_PROVIDER: Literal["jiguang", "chuanglan", ""] = ""
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_fallback.py::test_fallback_provider_defaults_empty -q`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/core/config.py tests/test_sms_fallback.py
git commit -m "feat(sms): 加 SMS_FALLBACK_PROVIDER 开关(默认空=关)"
```
---
## Task 3: 分派层 `send_code` 返回 SendResult(主路径)
先让主路径返回 `SendResult`(不含 fallback),并把现有 `test_sms_dispatch.py``== 60` 断言迁到 `.cooldown_sec`。fallback 分支在 Task 4 加。
**Files:**
- Modify: `app/integrations/sms/__init__.py`
- Modify: `tests/test_sms_dispatch.py`line 20/22/24、line 45-53
- Test: `tests/test_sms_fallback.py`
- [ ] **Step 1: 写失败测试**
`tests/test_sms_fallback.py` 追加:
```python
def test_send_code_primary_success_returns_sendresult(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "")
monkeypatch.setattr(jiguang, "send_code", lambda phone: 60)
r = sms.send_code(PHONE)
assert isinstance(r, SendResult)
assert r.cooldown_sec == 60
assert r.provider == "jiguang"
assert r.fallback is False
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_fallback.py::test_send_code_primary_success_returns_sendresult -q`
Expected: FAIL — `assert isinstance(r, SendResult)`(现 send_code 返回 int 60
- [ ] **Step 3: 最小实现**
`app/integrations/sms/__init__.py` 整体替换为(本 Task 只到主路径;Task 4/5 再扩 fallback 与 try-both):
```python
"""短信验证码服务 —— 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_code(phone: str) -> SendResult:
"""发码:主成功即返回;失败抛 SmsError。返回 SendResultcooldown + 实际渠道)。"""
primary = _primary()
cooldown = primary.send_code(phone)
return SendResult(cooldown_sec=cooldown, provider=_NAME[primary], fallback=False)
def verify_code(phone: str, code: str) -> bool:
"""校验:委托当前主 providertry-both 在 Task 5 加)。"""
return _primary().verify_code(phone, code)
```
- [ ] **Step 4: 迁移现有 dispatch 测试断言**
`tests/test_sms_dispatch.py` 中,把 3 处 `assert sms.send_code("13800138000") == 60`line 20/22/24)改为:
```python
assert sms.send_code("13800138000").cooldown_sec == 60
```
line 45-53 的 `test_unknown_provider_falls_back_to_jiguang``sms.send_code(...)` 不比较返回值,无需改;`test_verify_code_routes_by_provider` 不涉及返回值,无需改。)
- [ ] **Step 5: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_fallback.py tests/test_sms_dispatch.py -q`
Expected: PASS(新用例 + 原 dispatch 3 用例全绿)
- [ ] **Step 6: 提交**
```bash
git add app/integrations/sms/__init__.py tests/test_sms_dispatch.py tests/test_sms_fallback.py
git commit -m "feat(sms): send_code 返回 SendResult(主路径),迁移 dispatch 断言"
```
---
## Task 4: 分派层 `send_code` fallback 分支(仅 503 转备)
**Files:**
- Modify: `app/integrations/sms/__init__.py``send_code`
- Test: `tests/test_sms_fallback.py`
- [ ] **Step 1: 写失败测试**
`tests/test_sms_fallback.py` 追加:
```python
def test_send_code_503_falls_back_to_configured_backup(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("极光不可用", status_code=503)))
monkeypatch.setattr(chuanglan, "send_code",
lambda phone: (calls.append("chuanglan"), 60)[1])
r = sms.send_code(PHONE)
assert r.provider == "chuanglan"
assert r.fallback is True
assert r.cooldown_sec == 60
assert calls == ["chuanglan"]
@pytest.mark.parametrize("status", [429, 400])
def test_send_code_non_503_does_not_fall_back(monkeypatch, status):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("x", status_code=status)))
monkeypatch.setattr(chuanglan, "send_code",
lambda phone: (calls.append("chuanglan"), 60)[1])
with pytest.raises(SmsError) as ei:
sms.send_code(PHONE)
assert ei.value.status_code == status
assert calls == [] # 备未被调用
def test_send_code_503_without_backup_reraises(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "") # 无备
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("极光不可用", status_code=503)))
with pytest.raises(SmsError) as ei:
sms.send_code(PHONE)
assert ei.value.status_code == 503
def test_send_code_backup_also_fails_raises_backup_error(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("极光不可用", status_code=503)))
monkeypatch.setattr(chuanglan, "send_code",
_raiser(SmsError("创蓝也不可用", status_code=503)))
with pytest.raises(SmsError) as ei:
sms.send_code(PHONE)
assert "创蓝" in str(ei.value)
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_fallback.py -k "fall_back or 503 or non_503 or backup" -q`
Expected: FAIL — 主抛 503 时当前 `send_code` 未捕获,直接透出(`test_...falls_back...` 期望走到创蓝但没走)
- [ ] **Step 3: 实现 fallback 分支**
`app/integrations/sms/__init__.py``send_code` 替换为:
```python
def send_code(phone: str) -> SendResult:
"""发码:主成功即返回;仅主「供应商不可用(503)」且配置了备时转备补发。
429(本地冷却/超频)、400(手机号无效)不转——不绕过防刷、不为无效号白烧。
备也失败则抛备的 SmsError。返回 SendResultcooldown + 实际渠道 + 是否 fallback)。
"""
primary = _primary()
fb = _fallback()
try:
cooldown = primary.send_code(phone)
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 = fb.send_code(phone) # 备的冷却/错误码原样透出
return SendResult(cooldown_sec=cooldown, provider=_NAME[fb], fallback=True)
raise
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_fallback.py -q`
Expected: PASS(含 parametrize 的 429/400 两条)
- [ ] **Step 5: 提交**
```bash
git add app/integrations/sms/__init__.py tests/test_sms_fallback.py
git commit -m "feat(sms): send_code 仅主 503 时转创蓝补发(429/400 不转)"
```
---
## Task 5: 分派层 `verify_code` try-both(只遍历启用的链)
**Files:**
- Modify: `app/integrations/sms/__init__.py``verify_code`
- Test: `tests/test_sms_fallback.py`
- [ ] **Step 1: 写失败测试**
`tests/test_sms_fallback.py` 追加:
```python
def test_verify_hits_primary_without_touching_backup(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code",
lambda p, c: (calls.append("jiguang"), True)[1])
monkeypatch.setattr(chuanglan, "verify_code",
lambda p, c: (calls.append("chuanglan"), True)[1])
assert sms.verify_code(PHONE, "123456") is True
assert calls == ["jiguang"] # 主命中即停,不查备
def test_verify_falls_through_to_backup(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code",
lambda p, c: (calls.append("jiguang"), False)[1])
monkeypatch.setattr(chuanglan, "verify_code",
lambda p, c: (calls.append("chuanglan"), True)[1])
assert sms.verify_code(PHONE, "123456") is True
assert calls == ["jiguang", "chuanglan"] # 主未命中 → 查备(码在备)
def test_verify_all_miss_returns_false(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code", lambda p, c: False)
monkeypatch.setattr(chuanglan, "verify_code", lambda p, c: False)
assert sms.verify_code(PHONE, "123456") is False
def test_verify_backup_not_touched_when_fallback_off(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "") # 关闭 fallback
monkeypatch.setattr(jiguang, "verify_code",
lambda p, c: (calls.append("jiguang"), False)[1])
monkeypatch.setattr(chuanglan, "verify_code",
lambda p, c: (calls.append("chuanglan"), True)[1])
assert sms.verify_code(PHONE, "123456") is False
assert calls == ["jiguang"] # 关闭时创蓝零参与
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_fallback.py -k verify -q`
Expected: FAIL — `test_verify_falls_through_to_backup`(当前 verify 只问主,主 False 即返 False,不查备)
- [ ] **Step 3: 实现 try-both**
`app/integrations/sms/__init__.py``verify_code` 替换为:
```python
def verify_code(phone: str, code: str) -> bool:
"""校验:try-both,遍历「启用的 fallback 链」(主→备),任一命中即 True。
码只存在实际发码那家(fallback 前主已 pop 掉自己的码),另一家 rec is None 即 False、
不误判、不累加其防爆破计数。关闭 fallback 时链中只有主,备完全不参与。
"""
chain = [_primary()]
fb = _fallback()
if fb is not None:
chain.append(fb)
for prov in chain:
if prov.verify_code(phone, code): # Mode B:纯本地内存比对,不联网
logger.info("[SMS] verify hit provider=%s", _NAME[prov])
return True
return False
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_fallback.py tests/test_sms_dispatch.py -q`
Expected: PASS(全部 fallback + 原 dispatch 用例)
- [ ] **Step 5: 提交**
```bash
git add app/integrations/sms/__init__.py tests/test_sms_fallback.py
git commit -m "feat(sms): verify_code try-both 遍历启用的 fallback 链"
```
---
## Task 6: auth `sms_send` 接入 SendResult + 记 provider/fallback
**Files:**
- Modify: `app/api/v1/auth.py`line 210 调用、line 231-243 成功侧 details
- Test: `tests/test_auth.py`(加 1 用例)
- [ ] **Step 1: 写失败测试**
先确认现有 `test_auth.py``sms_send` 的调用/断言与 fixture 写法(找一个已有的 sms_send 成功用例作参照),再在 `tests/test_auth.py` 末尾追加:
```python
def test_sms_send_records_provider_in_details(client, db_session, monkeypatch):
"""sms_send 成功后风控流水 details 带 provider/fallbackmock 下 jiguang/False)。"""
from app.repositories import risk as risk_repo
captured = {}
orig = risk_repo.record_behavior_event
def _spy(db, **kw):
if kw.get("event_type") == risk_repo.EVENT_SMS_SEND and kw.get("outcome") == "success":
captured.update(kw.get("details") or {})
return orig(db, **kw)
monkeypatch.setattr(risk_repo, "record_behavior_event", _spy)
resp = client.post("/api/v1/auth/sms/send",
json={"phone": "13800009999", "device_id": "dev-provider-test"})
assert resp.status_code == 200, resp.text
assert captured.get("provider") == "jiguang"
assert captured.get("fallback") is False
```
> 注:`client` / `db_session` fixture 名、请求路径前缀(`/api/v1/auth/...`)、`record_behavior_event` 的 import 路径以 `tests/test_auth.py` 现有用例为准,若不同则对齐后再写。
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_auth.py::test_sms_send_records_provider_in_details -q`
Expected: FAIL — `captured.get("provider")` 为 None(当前 details 只有 `{"mock": ...}`
- [ ] **Step 3: 实现**
`app/api/v1/auth.py``sms_send` 中:
(a) line 209-210 的
```python
try:
cooldown = send_code(req.phone)
```
改为
```python
try:
send_result = send_code(req.phone)
cooldown = send_result.cooldown_sec
```
(b) line 231-243 成功侧 `record_behavior_event``details`
```python
details={"mock": settings.SMS_MOCK},
```
改为
```python
details={"mock": settings.SMS_MOCK,
"provider": send_result.provider,
"fallback": send_result.fallback},
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_auth.py::test_sms_send_records_provider_in_details -q`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/api/v1/auth.py tests/test_auth.py
git commit -m "feat(sms): sms_send 记 provider/fallback 到风控流水 details"
```
---
## Task 7: `.env.example` + 全量回归 + ruff
**Files:**
- Modify: `.env.example`
- [ ] **Step 1: 加配置注释**
`.env.example``SMS_PROVIDER` 附近加:
```bash
# 极光→创蓝 容灾:主 provider(SMS_PROVIDER)供应商不可用(503)时转投的备。
# 空=关闭 fallback(默认);生产设 chuanglan 开启极光主、创蓝备;置空即秒回退。
SMS_FALLBACK_PROVIDER=
```
- [ ] **Step 2: 全量 SMS 相关回归**
Run: `python -m pytest tests/test_sms_fallback.py tests/test_sms_dispatch.py tests/test_sms_chuanglan.py tests/test_sms_aliyun.py tests/test_auth.py -q`
Expected: PASS(新增全绿;原 SMS/auth 用例不回归)
- [ ] **Step 3: lint**
Run: `python -m ruff check app/integrations/sms/ app/core/config.py app/api/v1/auth.py tests/test_sms_fallback.py tests/test_sms_base.py`
Expected: `All checks passed!`
- [ ] **Step 4: 基线对比(确认无新增失败)**
Run: `python -m pytest -q`
Expected: 失败数 ≤ 基线(已知先前债 ~8 个);本改动不新增失败。若有新增,定位并修。
- [ ] **Step 5: 提交**
```bash
git add .env.example
git commit -m "docs(sms): .env.example 加 SMS_FALLBACK_PROVIDER 说明"
```
---
## Self-Review
**Spec 覆盖核对**(对 `2026-07-30-sms-jiguang-chuanglan-fallback-design.md`):
- §2-A 仅 503 转 / 429·400 不转 → Task 4`test_send_code_503_falls_back` + `test_send_code_non_503_does_not_fall_back`)✅
- §2-B try-both 只遍历启用链 → Task 5(4 个 verify 用例,含关闭时备零参与)✅
- §2-C details 记 provider/fallback + 日志 → Task 6details+ Task 4/5logger.warning/info)✅
- §2-D 配置开关默认空、生产设开 → Task 2 + Task 7 ✅
- §4.1 主/备解析(未知/同主→None)→ Task 3`_primary`/`_fallback`)✅
- §4.2 SendResult + 只认 503 + 备失败抛备错 → Task 1 + Task 4`test_send_code_backup_also_fails`)✅
- §4.4 码只在一家/防爆破不被绕过 → provider 内部行为(不改),try-both 逻辑 Task 5 覆盖;防爆破由 provider 各自既有测试保证 ✅
- §4.5 auth 一处改、verify 调用点不变 → Task 6 ✅
- §5 配置项 → Task 2 ✅
- §6 mock 永不 fallback → mock 下主 send_code 成功不抛异常,天然不进 fallback 分支(现有 mock 测试在 Task 7 回归保证)✅
- §10 send_code int→SendResult 仅 auth 一处 + dispatch 测试 → Task 3/6 ✅
**占位扫描**:无 TBD/TODO;每个 code step 均含完整代码与命令。Task 6 Step 1 标注了「以现有 fixture 为准」——这是**对齐现有约定**的指示,非占位(fixture 名在同文件已存在,执行时照抄)。
**类型/命名一致性**`SendResult(cooldown_sec, provider, fallback)` 三字段在 Task 1/3/4/6 全程一致;`_primary()`/`_fallback()`/`_ALL`/`_NAME` 命名跨 Task 3/4/5 一致;`SMS_FALLBACK_PROVIDER` 跨 Task 2/3/4/5/7 一致。
---
## 执行顺序说明
Task 1→2→3 有依赖(后者用前者的类型/配置/分派骨架),必须顺序执行。Task 4/5 都改 `send_code`/`verify_code`,也应在 Task 3 之后顺序做。Task 6 依赖 Task 3 的 `SendResult` 返回。Task 7 收尾。全程一条功能分支 `feat/sms-jiguang-chuanglan-fallback`,每 Task 一提交。
@@ -0,0 +1,208 @@
# 短信验证码 极光→创蓝 容灾 fallback — 设计方案
- 日期:2026-07-30
- 状态:待评审
- 范围:短信发送验证码服务,默认极光;**极光供应商不可用时自动转创蓝补发**;发/校验全程后台可区分本次走的是极光还是创蓝。
- 关联:`docs/superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md`(创蓝 provider 接入)
## 1. 背景与目标
短信验证码已是**可切换 provider** 架构(`app/integrations/sms/``__init__` 分派 + `jiguang` / `aliyun` / `chuanglan` + `base`)。极光(默认)与创蓝都是 **Mode B(自管码)**:本服务 `secrets` 生成码 → 存**各自模块的进程内存** → provider 只负责发送;`verify_code` 是**纯本地内存比对**,不联网。
现状是"单选一个 provider"`send_code` / `verify_code` 都只走 `SMS_PROVIDER` 指定的那一家。极光一旦供应商侧故障(欠费 / 网络 / 服务异常),`/sms/send` 直接 503,用户收不到码、登录中断。
**目标**:把极光设为主、创蓝设为备,在**极光供应商不可用**时自动转创蓝补发,最大化验证码送达率;同时让后台能查到每次发码/校验实际走的是哪家、是否发生了 fallback。
**非目标**:不改极光/创蓝各自的 Mode B 机器(发码/存码/校验/防刷),不引入 DB/Redis 共享存码,不做多 worker 共享(沿用现有内存态技术债)。
## 2. 关键决策(已与需求方确认)
| # | 决策 | 结论 |
|---|---|---|
| A | **fallback 触发范围** | **仅主 provider 返回"供应商不可用"`SmsError.status_code == 503`:网络错误 / 余额不足 / 服务故障)时转备。** 本地单号 60s 冷却与极光侧超频(均 429)、手机号无效(400)**不转**——不绕过防刷、不为无效号白烧一条。 |
| B | **校验路由** | **try-both,但只遍历"当前启用的 fallback 链"。** verify 依次问链上 provider(主→备),任一命中即通过;关闭 fallback 时链中只有主,创蓝完全不参与。 |
| C | **后台可见性** | **风控行为流水 + 结构化日志。** 复用 `risk_repo.record_behavior_event(EVENT_SMS_SEND)``details``provider` / `fallback` 字段;dispatch 层对 fallback 触发、校验命中打点。不新增表、不改客户端响应。 |
| D | **默认开关** | **配置开关 opt-in,生产设开。** 新增 `SMS_FALLBACK_PROVIDER`,默认空=关(保持现状零风险);生产 `.env``chuanglan` 开启。出问题置空即秒回退、不改代码。 |
补充约束:**fallback 仅在 Mode B ↔ Mode Bjiguang / chuanglan)之间有意义**。aliyun 是 Mode A(阿里云托管码、校验走接口),不参与 fallback 链。
## 3. 现状回顾(设计依赖的事实)
- **分派层** [`app/integrations/sms/__init__.py`](../../../app/integrations/sms/__init__.py)`_provider()` 每次按 `settings.SMS_PROVIDER` 选一个模块(默认 jiguang),`send_code` / `verify_code` 委托给它。
- **极光 / 创蓝均 Mode B**,各持一份**独立**的 `_codes` / `_last_sent` / `_lock`(两个互不相通的进程内存 dict)。`verify_code` 纯本地比对(`_codes.get(phone)` + `secrets.compare_digest`),**不发网络**;provider 的网络调用只在**发送**一步。
- **`SmsError(message, status_code)`**[`base.py`](../../../app/integrations/sms/base.py)):`429`=过频/超频(客户端稍后再来)、`503`=供应商不可用、`400`=手机号无效。这是 A 决策用来判定"是否 fallback"的唯一信号。
- **极光/创蓝 `send_code` 失败时"保留冷却、清掉未发出的码"**`_last_sent[phone]=now` 保留、`_codes.pop(phone)`。此行为不改(见 §4.3 冷却交互)。
- **auth 层落点**`sms_send` 发码成功 [auth.py:231](../../../app/api/v1/auth.py) 记 `EVENT_SMS_SEND` `outcome="success" details={"mock":...}`;失败 [auth.py:212](../../../app/api/v1/auth.py) 记 `outcome="failed" reason=...``send_code` 是内部函数,全仓仅此一处调用。`verify_code``sms_login` 与换绑两处调用,签名 `(phone, code)->bool`
## 4. 设计
### 4.1 fallback 链模型(分派层,`__init__.py`
引入"主 + 备"两级链,全部在分派层组装,**provider 文件一行不动**
```python
_ALL = {"jiguang": jiguang, "aliyun": aliyun, "chuanglan": chuanglan}
_NAME = {jiguang: "jiguang", aliyun: "aliyun", chuanglan: "chuanglan"}
def _primary():
# 未知/缺省回退 jiguang(保持 test_unknown_provider_falls_back_to_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) # 未知名 → None
```
### 4.2 发码 `send_code(phone) -> SendResult`(透出实际渠道)
现返回 `int`(cooldown),改为返回携带渠道信息的小结果对象(放 `base.py`provider 无关):
```python
@dataclass(frozen=True)
class SendResult:
cooldown_sec: int # 距下次可发秒数(= 实际发码 provider 的 SMS_SEND_INTERVAL_SEC
provider: str # 实际发码的 provider 名:"jiguang" / "chuanglan"
fallback: bool # 是否走了备(True=主不可用转备)
```
分派逻辑:
```python
def send_code(phone) -> SendResult:
primary, fb = _primary(), _fallback()
try:
cooldown = primary.send_code(phone)
return SendResult(cooldown, _NAME[primary], fallback=False)
except SmsError as e:
# A 决策:仅"供应商不可用(503)"且配置了备时转备;429/400 原样抛
if fb is not None and e.status_code == 503:
logger.warning("[SMS] primary=%s 不可用(%s)fallback→%s",
_NAME[primary], e, _NAME[fb])
cooldown = fb.send_code(phone) # 备也可能抛 SmsError(其冷却/错误码)→ 原样透出
return SendResult(cooldown, _NAME[fb], fallback=True)
raise
```
要点:
- **只认 `status_code == 503`**:极光的 429(本地冷却 / 超频 50009)、400(手机号无效)直接 raise,不 fallback。
- **备自带完整 Mode B 机器**:创蓝重新走自己的单号冷却 / 存码 / 错误码映射。备也失败则抛备的 `SmsError``auth` 按其 `status_code` 返回。
- **主/备文件不动**`jiguang.send_code` / `chuanglan.send_code` 仍返回 `int`;由分派层包装成 `SendResult`
### 4.3 冷却交互(已知行为,保守安全,不改 provider)
极光 `send_code` 失败(503)时**保留自己的冷却**(现有行为,防"欠费/签名失效时前端狂打极光")。在 fallback 下的连锁:
1. 首发:极光 503(记极光冷却)→ 转创蓝发成功(记创蓝冷却)。用户收到创蓝的码。
2. 60s 内重发:主=极光**冷却中 → 429**,`429 ≠ 503`**不 fallback** → 用户收到 429"过于频繁"。**这是期望行为**:挡住 60s 内重复补发烧钱;且此时创蓝也在自己冷却期。
3. 极光**持续故障**:每过 60s,极光冷却过期→再试极光(仍 503)→转创蓝补发。稳态下每 60s 白试一次极光、成功走创蓝,功能可用。
结论:现有"失败保留冷却"语义在 fallback 下自洽且保守。**不改极光/创蓝任何冷却逻辑。**
### 4.4 校验 `verify_code(phone, code) -> bool`try-both over 启用链)
```python
def verify_code(phone, code) -> bool:
chain = [_primary()]
fb = _fallback()
if fb is not None:
chain.append(fb)
for prov in chain:
if prov.verify_code(phone, code): # Mode B:纯本地内存比对,不联网、不抛异常
logger.info("[SMS] verify hit provider=%s", _NAME[prov])
return True
return False
```
要点:
- **关闭 fallback 时 `chain=[极光]`**,与现状字节级等价,**创蓝完全不参与**。
- **码只存在实际发码那家**fallback 发码前极光已 `_codes.pop` 清掉自己的码)→ 同一手机号同一时刻只有一家有码;另一家 `rec is None` 立即 False,不误判、不累加其 `attempts`。**防爆破仍在真实那家正常生效,不被绕过**。
- **命中即知渠道**(日志打点),天然满足 C 的校验侧可见性,无需显式记忆 phone→provider。
- 绝大多数登录:极光发成功→码在极光→第一家命中,创蓝那次内存查根本不发生。
- Mode B 的 `verify_code` 不抛 `SmsError`;若链中混入 Mode A(aliyun,本设计约束外)其 verify 降级抛 503,应原样透出中断——但按 §2 约束 aliyun 不入链,此路不触发。
### 4.5 auth.py 改动(最小)
- **`sms_send`**(唯一改动点):
```python
result = send_code(req.phone) # 现 SendResult
cooldown = result.cooldown_sec
# 成功侧 details 加 provider/fallbackC
details={"mock": settings.SMS_MOCK, "provider": result.provider, "fallback": result.fallback}
```
`SmsSendResponse(cooldown_sec=cooldown, ...)` 不变——**不把 provider 返回客户端**(C 选风控流水,非接口返回)。
- **`verify_code` 两处调用点不变**:签名 `(phone,code)->bool` 未改;校验命中渠道由分派层 §4.4 日志打点,auth 无需感知。
- **失败可观测**:极光 503 且创蓝也失败时,抛创蓝的 `SmsError` → 现有失败侧 `record_behavior_event(reason=str(e))` 记录;fallback 触发/失败另有 §4.2 分派层 `logger.warning`
### 4.6 可观测性汇总(满足 C)
| 事件 | 落点 | 内容 |
|---|---|---|
| 发码成功 | `EVENT_SMS_SEND` `details` | `provider`jiguang/chuanglan)、`fallback`bool |
| fallback 触发 | 分派层 `logger.warning` | 主不可用原因 + 转向的备 |
| 校验命中 | 分派层 `logger.info` | 命中的 `provider` |
| 发码彻底失败 | `EVENT_SMS_SEND` `outcome=failed` | `reason`(末端 SmsError 文案) |
## 5. 配置项(`app/core/config.py` 新增)
```python
# 主 provider 供应商不可用(503)时转投的备 provider;空=关闭 fallback(默认,保持现状)。
# 仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。
SMS_FALLBACK_PROVIDER: Literal["jiguang", "chuanglan", ""] = "" # Mode B only(aliyun 不入链)
```
- 复用现有 `SMS_PROVIDER`(主,默认 jiguang)与两家的凭证/旋钮(`CHUANGLAN_SMS_*``SMS_CODE_LENGTH` 等),**无其它新增**。
- `.env.example``SMS_FALLBACK_PROVIDER=`(注释:生产设 `chuanglan` 开启极光→创蓝容灾)。
- 生产前置:创蓝凭证齐全(`chuanglan_sms_configured`)+ 创蓝控制台 IP 白名单,否则 fallback 到创蓝仍 503。
## 6. 错误处理与边界
- **主 503 + 未配备**`_fallback()` 返 None → 原样抛主的 503(= 现状)。
- **主 503 + 备也失败**:抛备的 `SmsError`(按备错误码 429/400/503),auth 照常返回。
- **主 429/400**:不 fallback,原样抛。
- **mock 模式(`SMS_MOCK=true`**:主 `send_code` mock 分支直接成功、不抛异常 → **永不触发 fallback**`verify` mock 分支放行任意 N 位 → try-both 第一家(主)即 True。**现有 mock 测试全绿不变。**
- **未知 `SMS_FALLBACK_PROVIDER` 名**`_ALL.get` 返 None → 视为未配置、不 fallback(不崩)。
- **`SMS_FALLBACK_PROVIDER == SMS_PROVIDER`**`_fallback()` 返 None(不自我 fallback)。
## 7. 测试计划
新增 `tests/test_sms_fallback.py`monkeypatch 各 provider 的 `send_code`/`verify_code`,不真发):
1. 主发成功 → `SendResult(provider=主, fallback=False)`,不调备。
2. 主 503 + 配备 → 转备,备成功 → `SendResult(provider=备, fallback=True)`
3. 主 429 / 400 → 不 fallback,原样抛(断言备未被调用)。
4. 主 503 + 未配备 → 抛 503。
5. 主 503 + 备也 503 → 抛备的 SmsError。
6. verify:码在主 → 命中主;码在备 → 命中备;两家都无 → False。
7. **关闭 fallback**`SMS_FALLBACK_PROVIDER=""`)→ verify 只问主,**断言备 `verify_code` 未被调用**;send 主失败原样抛、不转备。
8. 防爆破:码在主,连续错到 `SMS_MAX_VERIFY_ATTEMPTS` 作废;全程备 `rec is None` 不受影响。
auth 层扩 `tests/test_auth.py``sms_send` 成功后 `EVENT_SMS_SEND``details``provider`/`fallback`mock 下 provider=jiguang、fallback=False)。
回归:`test_sms_dispatch.py`、现有 `test_auth.py` 中依赖 `send_code` 返回 `int` 的断言改为读 `SendResult.cooldown_sec`
## 8. YAGNI(明确不做)
- ❌ 不改极光/创蓝的 Mode B 机器(发/存/校验/冷却/防爆破)。
- ❌ 不做 DB/Redis 共享存码或 phone→provider 记忆表(多 worker 债维持现状、两家一起将来迁)。
- ❌ 不做 aliyunMode A)参与 fallback。
- ❌ 不做多级(>2)fallback 链、不做主备自动健康探测/熔断。
- ❌ 不把 provider 渠道透传给客户端(仅后台可见)。
- ❌ 不做发送量/成功率统计报表(C 只取"风控流水+日志",报表另议)。
## 9. 验收标准
- `SMS_FALLBACK_PROVIDER=chuanglan` 且创蓝配置齐全时:极光注入 503(欠费/网络)→ `/sms/send` 实际由创蓝下发、真机可收码并登录;`EVENT_SMS_SEND.details.provider=chuanglan``fallback=true`
- 极光正常时:走极光,`provider=jiguang``fallback=false`;创蓝零调用。
- `SMS_FALLBACK_PROVIDER=""`(默认):行为与当前完全一致,创蓝不参与发/校验。
- 极光 429/400:不转创蓝,原样返回。
- `SMS_MOCK=true`:任意 N 位通过、不真发、不触发 fallback。
- `ruff check .` 通过;新增/现有 `pytest` 全绿。
## 10. 影响面 / 回滚
- **改动文件**`sms/__init__.py`(分派层,主要)、`sms/base.py`(加 `SendResult`)、`core/config.py`(加开关)、`api/v1/auth.py``sms_send` 一处记 details)、`.env.example`、测试。**`jiguang.py` / `chuanglan.py` / `aliyun.py` 不动。**
- **破坏性接口变更**`send_code` 返回 `int → SendResult`,仅 `auth.py:210` 一处调用 + 相关测试需同步。
- **回滚**:置 `SMS_FALLBACK_PROVIDER=""` 即运行时秒回退到"纯极光",无需改代码/迁移。
+66 -10
View File
@@ -16,6 +16,10 @@ xiaomi/oppo/vivo)到该用户 device_liveness 里已注册的 push token。
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --count 1 # 各 1 条(先小量验证通道)
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --types withdraw_failed --count 3
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --types all --count 2
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --phone 22222222222 --token ABC123 --count 1
`--token` 是可选不区分大小写的 token 子串过滤器;会推送给该账号下所有 token 包含该
子串的设备没有匹配设备时脚本直接停止,不会生成站内消息
推送成败看输出里的 `shagua.vendor_push` 日志(push sent / push failed);2 台设备则每条各推 2
凭据缺失或 token 失效时 notification_events 只记日志不抛错(站内消息仍会落库)
@@ -58,33 +62,52 @@ _FAIL_REASONS = [
]
def _fire_one(db, uid: int, type_key: str, i: int) -> None:
def _fire_one(
db,
uid: int,
type_key: str,
i: int,
*,
push_token_contains: str | None = None,
) -> None:
"""构造一条该类型的瞬态业务对象(不落业务表,只为给 notify 函数读字段),触发真实推送。"""
if type_key == "withdraw_success":
order = WithdrawOrder(user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=50, source="coin_cash")
notification_events.notify_withdraw_success(db, order)
notification_events.notify_withdraw_success(
db, order, push_token_contains=push_token_contains
)
elif type_key == "withdraw_failed":
order = WithdrawOrder(
user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=350, source="coin_cash",
fail_reason=random.choice(_FAIL_REASONS),
)
notification_events.notify_withdraw_failed(db, order)
notification_events.notify_withdraw_failed(
db, order, push_token_contains=push_token_contains
)
elif type_key == "invite_order_reward":
# 假被邀请人 id(> 真实用户范围,避重):昵称回退「好友」。真实昵称请走 API 流程(见文末说明)。
fake_invitee = random.randint(900000, 999999)
notification_events.notify_invite_order_reward(
db, inviter_user_id=uid, invitee_user_id=fake_invitee, cash_cents=INVITE_COMPARE_REWARD_CENTS
db,
inviter_user_id=uid,
invitee_user_id=fake_invitee,
cash_cents=INVITE_COMPARE_REWARD_CENTS,
push_token_contains=push_token_contains,
)
elif type_key == "feedback_reward":
fb = Feedback(user_id=uid, content="(直发)", contact="", status="adopted",
reward_coins=300, admin_reply="感谢反馈,您说的问题已修复上线,金币请查收~")
fb.id = random.randint(900000, 999999)
notification_events.notify_feedback_reward(db, fb)
notification_events.notify_feedback_reward(
db, fb, push_token_contains=push_token_contains
)
elif type_key == "feedback_reply":
fb = Feedback(user_id=uid, content="(直发)", contact="", status="rejected",
admin_reply="您的建议我们记录啦,会在后续版本评估~")
fb.id = random.randint(900000, 999999)
notification_events.notify_feedback_reply(db, fb)
notification_events.notify_feedback_reply(
db, fb, push_token_contains=push_token_contains
)
elif type_key == "report_approved":
rep = PriceReport(
user_id=uid, reported_platform_id="jd", reported_platform_name="京东外卖",
@@ -92,7 +115,9 @@ def _fire_one(db, uid: int, type_key: str, i: int) -> None:
reward_coins=PRICE_REPORT_REWARD_COINS, store_name=f"测试火锅店{i:02d}",
)
rep.id = random.randint(900000, 999999)
notification_events.notify_report_approved(db, rep)
notification_events.notify_report_approved(
db, rep, push_token_contains=push_token_contains
)
else:
raise SystemExit(f"未知类型: {type_key}(可选: {', '.join(ALL_TYPES)})")
@@ -100,6 +125,11 @@ def _fire_one(db, uid: int, type_key: str, i: int) -> None:
def main() -> None:
parser = argparse.ArgumentParser(description="直接触发消息通知中心真实推送(后台驱动不了的事件用)")
parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})")
parser.add_argument(
"--token",
default="",
help="可选的厂商 token 子串(不区分大小写);仅推送到该账号下所有匹配设备",
)
parser.add_argument("--count", type=int, default=10, help="每类发多少条(默认 10)")
parser.add_argument(
"--types", default=",".join(DEFAULT_TYPES),
@@ -122,14 +152,40 @@ def main() -> None:
uid = user.id
targets = device_repo.list_push_targets(db, user_id=uid)
print(f"目标用户 {args.phone}(id={uid});已注册推送设备 {len(targets)} 台:"
f"{[t.push_vendor for t in targets] or '无(手机收不到!先在 App 上报 push token)'}")
token_filter = args.token.strip()
normalized_filter = token_filter.casefold()
matched_targets = [
target
for target in targets
if not token_filter
or normalized_filter in (target.push_token or "").casefold()
]
if token_filter and not matched_targets:
print(
f"❌ 用户 {args.phone}{len(targets)} 台有效推送设备中,"
f"没有 token 包含 {token_filter!r};未生成站内消息,也未发送推送。"
)
return
print(
f"目标用户 {args.phone}(id={uid});有效推送设备 {len(targets)} 台;"
f"本次匹配 {len(matched_targets)} 台:"
f"{[target.push_vendor for target in matched_targets] or '无(手机收不到!先在 App 上报 push token)'}"
)
if token_filter:
print(f"token 子串过滤(不区分大小写): {token_filter!r}")
print(f"即将触发:{types},每类 {args.count} 条 → 共 {len(types) * args.count}\n")
for t in types:
print(f"── {t} ×{args.count} " + "" * 30)
for i in range(1, args.count + 1):
_fire_one(db, uid, t, i)
_fire_one(
db,
uid,
t,
i,
push_token_contains=token_filter or None,
)
print(f"\n✅ 已触发完。站内消息已落 notification 表(用 {args.phone} 登录 App 可在消息中心看到);"
"\n 手机推送成败见上方 `shagua.vendor_push` 日志(push sent=成功 / push failed=失败)。")
+36 -3
View File
@@ -15,6 +15,10 @@ services/notification_events.notify_invite_order_reward —— 与生产同一
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py # 随机金额发 1 条
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --cents 200 # 固定 2.00 元
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --invitee-phone 12000000001 # 真实昵称
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --phone 22222222222 --token gkha
`--token` 是可选不区分大小写的 token 子串过滤器会推送给该账号下所有 token 包含该
子串的设备没有匹配设备时脚本直接停止不会生成站内消息
结果判读(看输出日志):
push sent = 厂商接口受理成功,手机应弹好友下单奖励到账通知
@@ -62,6 +66,11 @@ def _notif_count(db, uid: int) -> int:
def main() -> None:
parser = argparse.ArgumentParser(description="#12 好友下单到账 推送联调(每次 1 条,金额默认随机)")
parser.add_argument("--phone", default="11111111111", help="邀请人(收通知方)手机号,默认 11111111111")
parser.add_argument(
"--token",
default="",
help="可选的厂商 token 子串(不区分大小写);仅推送到该账号下所有匹配设备",
)
parser.add_argument("--cents", type=int, default=None,
help="奖励金额,单位分(默认随机 1~9999;线上真实值 200)")
parser.add_argument("--invitee-phone", default="",
@@ -87,13 +96,37 @@ def main() -> None:
invitee_id = random.randint(900000, 999999) # 假 id,昵称兜底「好友」,永不去重
targets = device_repo.list_push_targets(db, user_id=user.id)
vendors = [t.push_vendor for t in targets]
print(f"邀请人 {args.phone}(id={user.id});推送设备 {len(targets)} 台:{vendors or '无 ← 手机收不到!先在 App 上报 push token'}")
token_filter = args.token.strip()
normalized_filter = token_filter.casefold()
matched_targets = [
target
for target in targets
if not token_filter
or normalized_filter in (target.push_token or "").casefold()
]
if token_filter and not matched_targets:
print(
f"❌ 用户 {args.phone}{len(targets)} 台有效推送设备中,"
f"没有 token 包含 {token_filter!r};未生成站内消息,也未发送推送。"
)
return
print(
f"邀请人 {args.phone}(id={user.id});有效推送设备 {len(targets)} 台;"
f"本次匹配 {len(matched_targets)} 台:"
f"{[target.push_vendor for target in matched_targets] or '无 ← 手机收不到!先在 App 上报 push token'}"
)
if token_filter:
print(f"token 子串过滤(不区分大小写): {token_filter!r}")
before = _notif_count(db, user.id)
print(f"→ 本次奖励 【{cents / 100:.2f} 元】(invitee_user_id={invitee_id}),手机上按金额认领这条通知")
notification_events.notify_invite_order_reward(
db, inviter_user_id=user.id, invitee_user_id=invitee_id, cash_cents=cents
db,
inviter_user_id=user.id,
invitee_user_id=invitee_id,
cash_cents=cents,
push_token_contains=token_filter or None,
)
created = _notif_count(db, user.id) - before
+96
View File
@@ -0,0 +1,96 @@
"""发奖公式下限:看了就保底 1 金币,与前端展示公式完全对齐。
产品口径(2026-08看了就保底 1):`calculate_ad_reward_coin` **任何输入**都至少返回 1,
镜像客户端 FeedRewardFormula.singleUnitCoin(那边:eCPM /非法/非正数都返 1)此前 eCPM
缺失/ 0 0,与前端小球显示的 +1 不一致,且信息流侧把这类看满一份的广告记成 too_short 零发
公式是发奖与后台审计对账的唯一口径,改这一处两边同源
:激励视频(S2S 回调)路径在公式之前还有一道 `if not ecpm_raw` 早退**完全没上报 eCPM**
的回调仍记 ecpm_missing 零发( test_ad_reward.test_callback_without_ecpm_records_exception),
那是"回调缺字段"的数据完整性闸,区别于"广告如实上报 eCPM=0"(=看了真广告 保底 1)
"""
from __future__ import annotations
from app.core.rewards import calculate_ad_reward_coin
def test_low_positive_ecpm_floors_to_one_coin() -> None:
"""真实但极低的 eCPM 原本四舍五入成 0 金币,兜底为 1(线上 record 4667 的 43 分场景)。"""
# 43 分 = ¥0.43 CPM,因子1=0.1,重度用户 LT 第 69 条=1.0 → 0.43/1000×0.1×1.0×10000=0.43 → 旧口径 round=0
assert calculate_ad_reward_coin("43", 69) == 1
# 更低的 5 分同理:算出来 <0.5,旧口径也是 0
assert calculate_ad_reward_coin("5", 11) == 1
def test_zero_or_missing_ecpm_also_floors_to_one() -> None:
"""eCPM 为 0 / 缺失 / 非法都保底 1(2026-08「看了就保底 1」,与前端 FeedRewardFormula 对齐)。
尤其 "0"(广告如实上报零价值)此前返 0,导致小球显示 +1后端信息流记 too_short 零发的
前后端不一致 现在两端都 1
"""
assert calculate_ad_reward_coin("0", 1) == 1
assert calculate_ad_reward_coin(None, 1) == 1
assert calculate_ad_reward_coin("", 1) == 1
assert calculate_ad_reward_coin("abc", 1) == 1
def test_normal_ecpm_value_unchanged() -> None:
"""正常量级 eCPM 本就 ≥1,下限不改变其取值(回归保护)。"""
# 200 分 = ¥2 CPM,因子1=0.1,LT 第 1 条=2.0 → 2/1000×0.1×2.0×10000 = 4
assert calculate_ad_reward_coin("200", 1) == 4
def test_feed_reward_low_ecpm_grants_one_coin_instead_of_too_short() -> None:
"""端到端:低 eCPM 的信息流广告看满一份(≥10s),原本单份金币算成 0 被记 too_short,
现在兜底发 1 金币状态 granted(线上 record 4667 那类看了却零发的修复)"""
from app.db.session import SessionLocal
from app.models.user import User
from app.repositories.ad_feed_reward import grant_feed_reward
db = SessionLocal()
try:
user = User(phone="19900000043", username="feedfloor43", register_channel="sms")
db.add(user)
db.commit()
db.refresh(user)
rec = grant_feed_reward(
db, user.id,
client_event_id="feed-floor-low-ecpm-0001",
ecpm="2", # ¥0.02 CPM,单份收益四舍五入 <0.5 → 旧口径算成 0
duration_seconds=15, # ≥10s → 看满一份,不走「时长不足」的 else 分支
ad_type="draw",
feed_scene="comparison",
)
assert rec.status == "granted"
assert rec.coin == 1
finally:
db.close()
def test_feed_reward_zero_ecpm_grants_one_coin() -> None:
"""端到端:eCPM 如实上报 0(用户反馈的真实广告返回 eCPM=0 场景),看满一份也保底 1、
状态 granted 与前端小球显示的 +1 一致,不再前显示后零发"""
from app.db.session import SessionLocal
from app.models.user import User
from app.repositories.ad_feed_reward import grant_feed_reward
db = SessionLocal()
try:
user = User(phone="19900000044", username="feedzero44", register_channel="sms")
db.add(user)
db.commit()
db.refresh(user)
rec = grant_feed_reward(
db, user.id,
client_event_id="feed-zero-ecpm-0001",
ecpm="0", # 广告如实上报 eCPM=0
duration_seconds=15, # 看满一份
ad_type="draw",
feed_scene="comparison",
)
assert rec.status == "granted"
assert rec.coin == 1
finally:
db.close()
+48 -4
View File
@@ -68,12 +68,12 @@ def test_list_config(admin_client: TestClient, token: str) -> None:
r = admin_client.get("/admin/api/config", headers=_auth(token))
assert r.status_code == 200, r.text
items = {i["key"]: i for i in r.json()}
# 非 hidden 项照常返回;看广告组保留可见的:每日上限 / 单次金币上限 / 关闭后冷却
assert "signin_rewards" in items and "ad_daily_limit" in items and "ad_cooldown_sec" in items
# hidden 项(任务/里程碑、首页轮播数据源、广告组的单次金币/每轮次数/信息流广告开关)不在配置页返回。
# 非 hidden 项照常返回;广告次数上限迁到「白名单」统一配置
assert "signin_rewards" in items and "ad_cooldown_sec" in items
# hidden 项(任务/里程碑、首页轮播数据源、广告次数/单次金币/每轮次数/信息流广告开关)不在配置页返回。
for hidden_key in (
"task_rewards", "record_milestones", "marquee_feed_mode",
"ad_reward_coin", "ad_round_count", "comparing_ad_enabled",
"ad_daily_limit", "ad_reward_coin", "ad_round_count", "comparing_ad_enabled",
):
assert hidden_key not in items, f"{hidden_key} 应被 hidden 过滤"
assert items["signin_rewards"]["value"] == [
@@ -124,6 +124,50 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N
db.close()
def test_list_config_reads_limit_values_from_global_bundle(
admin_client: TestClient,
token: str,
) -> None:
changed = admin_client.patch(
"/admin/api/config/ad_cooldown_sec",
json={"value": 17},
headers=_auth(token),
)
assert changed.status_code == 200, changed.text
items = {
item["key"]: item
for item in admin_client.get(
"/admin/api/config",
headers=_auth(token),
).json()
}
assert items["ad_cooldown_sec"]["value"] == 17
assert items["ad_cooldown_sec"]["overridden"] is True
@pytest.mark.parametrize(
("key", "value"),
[
("ad_daily_limit", 0),
("ad_daily_limit", 100_001),
("ad_cooldown_sec", 86_401),
],
)
def test_update_limit_config_rejects_out_of_range_values(
admin_client: TestClient,
token: str,
key: str,
value: int,
) -> None:
response = admin_client.patch(
f"/admin/api/config/{key}",
json={"value": value},
headers=_auth(token),
)
assert response.status_code == 400, response.text
def test_update_bool_config(admin_client: TestClient, token: str) -> None:
# 提现自动对账开关默认 True
items = {
+7 -2
View File
@@ -73,6 +73,7 @@ def test_monitoring_audit_catalog_and_api_permissions(
"analytics-health",
"event-logs",
"audit-logs",
"limit-whitelist",
]
# 运营默认可查风控和设备存活,不能绕过导航直调技术/审计接口。
@@ -82,6 +83,9 @@ def test_monitoring_audit_catalog_and_api_permissions(
assert admin_client.get(
"/admin/api/device-liveness/stats", headers=_auth(operator_token)
).status_code == 200
assert admin_client.get(
"/admin/api/limit-whitelist/rules", headers=_auth(operator_token)
).status_code == 200
for path in (
"/admin/api/analytics-health/overview?date_from=2026-07-01T00:00:00Z&date_to=2026-07-02T00:00:00Z",
"/admin/api/event-logs",
@@ -89,13 +93,14 @@ def test_monitoring_audit_catalog_and_api_permissions(
):
assert admin_client.get(path, headers=_auth(operator_token)).status_code == 403
# 技术角色默认拥有监控审计组全部项权限。
# 技术角色默认拥有监控审计组全部项权限。
for path in (
"/admin/api/risk-monitor/summary",
"/admin/api/device-liveness/stats",
"/admin/api/analytics-health/overview?date_from=2026-07-01T00:00:00Z&date_to=2026-07-02T00:00:00Z",
"/admin/api/event-logs",
"/admin/api/audit-logs",
"/admin/api/limit-whitelist/rules",
):
assert admin_client.get(path, headers=_auth(tech_token)).status_code == 200
@@ -199,7 +204,7 @@ def test_builtin_roles_labels_and_pages(admin_client, super_token) -> None:
}
assert set(roles["tech"]["pages"]) == {
"dashboard", "risk-monitor", "device-liveness", "analytics-health", "config",
"ad-revenue", "huawei-review", "event-logs", "audit-logs",
"ad-revenue", "huawei-review", "event-logs", "audit-logs", "limit-whitelist",
}
+23
View File
@@ -68,6 +68,29 @@ def test_sms_login_and_me_flow(client) -> None:
assert r.json()["ok"] is True
def test_sms_send_records_provider_and_fallback_in_details(client, monkeypatch) -> None:
"""批次3(Task 6):sms_send 成功后风控流水 details 带 provider/fallback。
mock 默认走极光主(SMS_FALLBACK_PROVIDER ) provider=jiguangfallback=False
(回归:修复前 send_code 返回 SendResult 被塞进 cooldown_sec:int 500,此处 200 断言即挡住)"""
from app.repositories import risk as risk_repo
captured: dict = {}
orig = risk_repo.record_behavior_event
def _spy(db, **kw):
if kw.get("event_type") == risk_repo.EVENT_SMS_SEND and kw.get("outcome") == "success":
captured.update(kw.get("details") or {})
return orig(db, **kw)
monkeypatch.setattr(risk_repo, "record_behavior_event", _spy)
r = client.post("/api/v1/auth/sms/send",
json={"phone": "13800009999", "device_id": "dev-provider-test"})
assert r.status_code == 200, r.text
assert captured.get("provider") == "jiguang"
assert captured.get("fallback") is False
def test_sms_send_too_frequent(client) -> None:
phone = "13900139000"
assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 200
+29 -2
View File
@@ -48,7 +48,10 @@ def test_compare_start_is_idempotent_by_trace_id(client) -> None:
retry = client.post("/api/v1/compare/start", json=payload, headers=_headers(token))
assert first.status_code == 200, first.text
assert first.json() == {"limit": 100, "used": 1, "remaining": 99}
# trace_id 回显客户端带来的值(老协议幂等路径)
assert first.json() == {
"limit": 100, "used": 1, "remaining": 99, "trace_id": payload["trace_id"],
}
assert retry.status_code == 200, retry.text
assert retry.json() == first.json()
with SessionLocal() as db:
@@ -69,6 +72,28 @@ def test_compare_start_is_idempotent_by_trace_id(client) -> None:
assert record.device_id == "quota-device"
def test_compare_start_issues_trace_id_when_absent(client) -> None:
"""新客户端不带 trace_id → 服务端签发并随响应返回,running 行以签发 id 建。"""
token, user_id = _login(client)
response = client.post(
"/api/v1/compare/start",
json={"business_type": "food", "device_id": "quota-device-issue"},
headers=_headers(token),
)
assert response.status_code == 200, response.text
body = response.json()
issued = body["trace_id"]
assert issued # 非空签发
assert body["used"] == 1
with SessionLocal() as db:
record = db.execute(
select(ComparisonRecord).where(ComparisonRecord.trace_id == issued)
).scalar_one()
assert record.user_id == user_id
assert record.status == "running"
assert record.device_id == "quota-device-issue"
def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
token, user_id = _login(client)
now = datetime.now(CN_TZ).replace(tzinfo=None)
@@ -101,7 +126,9 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
headers=_headers(token),
)
assert allowed.status_code == 200, allowed.text
assert allowed.json() == {"limit": 100, "used": 100, "remaining": 0}
assert allowed.json() == {
"limit": 100, "used": 100, "remaining": 0, "trace_id": final_allowed_trace,
}
rejected_trace = f"quota-rejected-{user_id}"
response = client.post(
+12 -6
View File
@@ -8,6 +8,7 @@ mock 掉对 pricebot 的 httpx 调用,验证:
"""
from __future__ import annotations
import json
import time
from unittest.mock import MagicMock, patch
@@ -62,7 +63,8 @@ def test_coupon_step_no_auth_required(client) -> None:
def test_coupon_step_passes_body_through(client, access_token) -> None:
"""带 token + pricebot 200 → 响应原样透传,请求 body 原样转发到 /api/coupon/step"""
"""带 token + pricebot 200 → 请求 body 原样转发到 /api/coupon/step;
响应在透传基础上顶层回显本次任务 trace_id(setdefault 注入,其余字段原样)"""
fake_pricebot_resp = {
"success": True,
"action": {
@@ -83,9 +85,12 @@ def test_coupon_step_passes_body_through(client, access_token) -> None:
captured: dict = {}
async def fake_post(self, url, json=None, **kw):
# ⚠️ coupon_step 转发用 content=raw(原始字节透传,不重新 dumps),不是 json= ——
# fake 必须捕 content。旧 fake 只捕 json= 导致 captured["json"] 恒 None,本测试
# 自 content=raw 优化后一直红着(pre-existing),本次顺手修正。
async def fake_post(self, url, content=None, **kw):
captured["url"] = url
captured["json"] = json
captured["content"] = content
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json = lambda: fake_pricebot_resp
@@ -99,9 +104,10 @@ def test_coupon_step_passes_body_through(client, access_token) -> None:
)
assert r.status_code == 200, r.text
assert r.json() == fake_pricebot_resp
# 验证请求被原样转发(body 不动 + URL 指向 pricebot)
assert captured["json"] == _stub_request_body()
# 顶层多出 trace_id 回显(值=请求带的;不 mint,签发点唯一在 /coupon/session started)
assert r.json() == {**fake_pricebot_resp, "trace_id": "test-trace-1"}
# 验证请求被原样转发(body 字节不动 + URL 指向 pricebot)
assert json.loads(captured["content"]) == _stub_request_body()
assert captured["url"].endswith("/api/coupon/step")
+65
View File
@@ -0,0 +1,65 @@
"""POST /api/v1/coupon/session 的 trace_id 签发行为(统一 trace_id 由后端下发)。
- started 不带 trace_id 服务端签发并返回,行以签发 id ;
- started trace_id 回显沿用(老客户端兼容);
- started trace_id 不签发不写库,trace_id=null(防孤儿行)
"""
from __future__ import annotations
from sqlalchemy import func, select
from app.db.session import SessionLocal
from app.models.coupon_state import CouponSession
def _base_payload(**overrides) -> dict:
payload = {
"device_id": "cs-issue-device",
"status": "started",
"started_at_ms": 1_722_000_000_000,
"platforms": ["meituan-waimai"],
"app_env": "dev",
}
payload.update(overrides)
return payload
def test_session_started_issues_trace_id_when_absent(client) -> None:
response = client.post("/api/v1/coupon/session", json=_base_payload())
assert response.status_code == 200, response.text
body = response.json()
assert body["ok"] is True
issued = body["trace_id"]
assert issued
with SessionLocal() as db:
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == issued)
).scalar_one()
assert row.device_id == "cs-issue-device"
assert row.status == "started"
def test_session_started_echoes_client_trace_id(client) -> None:
response = client.post(
"/api/v1/coupon/session", json=_base_payload(trace_id="cs-legacy-1")
)
assert response.status_code == 200, response.text
assert response.json()["trace_id"] == "cs-legacy-1"
with SessionLocal() as db:
count = db.scalar(
select(func.count(CouponSession.id)).where(
CouponSession.trace_id == "cs-legacy-1"
)
)
assert count == 1
def test_session_terminal_without_trace_id_skips_write(client) -> None:
response = client.post(
"/api/v1/coupon/session",
json=_base_payload(status="completed", elapsed_ms=1234, claimed_count=2),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["ok"] is True
assert body["trace_id"] is None
File diff suppressed because it is too large Load Diff
+3 -9
View File
@@ -10,11 +10,7 @@ from fastapi.testclient import TestClient
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.api.v1 import compare as compare_api
from app.core.config_schema import (
RISK_COMPARE_DAILY_THRESHOLD_KEY,
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RISK_SMS_HOURLY_THRESHOLD_KEY,
)
from app.core.config_schema import LIMIT_POLICY_GLOBAL_KEY
from app.core.security import issue_token_pair
from app.db.session import SessionLocal
from app.models.app_config import AppConfig
@@ -28,9 +24,7 @@ from app.repositories import user as user_repo
@pytest.fixture(autouse=True)
def _reset_risk_rule_config():
keys = (
RISK_SMS_HOURLY_THRESHOLD_KEY,
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RISK_COMPARE_DAILY_THRESHOLD_KEY,
LIMIT_POLICY_GLOBAL_KEY,
risk_repo.RISK_RESET_BASELINES_KEY,
)
with SessionLocal() as db:
@@ -533,7 +527,7 @@ def test_admin_can_edit_rules_and_current_window_is_reconciled() -> None:
"/admin/api/risk-monitor/rules",
headers=headers,
json={
"sms_hourly_threshold": 21,
"sms_hourly_threshold": 100_001,
"oneclick_daily_threshold": 20,
"compare_daily_threshold": 100,
},
+26
View File
@@ -0,0 +1,26 @@
"""SMS 共享基座类型 SendResult。"""
from __future__ import annotations
import dataclasses
import pytest
from app.integrations.sms.base import SendResult
def test_send_result_fields():
r = SendResult(cooldown_sec=60, provider="jiguang", fallback=True)
assert r.cooldown_sec == 60
assert r.provider == "jiguang"
assert r.fallback is True
def test_send_result_fallback_defaults_false():
r = SendResult(cooldown_sec=60, provider="jiguang")
assert r.fallback is False
def test_send_result_is_frozen():
r = SendResult(cooldown_sec=60, provider="jiguang")
with pytest.raises(dataclasses.FrozenInstanceError):
r.provider = "chuanglan"
+3 -3
View File
@@ -17,11 +17,11 @@ def test_send_code_routes_by_provider_and_switches_per_call(monkeypatch) -> None
monkeypatch.setattr(chuanglan, "send_code", lambda phone: (calls.append("chuanglan"), 60)[1])
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
assert sms.send_code("13800138000") == 60
assert sms.send_code("13800138000").cooldown_sec == 60
monkeypatch.setattr(settings, "SMS_PROVIDER", "aliyun")
assert sms.send_code("13800138000") == 60
assert sms.send_code("13800138000").cooldown_sec == 60
monkeypatch.setattr(settings, "SMS_PROVIDER", "chuanglan")
assert sms.send_code("13800138000") == 60
assert sms.send_code("13800138000").cooldown_sec == 60
assert calls == ["jiguang", "aliyun", "chuanglan"] # 每次按当前 provider 路由,运行时可切
+185
View File
@@ -0,0 +1,185 @@
"""极光→创蓝 容灾 fallback(分派层 send_code / verify_code)。
monkeypatch provider send_code/verify_code 为标记函数不发真网络
断言仅主 503 才转备429/400 不转verify try-both 只遍历启用的链
"""
from __future__ import annotations
import pytest
from app.core.config import settings
from app.integrations import sms
from app.integrations.sms import chuanglan, jiguang
from app.integrations.sms.base import SendResult, SmsError
PHONE = "13800138000"
def _raiser(exc: Exception):
"""返回一个调用即 raise exc 的 send_code 替身(lambda 不能 raise)。"""
def _f(phone):
raise exc
return _f
def test_fallback_provider_defaults_empty():
assert settings.SMS_FALLBACK_PROVIDER == ""
def test_send_code_primary_success_returns_sendresult(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "")
monkeypatch.setattr(jiguang, "send_code", lambda phone: 60)
r = sms.send_code(PHONE)
assert isinstance(r, SendResult)
assert r.cooldown_sec == 60
assert r.provider == "jiguang"
assert r.fallback is False
def test_send_code_503_falls_back_to_configured_backup(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("极光不可用", status_code=503)))
monkeypatch.setattr(chuanglan, "send_code",
lambda phone: (calls.append("chuanglan"), 60)[1])
r = sms.send_code(PHONE)
assert r.provider == "chuanglan"
assert r.fallback is True
assert r.cooldown_sec == 60
assert calls == ["chuanglan"]
@pytest.mark.parametrize("status", [429, 400])
def test_send_code_non_503_does_not_fall_back(monkeypatch, status):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("x", status_code=status)))
monkeypatch.setattr(chuanglan, "send_code",
lambda phone: (calls.append("chuanglan"), 60)[1])
with pytest.raises(SmsError) as ei:
sms.send_code(PHONE)
assert ei.value.status_code == status
assert calls == []
def test_send_code_503_without_backup_reraises(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("极光不可用", status_code=503)))
with pytest.raises(SmsError) as ei:
sms.send_code(PHONE)
assert ei.value.status_code == 503
def test_send_code_backup_also_fails_raises_backup_error(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("极光不可用", status_code=503)))
monkeypatch.setattr(chuanglan, "send_code",
_raiser(SmsError("创蓝也不可用", status_code=503)))
with pytest.raises(SmsError) as ei:
sms.send_code(PHONE)
assert "创蓝" in str(ei.value)
def test_dynamic_cooldown_is_forwarded_to_primary_and_backup(monkeypatch):
calls = []
def primary(phone, *, cooldown_sec=None):
calls.append(("jiguang", cooldown_sec))
raise SmsError("极光不可用", status_code=503)
def backup(phone, *, cooldown_sec=None):
calls.append(("chuanglan", cooldown_sec))
return cooldown_sec
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code", primary)
monkeypatch.setattr(chuanglan, "send_code", backup)
result = sms.send_code(PHONE, cooldown_sec=15)
assert result.cooldown_sec == 15
assert result.provider == "chuanglan"
assert result.fallback is True
assert calls == [("jiguang", 15), ("chuanglan", 15)]
def test_verify_hits_primary_without_touching_backup(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code",
lambda p, c: (calls.append("jiguang"), True)[1])
monkeypatch.setattr(chuanglan, "verify_code",
lambda p, c: (calls.append("chuanglan"), True)[1])
assert sms.verify_code(PHONE, "123456") is True
assert calls == ["jiguang"]
def test_verify_falls_through_to_backup(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code",
lambda p, c: (calls.append("jiguang"), False)[1])
monkeypatch.setattr(chuanglan, "verify_code",
lambda p, c: (calls.append("chuanglan"), True)[1])
assert sms.verify_code(PHONE, "123456") is True
assert calls == ["jiguang", "chuanglan"]
def test_verify_all_miss_returns_false(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code", lambda p, c: False)
monkeypatch.setattr(chuanglan, "verify_code", lambda p, c: False)
assert sms.verify_code(PHONE, "123456") is False
def test_verify_backup_not_touched_when_fallback_off(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "")
monkeypatch.setattr(jiguang, "verify_code",
lambda p, c: (calls.append("jiguang"), False)[1])
monkeypatch.setattr(chuanglan, "verify_code",
lambda p, c: (calls.append("chuanglan"), True)[1])
assert sms.verify_code(PHONE, "123456") is False
assert calls == ["jiguang"]
def test_dynamic_verify_attempt_limit_is_forwarded_to_fallback_chain(monkeypatch):
calls = []
def verify(provider, result):
def _verify(phone, code, *, max_failed_attempts=None):
calls.append((provider, max_failed_attempts))
return result
return _verify
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code", verify("jiguang", False))
monkeypatch.setattr(chuanglan, "verify_code", verify("chuanglan", True))
assert sms.verify_code(PHONE, "123456", max_failed_attempts=2) is True
assert calls == [("jiguang", 2), ("chuanglan", 2)]