Compare commits

..

1 Commits

Author SHA1 Message Date
no_gen_mu f7fc4af17c feat(wallet): 连续15天无活跃账户金币+现金自动清零
新增进程内定时任务:每天北京0点后扫描,对连续无活跃满15整天(既没发起比价、
也没发起领券)的用户,清空金币与金币兑换现金;单纯登录不算活跃,邀请奖励金物理
隔离不动,每笔清零写 inactive_clear 流水可逐笔回溯。

- 活跃口径=发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started
  (登录 last_login_at 不算);新用户靠 created_at 兜底(注册未满15天豁免,不会刚注册就被清)
- worker 照 daily_exchange_worker 结构:文件锁同机互斥 + 北京日切 + 启动补跑
- 只扫有余额账户,清零走 grant_coins/grant_cash 负数入账 → 天然幂等
- 循环内读实时余额 + 行锁清零防并发;days<1 钳位防误配全员清空
- 新增 INACTIVE_CLEAR_ENABLED/DAYS/CHECK_INTERVAL_SEC 三开关(默认开,15天)
- 复用现有表无需迁移

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:45:20 +08:00
9 changed files with 339 additions and 237 deletions
+15 -24
View File
@@ -12,16 +12,11 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, status
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, decode_token, issue_token_pair
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code
@@ -45,10 +40,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(
@@ -105,26 +99,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
View File
@@ -169,6 +169,14 @@ class Settings(BaseSettings):
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
# 连续 N 天无活跃(无比价 / 领券,登录不算)账户金币+现金清零:进程内 worker
# app.core.inactive_clear_worker 跨北京 0 点跑一轮 inactive_clear.clear_inactive_accounts。
# 邀请奖励金物理隔离,不在清空范围。⚠️ 不可逆批量资金操作,口径见 repositories/inactive_clear。
# 运营要临时停在 .env 置 false(worker 不启动)。
INACTIVE_CLEAR_ENABLED: bool = True
# 连续无活跃**满这么多整天之后**才清:15 → 最后活跃在 15 天前当天仍保留,第 16 天起清。
INACTIVE_CLEAR_DAYS: int = 15
INACTIVE_CLEAR_CHECK_INTERVAL_SEC: int = 600
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
+132
View File
@@ -0,0 +1,132 @@
"""连续 N 天无活跃账户金币 / 现金清零的进程内定时任务。
结构同 app.core.daily_exchange_worker:每 `INACTIVE_CLEAR_CHECK_INTERVAL_SEC` 醒一次,
跨进北京新的一天(0 点后)就跑一轮 inactive_clear.clear_inactive_accounts。
健壮性:
- **天然幂等**:只清有余额的账户,清完余额=0,下一轮 / 重启 / 多次唤醒都不重复清。
- **当天首跑即补**:进程起来时若当天还没跑过,立即跑一轮。
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑。
- **开关**:settings.INACTIVE_CLEAR_ENABLED=false 时不启动。
⚠️ 这是不可逆的批量资金操作(清空用户金币 + 金币现金,邀请金除外)。活跃口径与清零
细节见 app.repositories.inactive_clear。
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from datetime import date
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from app.core import rewards
from app.core.config import settings
from app.db.session import SessionLocal
from app.repositories import inactive_clear as inactive_clear_repo
logger = logging.getLogger("shagua.inactive_clear")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "inactive_clear.lock"
def _touch_lock() -> None:
with contextlib.suppress(FileNotFoundError):
os.utime(_LOCK_PATH, None)
@contextlib.contextmanager
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
"""同机多进程保护:同一时间只允许一个清零 worker 运行。"""
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
fd: int | None = None
try:
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
try:
age = time.time() - _LOCK_PATH.stat().st_mtime
except FileNotFoundError:
age = stale_after_sec + 1
if age > stale_after_sec:
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
fd = None
if fd is None:
yield False
return
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
yield True
finally:
if fd is not None:
os.close(fd)
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
def _clear_once() -> dict:
with SessionLocal() as db:
return inactive_clear_repo.clear_inactive_accounts(
db, days=settings.INACTIVE_CLEAR_DAYS
)
async def _run_loop() -> None:
interval = max(60, int(settings.INACTIVE_CLEAR_CHECK_INTERVAL_SEC))
lock_stale_after = max(interval * 3, 1800)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("inactive-clear skipped: another worker owns lock")
return
await _run_locked_loop(interval)
async def _run_locked_loop(interval: int) -> None:
logger.info(
"inactive-clear worker started interval=%ss days=%s",
interval,
settings.INACTIVE_CLEAR_DAYS,
)
# 本进程上次跑过的北京日;None=尚未跑过本进程(启动即补当天)。
last_run: date | None = None
try:
while True:
try:
_touch_lock()
today = rewards.cn_today()
if last_run != today:
result = await asyncio.to_thread(_clear_once)
last_run = today
logger.info("inactive-clear done date=%s result=%s", today, result)
except SQLAlchemyError:
logger.exception("inactive-clear db error")
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception("inactive-clear unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("inactive-clear worker stopped")
raise
def start_inactive_clear_worker() -> asyncio.Task | None:
if not settings.INACTIVE_CLEAR_ENABLED:
logger.info("inactive-clear disabled (INACTIVE_CLEAR_ENABLED=false)")
return None
return asyncio.create_task(_run_loop(), name="inactive-clear")
async def stop_inactive_clear_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+8 -93
View File
@@ -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)
+1 -2
View File
@@ -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 定:登录风控只留单号冷却 + 单设备频控);
+7
View File
@@ -49,6 +49,10 @@ from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.inactive_clear_worker import (
start_inactive_clear_worker,
stop_inactive_clear_worker,
)
from app.core.logging import setup_logging
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
from app.core.withdraw_reconcile_worker import (
@@ -74,18 +78,21 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
try:
# 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求
from app.utils import geo
geo.ensure_loaded()
except Exception: # noqa: BLE001
logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)")
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
inactive_clear_task = start_inactive_clear_worker()
try:
yield
finally:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
await stop_inactive_clear_worker(inactive_clear_task)
await aclose_pricebot_client()
logger.info("shutting down")
+168
View File
@@ -0,0 +1,168 @@
"""连续 N 天无活跃账户的金币 / 现金清零。
规则(2026-07-15 产品定):某用户连续 `days` 天(默认 15)既没发起比价、也没发起领券
→ 判定为「流失」,清空其金币余额(coin_balance)与金币兑换现金(cash_balance_cents)。
**单纯登录(打开 App)不算活跃**——光登录、不用比价/领券的用户视为流失照清。
**邀请奖励金(invite_cash_balance_cents)物理隔离,不在清空范围**(产品红线,见
models/wallet.py CoinAccount 注释);total_coin_earned(历史累计赚取,只增不减)也不动。
活跃口径:发起比价 real_compare_start + 发起领券 real_coupon_start / claim_started
两路并集(**不含登录 last_login_at**)。⚠️与 admin 大盘 DAU / 后台「最近活跃」列**不同**
——那两处含登录,此处刻意去掉,只认真正用了核心功能;若两处口径要同步须留意此差异。
新用户保护:从没发起过比价/领券的用户永远不在活跃集合,故用**注册时间兜底**——注册未满
days 天(created_at 在 today-days 之后)的用户豁免清零,新用户有 days 天宽限,不会刚注册
(有礼金)就被清。(登录已不算活跃,故不能再靠 last_login 兜底新用户。)
清零走 wallet.grant_coins / grant_cash(负数出账入口):更新余额快照 + 写一笔
biz_type=inactive_clear 的流水(带 balance_after),可逐笔回溯 / 人工恢复;grant_coins
对负数不累加 total_coin_earned。
"""
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.analytics_event import AnalyticsEvent
from app.models.coupon_state import CouponPromptEngagement
from app.models.user import User
from app.models.wallet import CoinAccount
from app.repositories.wallet import get_or_create_account, grant_cash, grant_coins
CLEAR_BIZ_TYPE = "inactive_clear"
# 「活跃」计入的埋点事件:发起比价 + 发起领券(登录不算)
_ACTIVE_EVENTS = ("real_compare_start", "real_coupon_start")
def _cn_date_start_utc(d: date) -> datetime:
"""北京自然日 d 的 00:00 → UTC aware 下界。
analytics_event.created_at / user.created_at 存 UTC(见各自模型),须用 UTC 边界比较;
CN_TZ 是固定 +8 偏移(rewards.CN_TZ),无 DST 歧义。
"""
return datetime(d.year, d.month, d.day, tzinfo=rewards.CN_TZ).astimezone(UTC)
def _active_user_ids_since(db: Session, since_cn: date) -> set[int]:
"""北京自然日 since_cn(含)当天 0 点起 **发起过比价 / 领券** 的 user_id 集合。
**登录不算活跃**(产品定):只认核心功能行为。两路并集:
- 发起比价 / 领券:analytics_event.event in _ACTIVE_EVENTS 且 created_at >= since(UTC 边界)
- 发起领券:coupon_prompt_engagement.engage_type=claim_started 且 engage_date >= since(北京日列)
"""
since_utc = _cn_date_start_utc(since_cn)
ids: set[int] = set()
ids.update(
uid
for uid in db.execute(
select(AnalyticsEvent.user_id).where(
AnalyticsEvent.user_id.is_not(None),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
AnalyticsEvent.created_at >= since_utc,
)
).scalars()
if uid is not None
)
ids.update(
uid
for uid in db.execute(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.user_id.is_not(None),
CouponPromptEngagement.engage_type == "claim_started",
CouponPromptEngagement.engage_date >= since_cn,
)
).scalars()
if uid is not None
)
return ids
def _new_user_ids_since(db: Session, since_cn: date) -> set[int]:
"""注册时间 >= since_cn 北京日 0 点的 user_id——注册未满 days 天的新用户,豁免清零。"""
since_utc = _cn_date_start_utc(since_cn)
return set(db.execute(select(User.id).where(User.created_at >= since_utc)).scalars())
def clear_inactive_accounts(db: Session, *, days: int, dry_run: bool = False) -> dict:
"""连续无活跃**满 `days` 整天之后**把用户金币 + 金币现金清零(邀请金不动),逐用户独立事务。
- 活跃 = 发起比价 / 发起领券(**登录不算**)。last_active >= today-days 视为活跃;连续无
比价无领券满 days 天(且注册也满 days 天)才清。days=15 → 最后一次比价/领券在 15 天前
当天仍留、第 16 天起清。today 一律北京时(rewards.cn_today())。
- 新用户保护:注册未满 days 天(created_at 在 today-days 之后)豁免,不会刚注册就被清。
- 只扫描有余额(coin_balance>0 或 cash_balance_cents>0)的账户:清零后余额=0,下一轮
自然跳过 → 天然幂等,重启 / 多次唤醒 / 补跑都安全。
- 逐用户独立事务:单用户异常 rollback 不影响其他人。
- dry_run=True:只统计不写库(供运营上线前预演:看会清哪些人、清多少)。
返回统计 dict(scanned/cleared/skipped_active/skipped_new_user/coin_cleared/
cents_cleared/failed)。
"""
# days<1 会把 active_since 推到未来 → 全员判流失清空(灾难);防御性钳到 >=1。
days = max(1, days)
today = rewards.cn_today()
# 连续无活跃满 days 整天之后才清:active_since=today-days,活跃/新用户都以此为边界。
active_since = today - timedelta(days=days)
active_ids = _active_user_ids_since(db, active_since)
new_user_ids = _new_user_ids_since(db, active_since)
# 只取候选 user_id;清零金额在循环内读**实时**余额(不用此处快照)——活跃集合算完到逐行
# 清零之间余额可能变(如并发看广告发币),用快照 grant 会清不干净、balance_after≠0。
candidate_ids = (
db.execute(
select(CoinAccount.user_id).where(
or_(CoinAccount.coin_balance > 0, CoinAccount.cash_balance_cents > 0)
)
)
.scalars()
.all()
)
stats = {
"scanned": 0,
"cleared": 0,
"skipped_active": 0,
"skipped_new_user": 0,
"coin_cleared": 0,
"cents_cleared": 0,
"failed": 0,
}
remark = f"连续{days}天无活跃清零"
for user_id in candidate_ids:
stats["scanned"] += 1
if user_id in active_ids:
stats["skipped_active"] += 1
continue
if user_id in new_user_ids: # 注册未满 days 天,新用户宽限
stats["skipped_new_user"] += 1
continue
if dry_run:
acc = db.get(CoinAccount, user_id)
if acc is not None:
stats["cleared"] += 1
stats["coin_cleared"] += acc.coin_balance
stats["cents_cleared"] += acc.cash_balance_cents
continue
try:
# lock=True 对该账户行加 FOR UPDATE(PG 生效,SQLite no-op),读-清-写串行化防并发
acc = get_or_create_account(db, user_id, commit=False, lock=True)
coin, cents = acc.coin_balance, acc.cash_balance_cents
if coin <= 0 and cents <= 0:
continue # 快照后被并发清空 / 变动,无需再清
if coin > 0:
grant_coins(db, user_id, -coin, biz_type=CLEAR_BIZ_TYPE, remark=remark)
if cents > 0:
grant_cash(db, user_id, -cents, biz_type=CLEAR_BIZ_TYPE, remark=remark)
db.commit()
stats["cleared"] += 1
stats["coin_cleared"] += coin
stats["cents_cleared"] += cents
except Exception: # noqa: BLE001 - 批处理不因单用户异常中断
db.rollback()
stats["failed"] += 1
return stats
-61
View File
@@ -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(内存计数跨用例累加),本用例临时打开并清空计数隔离。"""
-57
View File
@@ -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 # 桶数没超阈值 → 不清理