Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1567a9ac74 | |||
| 80652ca78d | |||
| f411e81b07 | |||
| f8b5d31d2e | |||
| af615a9853 | |||
| 0177d467bf | |||
| 3146224944 | |||
| 3621d606ad | |||
| c1b00eca0c | |||
| a5fdcb53d8 | |||
| fe9b749154 | |||
| 88313cefdf | |||
| 2a82b20365 |
+15
-24
@@ -12,17 +12,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import test_account
|
||||
from app.core.ratelimit import (
|
||||
RateLimitRule,
|
||||
check_rate_limits,
|
||||
enforce_rate_limit,
|
||||
record_rate_limits,
|
||||
)
|
||||
from app.core.ratelimit import enforce_rate_limit
|
||||
from app.core.security import (
|
||||
TokenError,
|
||||
create_bind_ticket,
|
||||
@@ -64,10 +59,9 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
|
||||
SMS_LOGIN_MAX_PER_HOUR = 5
|
||||
# 发码防刷(同一设备 device_id + 同一 IP,**只按成功发码计数**;被单号 60s 冷却挡下的重发不占额度):
|
||||
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
|
||||
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
|
||||
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限
|
||||
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸)
|
||||
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
|
||||
|
||||
|
||||
def _login_response(
|
||||
@@ -124,26 +118,23 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
logger.info("test_account sms_send short-circuit (不真发)")
|
||||
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
|
||||
|
||||
# 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**。
|
||||
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
|
||||
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
|
||||
# (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
|
||||
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,
|
||||
"今日验证码发送次数过多,请明天再试"),
|
||||
]
|
||||
check_rate_limits(request, subject=req.device_id, rules=send_rules)
|
||||
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
|
||||
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
|
||||
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
|
||||
enforce_rate_limit(
|
||||
request,
|
||||
scope="sms-send-device",
|
||||
subject=req.device_id,
|
||||
limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE,
|
||||
window_sec=3600,
|
||||
detail="操作过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
try:
|
||||
cooldown = send_code(req.phone)
|
||||
except SmsError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
|
||||
# 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
|
||||
record_rate_limits(request, subject=req.device_id, rules=send_rules)
|
||||
|
||||
from app.core.config import settings # 局部 import 避免循环
|
||||
|
||||
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
|
||||
|
||||
+8
-93
@@ -9,41 +9,29 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# key -> (window_start_ts, count, window_sec)
|
||||
# 存每个 key 自己的 window_sec:_buckets 混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key,
|
||||
# GC 必须按各 key 自己的窗口判过期(见 [_purge_expired]),否则短窗口调用触发的 GC 会误删长窗口 key。
|
||||
_buckets: dict[str, tuple[float, int, float]] = {}
|
||||
# key -> (window_start_ts, count)
|
||||
_buckets: dict[str, tuple[float, int]] = {}
|
||||
_lock = threading.Lock()
|
||||
_GC_THRESHOLD = 10000 # _buckets 超此阈值才顺手清过期 key(仿 sms.py;测试可 monkeypatch 调小强制每次扫)
|
||||
|
||||
|
||||
def _purge_expired(now: float) -> None:
|
||||
"""清过期 key(**仅在持有 _lock 时调用**)。按每个 key 自己存的 window_sec 判过期,而非调用方的窗口
|
||||
—— _buckets 是全局共享、混着 60s(广告)/3600s(登录)/86400s(日闸)不同窗口的 key;若用调用方窗口,
|
||||
高频的 60s 广告端点触发 GC 时会把本该活 3600s/86400s 的登录/日闸计数一并删掉,使其在规模上(超阈值才
|
||||
触发本清理)被反复清零而失效。仅在超阈值时扫,低频、开销可忽略。"""
|
||||
if len(_buckets) <= _GC_THRESHOLD:
|
||||
return
|
||||
for k in [k for k, (s, _, w) in _buckets.items() if now - s >= w]:
|
||||
_buckets.pop(k, None)
|
||||
|
||||
|
||||
def _hit(key: str, limit: int, window_sec: float) -> bool:
|
||||
"""记一次访问。返回 True=放行,False=超限。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||
start, count = _buckets.get(key, (now, 0))
|
||||
if now - start >= window_sec: # 窗口过期,重置
|
||||
start, count = now, 0
|
||||
count += 1
|
||||
_buckets[key] = (start, count, window_sec)
|
||||
_purge_expired(now) # 顺手清过期 key(按各自窗口),防内存无限涨
|
||||
_buckets[key] = (start, count)
|
||||
# 顺手清理过期 key,防内存无限涨(低频访问足够)
|
||||
if len(_buckets) > 10000:
|
||||
for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]:
|
||||
_buckets.pop(k, None)
|
||||
return count <= limit
|
||||
|
||||
|
||||
@@ -95,76 +83,3 @@ def enforce_rate_limit(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
# ===================== 先判 / 后记(只按「成功」计数)=====================
|
||||
# _hit 是原子「判+记」:一调用就 +1,适合登录爆破(失败尝试也要计)。但对「短信发码」这类
|
||||
# **只想给成功动作计数**的场景不合适 —— 被单号冷却挡下的重发没真发、没烧钱,不该占额度。
|
||||
# 故拆成 _peek(只判不记)+ _commit(只记):check_rate_limits 先判 → 动作 → 成功后 record。
|
||||
|
||||
|
||||
class RateLimitRule(NamedTuple):
|
||||
"""一条限流规则。scope 区分不同闸(不同 key 前缀);同一 (subject, IP) 在 window_sec
|
||||
内最多 limit 次,超限抛 429 用 detail 文案。
|
||||
|
||||
(scope, window_sec) 成对绑在一条规则里 —— check(先判)与 record(计数)复用同一条,
|
||||
避免两处把窗口/scope 写歪导致 key 对不上。
|
||||
"""
|
||||
|
||||
scope: str
|
||||
limit: int
|
||||
window_sec: float
|
||||
detail: str = "操作过于频繁,请稍后再试"
|
||||
|
||||
|
||||
def _peek(key: str, limit: int, window_sec: float) -> bool:
|
||||
"""只读:当前窗口内是否还没到上限(count < limit)。**不改计数**。
|
||||
与 [_commit] 配对实现「先判后记」——只在动作成功后才 _commit。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||
if now - start >= window_sec: # 窗口已过期 → 视作已重置(count 归零)
|
||||
count = 0
|
||||
return count < limit
|
||||
|
||||
|
||||
def _commit(key: str, window_sec: float) -> None:
|
||||
"""记一次访问(+1)。窗口过期则以本次为起点重置。仅在动作成功后调用。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||
if now - start >= window_sec: # 窗口过期,重置
|
||||
start, count = now, 0
|
||||
_buckets[key] = (start, count + 1, window_sec)
|
||||
_purge_expired(now) # 顺手清过期 key(按各自窗口,同 [_hit])
|
||||
|
||||
|
||||
def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
|
||||
"""【先判】一组限流:任一规则已达上限即抛 429,且**不改计数**。
|
||||
|
||||
配合 [record_rate_limits] 实现「只按成功计数」:先 check 所有闸(全未超才继续)→ 执行动作
|
||||
→ 动作**成功后**再 record。动作被下游挡下(如短信单号冷却)、没真正发生时不 record → 不占额度。
|
||||
key = `scope:subject:client_ip`(与 [enforce_rate_limit] 同款)。
|
||||
"""
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return
|
||||
ip = _client_ip(request)
|
||||
for rule in rules:
|
||||
if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=rule.detail,
|
||||
)
|
||||
|
||||
|
||||
def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
|
||||
"""【记一次】一组限流(每条规则 +1)。仅在动作成功后调用,与 [check_rate_limits] 配对。
|
||||
|
||||
⚠️ check→动作→record 非原子:并发突发下计数可能略超 limit(每个在途请求各 +1)。对
|
||||
「防脚本/防轰炸」的安全网定位可接受;要精确配额需迁 Redis(见模块 docstring)。
|
||||
"""
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return
|
||||
ip = _client_ip(request)
|
||||
for rule in rules:
|
||||
_commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec)
|
||||
|
||||
@@ -13,8 +13,7 @@ worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移
|
||||
|
||||
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
|
||||
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
|
||||
2. 单设备(device_id)+ IP 每小时 / 每天频控(api 层 auth.sms_send 的 check/record_rate_limits,
|
||||
**只按成功发码计数** —— 被本文件单号冷却挡下的重发不占额度)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
2. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换,
|
||||
脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。
|
||||
⚠️ 原「单号每日上限」2026-07-03 按精简要求删除(mentor 定:登录风控只留单号冷却 + 单设备频控);
|
||||
|
||||
@@ -59,17 +59,6 @@ def is_default_nickname(nickname: str | None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def apply_wechat_display_identity(
|
||||
user: User, *, wechat_nickname: str | None, wechat_avatar_url: str | None
|
||||
) -> None:
|
||||
"""§10:用已有账号绑微信时,仅当展示字段仍为默认才用微信昵称/头像替换(两规则独立);
|
||||
自定义(改过昵称/传过头像)则保留。只改内存对象,由调用方 commit。"""
|
||||
if is_default_nickname(user.nickname) and wechat_nickname:
|
||||
user.nickname = wechat_nickname
|
||||
if user.avatar_url is None and wechat_avatar_url:
|
||||
user.avatar_url = wechat_avatar_url
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> User | None:
|
||||
return db.execute(
|
||||
select(User).where(User.username == username)
|
||||
@@ -116,16 +105,14 @@ def attach_wechat_to_user(
|
||||
) -> User:
|
||||
"""继续绑定:把微信 openid + 微信源字段并入已存在账号(调用方保证 user.wechat_openid 为空)。
|
||||
|
||||
写 wechat_openid / wechat_nickname / wechat_avatar_url,并按 §10 规则回填展示字段:
|
||||
仅当昵称仍为默认值(is_default_nickname)时用微信昵称替换,仅当头像为 null 时用微信头像替换;
|
||||
用户已自定义的展示昵称/头像始终保留,两规则相互独立。
|
||||
撞 openid 唯一约束(O 期间被别处绑走,极罕见)时由调用方捕获 IntegrityError 兜底降级为"只登入不绑"。
|
||||
**只写 wechat_openid / wechat_nickname / wechat_avatar_url,不动展示 nickname/avatar_url**
|
||||
(完整 §10"默认则用微信、改过则保留"规则留 M3)。撞 openid 唯一约束(O 期间被别处绑走,
|
||||
极罕见)时由调用方捕获 IntegrityError 兜底降级为"只登入不绑"。
|
||||
"""
|
||||
user.wechat_openid = openid
|
||||
user.wechat_nickname = wechat_nickname
|
||||
user.wechat_avatar_url = wechat_avatar_url
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
apply_wechat_display_identity(user, wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@@ -20,7 +20,6 @@ from app.core.config import settings
|
||||
from app.core.rewards import COIN_PER_CENT, coins_to_cents
|
||||
from app.integrations import wxpay
|
||||
from app.models.user import User
|
||||
from app.repositories.user import apply_wechat_display_identity
|
||||
from app.models.wallet import (
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
@@ -35,10 +34,6 @@ _WX_STATE_SUCCESS = "SUCCESS"
|
||||
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
|
||||
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
|
||||
_WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"}
|
||||
# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。
|
||||
# 被拒/转账失败/解绑退回(rejected/failed,均已退款、钱没到手)不在此列 → 新人档恢复可提
|
||||
# (2026-07-16 修正:此前判定不看状态,解绑微信退回后 0.1 被误判已用、资格永久锁死)。
|
||||
_NEWBIE_TIER_HELD_STATUSES = {"reviewing", "pending", "success"}
|
||||
# 免确认收款授权状态
|
||||
_WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账
|
||||
_WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启
|
||||
@@ -379,7 +374,6 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
|
||||
user.wechat_openid = info["openid"]
|
||||
user.wechat_nickname = info["nickname"]
|
||||
user.wechat_avatar_url = info["avatar_url"]
|
||||
apply_wechat_display_identity(user, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"])
|
||||
db.commit()
|
||||
return info
|
||||
|
||||
@@ -628,10 +622,9 @@ def _beijing_today_start_utc() -> datetime:
|
||||
def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]:
|
||||
"""福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。
|
||||
|
||||
规则(2026-07-09 拍板,7-9提现ui对齐;新人档判定 2026-07-16 修正):
|
||||
- 新人档(0.1/0.3):账号历史一次性——进行中(reviewing/pending)或成功打款(success)即视为
|
||||
已用,直接**从返回列表消失**;被拒/转账失败/解绑退回(均已退款、钱没到手)则恢复可提,不永久
|
||||
占用资格。两档各自独立互不影响,不参与"每日选一个额度"互斥。
|
||||
规则(2026-07-09 拍板,7-9提现ui对齐):
|
||||
- 新人档(0.1/0.3):账号历史一次性——只要发起过(**任意状态**,含被拒/失败,"发起就算")
|
||||
即视为已用,直接**从返回列表消失**;两档各自独立互不影响,不参与"每日选一个额度"互斥。
|
||||
- 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
|
||||
都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。
|
||||
- invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。
|
||||
@@ -642,7 +635,7 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -
|
||||
tiers = rewards.WITHDRAW_TIERS_COIN_CASH
|
||||
amounts = [t.amount_cents for t in tiers]
|
||||
newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie]
|
||||
# 新人档历史是否用过:进行中或已成功打款的单占用资格;被拒/失败/解绑退回(已退款)不算(恢复可提)
|
||||
# 新人档历史是否用过:任意时间、任意状态("发起就算")
|
||||
used_newbie: set[int] = set(
|
||||
db.execute(
|
||||
select(WithdrawOrder.amount_cents)
|
||||
@@ -651,7 +644,6 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.source == "coin_cash",
|
||||
WithdrawOrder.amount_cents.in_(newbie_amounts),
|
||||
WithdrawOrder.status.in_(_NEWBIE_TIER_HELD_STATUSES),
|
||||
)
|
||||
).scalars()
|
||||
) if newbie_amounts else set()
|
||||
|
||||
@@ -107,67 +107,6 @@ def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_sms_send_cooldown_reject_not_counted(client, monkeypatch) -> None:
|
||||
"""发码额度只算「成功发码」:被单号 60s 冷却挡下的重发(429)不占设备额度。
|
||||
做法:同号狂发只成功 1 次、其余被冷却挡下;把小时额度设 2,证明换号后仍能再成功发 1 次
|
||||
—— 若冷却重发也计数,额度早被那几次耗尽。"""
|
||||
from app.api.v1 import auth
|
||||
from app.core import ratelimit
|
||||
|
||||
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 2)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
device = "dev-cooldown"
|
||||
phone_a = "13710137000"
|
||||
# 首发成功(小时闸计 1/2)
|
||||
assert client.post(
|
||||
"/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device}
|
||||
).status_code == 200
|
||||
# 同号连发 3 次:都被单号 60s 冷却挡下 → 429,且**不占**设备额度
|
||||
for _ in range(3):
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device}
|
||||
)
|
||||
assert r.status_code == 429, r.text
|
||||
# 换号再发:设备额度只用了 1/2(冷却那几次没算)→ 仍放行(计到 2/2)
|
||||
assert client.post(
|
||||
"/api/v1/auth/sms/send", json={"phone": "13710137001", "device_id": device}
|
||||
).status_code == 200
|
||||
# 又换号:此时小时闸已 2/2 → 429(反证成功发码确实各计了 1)
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send", json={"phone": "13710137002", "device_id": device}
|
||||
)
|
||||
assert r.status_code == 429, r.text
|
||||
|
||||
|
||||
def test_sms_send_daily_cap(client, monkeypatch) -> None:
|
||||
"""每天发码上限(设备 + IP):成功发码累计到日上限即 429(用不同手机号绕开单号冷却)。
|
||||
抬高小时闸单独测日闸;超限文案含「今日」以便前端提示明天再来。"""
|
||||
from app.api.v1 import auth
|
||||
from app.core import ratelimit
|
||||
|
||||
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 100) # 抬高小时闸,不干扰
|
||||
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_DAY_PER_DEVICE", 3)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
device = "dev-daily"
|
||||
for i in range(3):
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send",
|
||||
json={"phone": f"13720137{i:03d}", "device_id": device},
|
||||
)
|
||||
assert r.status_code == 200, f"第 {i + 1} 次应放行: {r.text}"
|
||||
# 第 4 次:同设备同 IP 当日超限 → 429
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send",
|
||||
json={"phone": "13720137999", "device_id": device},
|
||||
)
|
||||
assert r.status_code == 429, r.text
|
||||
assert "今日" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_sms_login_device_ip_rate_limit(client, monkeypatch) -> None:
|
||||
"""防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试,超出 429。
|
||||
conftest 默认 RATE_LIMIT_ENABLED=false(内存计数跨用例累加),本用例临时打开并清空计数隔离。"""
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""ratelimit 内存桶过期清理(GC)测试。
|
||||
|
||||
回归重点:_buckets 是**全局共享**、混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key。
|
||||
GC 必须按【每个 key 自己存的 window_sec】判过期,而不是当前调用方的窗口 —— 否则高频的 60s 端点
|
||||
触发 GC 时会把本该存活更久的 3600s/86400s 计数(如短信日闸)一并删掉,使其被反复清零、限流失效。
|
||||
用 monkeypatch 把 _GC_THRESHOLD 调 0 强制每次都扫,免造上万条(仿 test_auth 里对 sms._GC_THRESHOLD 的做法)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core import ratelimit
|
||||
|
||||
|
||||
def test_purge_expired_respects_each_key_own_window(monkeypatch) -> None:
|
||||
"""短窗口(60s)触发的 GC 只删真正过期的 key,不得删掉仍在自身窗口内的长窗口 key。"""
|
||||
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0) # 强制每次都扫
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
now = 1_000_000.0
|
||||
# 日闸:100s 前开窗、window=86400 → 远未过期,必须保留
|
||||
ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 100, 7, 86400.0)
|
||||
# 登录:1800s、window=3600 → 未过期,保留
|
||||
ratelimit._buckets["sms-login-device:D:IP"] = (now - 1800, 2, 3600.0)
|
||||
# 广告:120s、window=60 → 已过期,应删
|
||||
ratelimit._buckets["ad-watch-report:IP2"] = (now - 120, 3, 60.0)
|
||||
|
||||
ratelimit._purge_expired(now)
|
||||
|
||||
assert "sms-send-device-daily:D:IP" in ratelimit._buckets
|
||||
assert "sms-login-device:D:IP" in ratelimit._buckets
|
||||
assert "ad-watch-report:IP2" not in ratelimit._buckets
|
||||
|
||||
|
||||
def test_purge_expired_keeps_long_window_key_older_than_short_window(monkeypatch) -> None:
|
||||
"""反证旧 bug:日闸 key 已老于 3600s,旧代码在 60s/3600s 端点触发 GC 时会误删它;
|
||||
现在按自身 86400s 窗口判 → 未过期 → 必须保留。"""
|
||||
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
now = 2_000_000.0
|
||||
# 3700s 前开窗(> 1 小时),但 window=86400 → 未过期
|
||||
ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 3700, 20, 86400.0)
|
||||
|
||||
ratelimit._purge_expired(now)
|
||||
|
||||
assert "sms-send-device-daily:D:IP" in ratelimit._buckets
|
||||
|
||||
|
||||
def test_purge_expired_noop_below_threshold(monkeypatch) -> None:
|
||||
"""未超阈值时不扫(即便有过期 key 也不动),避免每次请求都 O(n) 扫全表。"""
|
||||
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 10)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
now = 3_000_000.0
|
||||
ratelimit._buckets["stale:IP"] = (now - 999, 1, 60.0) # 早过期,但没超阈值
|
||||
ratelimit._purge_expired(now)
|
||||
|
||||
assert "stale:IP" in ratelimit._buckets # 桶数没超阈值 → 不清理
|
||||
@@ -178,27 +178,6 @@ def test_rebind_creates_new_account_and_binds_openid(client, monkeypatch) -> Non
|
||||
assert r.json()["token"]["user"]["id"] == y["id"]
|
||||
|
||||
|
||||
# ===== §10: continue 路径也应用展示身份回填规则 =====
|
||||
|
||||
def test_continue_applies_section10(client, monkeypatch) -> None:
|
||||
"""§10 via M2 attach 路径: X 是默认昵称+null头像的 sms 账号;
|
||||
continue 绑定微信后,展示昵称/头像应被微信值替换,并体现在响应的 token.user 中。"""
|
||||
phone = "13900139211"
|
||||
_sms_occupy(client, phone) # 建 X:默认昵称, null avatar
|
||||
body = _occupy_via_conflict(
|
||||
client, monkeypatch, "openid_s10", phone, "devS10"
|
||||
) # fake_userinfo 默认 nickname="微信昵称", avatar="http://x/a.png"
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/auth/wechat/conflict/continue",
|
||||
json={"conflict_ticket": body["conflict_ticket"], "device_id": "devS10"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
user_out = r.json()["token"]["user"]
|
||||
assert user_out["nickname"] == "微信昵称"
|
||||
assert user_out["avatar_url"] == "http://x/a.png"
|
||||
|
||||
|
||||
def test_rebind_blocked_within_30_days(client, monkeypatch) -> None:
|
||||
"""同一手机号 30 天内二次换绑 → 409;占用响应 rebind_available=False。"""
|
||||
phone = "13900139302"
|
||||
|
||||
@@ -312,94 +312,6 @@ def test_bind_rejects_openid_already_bound(client, monkeypatch) -> None:
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
# ===== §10 绑微信时展示身份回填规则 =====
|
||||
|
||||
def test_bind_replaces_default_nickname_and_null_avatar(client, monkeypatch) -> None:
|
||||
"""§10-A: 全默认(昵称=系统默认,头像=null) → 绑微信后两个展示字段都替换为微信值。"""
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.code_to_userinfo",
|
||||
lambda code: {"openid": "openid_s10_a", "nickname": "微信昵称A", "avatar_url": "https://x/a.png", "raw": {}},
|
||||
)
|
||||
token = _login(client, "13800003001")
|
||||
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = client.get("/api/v1/auth/me", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["nickname"] == "微信昵称A"
|
||||
assert body["avatar_url"] == "https://x/a.png"
|
||||
|
||||
|
||||
def test_bind_keeps_customized_nickname(client, monkeypatch) -> None:
|
||||
"""§10-B: 用户已改过昵称 → 绑微信后昵称保留,但 null 头像仍替换为微信头像。"""
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.code_to_userinfo",
|
||||
lambda code: {"openid": "openid_s10_b", "nickname": "微信昵称B", "avatar_url": "https://x/b.png", "raw": {}},
|
||||
)
|
||||
token = _login(client, "13800003002")
|
||||
# 先把昵称改成非默认值
|
||||
r = client.patch(
|
||||
"/api/v1/user/profile", json={"nickname": "我的名字"}, headers=_auth(token)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = client.get("/api/v1/auth/me", headers=_auth(token))
|
||||
body = r.json()
|
||||
assert body["nickname"] == "我的名字" # 保留自定义昵称
|
||||
assert body["avatar_url"] == "https://x/b.png" # null 头像被微信头像替换
|
||||
|
||||
|
||||
def test_bind_keeps_customized_avatar(client, monkeypatch) -> None:
|
||||
"""§10-C: 已有自定义头像 → 绑微信后头像保留,但默认昵称替换为微信昵称。"""
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.code_to_userinfo",
|
||||
lambda code: {"openid": "openid_s10_c", "nickname": "微信昵称C", "avatar_url": "https://x/c.png", "raw": {}},
|
||||
)
|
||||
token = _login(client, "13800003003")
|
||||
phone = "13800003003"
|
||||
# 直接用 DB 给用户写入自定义头像(保持默认昵称)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
|
||||
user.avatar_url = "https://custom/av.png"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = client.get("/api/v1/auth/me", headers=_auth(token))
|
||||
body = r.json()
|
||||
assert body["nickname"] == "微信昵称C" # 默认昵称被替换
|
||||
assert body["avatar_url"] == "https://custom/av.png" # 自定义头像保留
|
||||
|
||||
|
||||
def test_bind_wechat_empty_keeps_default(client, monkeypatch) -> None:
|
||||
"""§10-D: 微信侧 nickname/avatar 均为 None → 不覆盖,展示字段维持原默认值。"""
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.code_to_userinfo",
|
||||
lambda code: {"openid": "openid_s10_d", "nickname": None, "avatar_url": None, "raw": {}},
|
||||
)
|
||||
token = _login(client, "13800003004")
|
||||
|
||||
r = client.get("/api/v1/auth/me", headers=_auth(token))
|
||||
original_nickname = r.json()["nickname"] # 系统分配的默认昵称
|
||||
assert original_nickname.startswith("用户")
|
||||
|
||||
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = client.get("/api/v1/auth/me", headers=_auth(token))
|
||||
body = r.json()
|
||||
assert body["nickname"] == original_nickname # 默认昵称不变
|
||||
assert body["avatar_url"] is None # 头像仍为 null
|
||||
|
||||
|
||||
def test_withdraw_reject_refunds(client, monkeypatch) -> None:
|
||||
"""管理员审核拒绝 → 退回现金 + 单 rejected + 理由写入 fail_reason(用户可见)。"""
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_reject", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
|
||||
Reference in New Issue
Block a user