Spaces:
Running
Running
File size: 14,248 Bytes
c8e5eaa be93389 c8e5eaa b5d7112 c8e5eaa 2411edd c8e5eaa 2411edd c8e5eaa b9d11ff c8e5eaa b9d11ff c8e5eaa b9d11ff c8e5eaa b9d11ff c8e5eaa b9d11ff c8e5eaa be93389 c8e5eaa 2411edd be93389 2411edd c8e5eaa b5d7112 c8e5eaa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | """跨 worker API 限流:已验证用户按账号计数,匿名请求按客户端 IP 计数。"""
from datetime import datetime
from functools import wraps
from inspect import iscoroutinefunction
import hashlib
import hmac
import logging
import secrets
import threading
import time
from fastapi import Request
from fastapi import HTTPException
from auth_io import run_identity_io
from owned_thread import OwnedThreadGroup
from sqlalchemy import case, delete
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from 安全认证 import get_account_from_token
from database_sql import SessionLocal, engine
from models_sql import ApiRateLimitBucket, VerificationCode, VerificationContactCooldown
from 安全认证 import JWT_SECRET
logger = logging.getLogger("ComfyUI-Ranking.RateLimit")
_cleanup_lock = threading.Lock()
_last_cleanup_at = 0.0
MAX_SHARED_WORK = 40
_shared_work = OwnedThreadGroup(MAX_SHARED_WORK,
lambda: HTTPException(status_code=503, detail='限流服务暂不可用'))
run_shared_work = _shared_work.run
start_shared_work = _shared_work.start
stop_shared_work = _shared_work.stop
close_shared_work = _shared_work.close
def get_remote_address(request: Request) -> str:
"""仅信任 ASGI transport 提供的客户端地址,不直接信任可伪造的代理头。"""
client = getattr(request, "client", None)
return getattr(client, "host", None) or "unknown"
def get_rate_limit_key(request: Request) -> str:
"""返回稳定的限流身份键;只读取并验证 Bearer Token,不记录或返回 Token。"""
authorization = request.headers.get("Authorization", "")
scheme, _, token = authorization.partition(" ")
if scheme.lower() == "bearer" and token.strip():
account = get_account_from_token(token.strip())
if account:
return f"account:{account.casefold()}"
return f"ip:{get_remote_address(request)}"
def _consume_shared_limit(identity: str, scope: str, limit: int) -> None:
"""使用数据库原子 UPSERT 计数;数据库不可用时 fail closed。"""
now = int(time.time())
window_start = now - (now % 60)
bucket_key = hashlib.sha256(f"{identity}\0{scope}".encode("utf-8")).hexdigest()
session = None
try:
session = SessionLocal()
dialect = engine.dialect.name
values = {
"bucket_key": bucket_key,
"window_start": window_start,
"request_count": 1,
"updated_at": datetime.utcnow(),
}
if dialect == "sqlite":
statement = sqlite_insert(ApiRateLimitBucket).values(**values)
statement = statement.on_conflict_do_update(
index_elements=[ApiRateLimitBucket.bucket_key],
set_={
"window_start": window_start,
"request_count": case(
(ApiRateLimitBucket.window_start == window_start,
case((ApiRateLimitBucket.request_count < limit + 1,
ApiRateLimitBucket.request_count + 1), else_=limit + 1)),
else_=1,
),
"updated_at": datetime.utcnow(),
},
)
elif dialect == "postgresql":
statement = postgresql_insert(ApiRateLimitBucket).values(**values)
statement = statement.on_conflict_do_update(
index_elements=[ApiRateLimitBucket.bucket_key],
set_={
"window_start": window_start,
"request_count": case(
(ApiRateLimitBucket.window_start == window_start,
case((ApiRateLimitBucket.request_count < limit + 1,
ApiRateLimitBucket.request_count + 1), else_=limit + 1)),
else_=1,
),
"updated_at": datetime.utcnow(),
},
)
elif dialect == "mysql":
statement = mysql_insert(ApiRateLimitBucket).values(**values)
statement = statement.on_duplicate_key_update(
[
# MySQL evaluates ON DUPLICATE KEY UPDATE assignments left-to-right.
# Read the old window before replacing it, or a new minute would
# incorrectly continue the prior minute's count.
("request_count", case(
(ApiRateLimitBucket.window_start == window_start,
case((ApiRateLimitBucket.request_count < limit + 1,
ApiRateLimitBucket.request_count + 1), else_=limit + 1)),
else_=1,
)),
("window_start", window_start),
("updated_at", datetime.utcnow()),
]
)
else:
raise RuntimeError(f"Unsupported SQL dialect for shared rate limiting: {dialect}")
session.execute(statement)
bucket = session.get(ApiRateLimitBucket, bucket_key)
if not bucket:
raise RuntimeError("Rate limit bucket upsert did not produce a row")
request_count = bucket.request_count
session.commit()
if request_count > limit:
raise HTTPException(status_code=429, detail=f"请求过于频繁,限流 {limit}/分钟,请稍后再试")
_cleanup_old_buckets_if_due()
except HTTPException:
if session is not None:
try: session.rollback()
except Exception: logger.error("SHARED_RATE_LIMIT_ROLLBACK_FAIL")
raise
except Exception as exc:
if session is not None:
try: session.rollback()
except Exception: logger.error("SHARED_RATE_LIMIT_ROLLBACK_FAIL")
logger.error("SHARED_RATE_LIMIT_DB_FAIL | scope=%s | error_type=%s", scope, type(exc).__name__)
raise HTTPException(status_code=503, detail="限流服务暂不可用") from exc
finally:
if session is not None:
try: session.close()
except Exception as error:
logger.error("SHARED_RATE_LIMIT_CLOSE_FAIL | error_type=%s", type(error).__name__)
raise HTTPException(status_code=503, detail="限流服务暂不可用") from None
def _cleanup_old_buckets_if_due() -> None:
global _last_cleanup_at
now = time.time()
if now - _last_cleanup_at < 900:
return
if not _cleanup_lock.acquire(blocking=False):
return
try:
if now - _last_cleanup_at < 900:
return
session = SessionLocal()
try:
session.execute(delete(ApiRateLimitBucket).where(ApiRateLimitBucket.window_start < int(now) - 86_400))
session.execute(delete(VerificationContactCooldown).where(
VerificationContactCooldown.last_sent_at < int(now) - 86_400
))
session.execute(delete(VerificationCode).where(VerificationCode.expires_at < int(now)))
session.commit()
_last_cleanup_at = now
except Exception:
session.rollback()
logger.warning("SHARED_RATE_LIMIT_CLEANUP_FAIL")
finally:
session.close()
finally:
_cleanup_lock.release()
def shared_limit(limit: int):
"""FastAPI endpoint decorator backed by the shared SQL minute-window table."""
if not isinstance(limit, int) or limit < 1:
raise ValueError("limit must be a positive integer")
def decorate(endpoint):
scope = f"{endpoint.__module__}.{endpoint.__name__}"
is_async = iscoroutinefunction(endpoint)
@wraps(endpoint)
async def wrapped(*args, **kwargs):
request = kwargs.get("request")
if request is None:
request = next((arg for arg in args if isinstance(arg, Request)), None)
if request is None:
raise RuntimeError(f"{scope} requires a FastAPI Request parameter")
identity = await run_identity_io(get_rate_limit_key, request)
await run_shared_work(_consume_shared_limit, identity, scope, limit)
if is_async:
return await endpoint(*args, **kwargs)
return await run_shared_work(endpoint, *args, **kwargs)
return wrapped
return decorate
def reserve_contact_cooldown(contact: str, cooldown_seconds: int) -> int:
"""原子预留联系方式冷却;返回 0 表示成功,否则返回剩余秒数。"""
normalized = str(contact or "").strip().casefold()
contact_key = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
now = int(time.time())
cutoff = now - cooldown_seconds
claim_id = secrets.token_hex(16)
session = SessionLocal()
try:
values = {"contact_key": contact_key, "last_sent_at": now, "claim_id": claim_id}
dialect = engine.dialect.name
if dialect == "sqlite":
statement = sqlite_insert(VerificationContactCooldown).values(**values)
statement = statement.on_conflict_do_update(
index_elements=[VerificationContactCooldown.contact_key],
set_={
"last_sent_at": case((VerificationContactCooldown.last_sent_at <= cutoff, now),
else_=VerificationContactCooldown.last_sent_at),
"claim_id": case((VerificationContactCooldown.last_sent_at <= cutoff, claim_id),
else_=VerificationContactCooldown.claim_id),
},
)
elif dialect == "postgresql":
statement = postgresql_insert(VerificationContactCooldown).values(**values)
statement = statement.on_conflict_do_update(
index_elements=[VerificationContactCooldown.contact_key],
set_={
"last_sent_at": case((VerificationContactCooldown.last_sent_at <= cutoff, now),
else_=VerificationContactCooldown.last_sent_at),
"claim_id": case((VerificationContactCooldown.last_sent_at <= cutoff, claim_id),
else_=VerificationContactCooldown.claim_id),
},
)
elif dialect == "mysql":
statement = mysql_insert(VerificationContactCooldown).values(**values)
statement = statement.on_duplicate_key_update(
[
# Evaluate eligibility against the old timestamp before advancing it.
("claim_id", case((VerificationContactCooldown.last_sent_at <= cutoff, claim_id),
else_=VerificationContactCooldown.claim_id)),
("last_sent_at", case((VerificationContactCooldown.last_sent_at <= cutoff, now),
else_=VerificationContactCooldown.last_sent_at)),
]
)
else:
raise RuntimeError(f"Unsupported SQL dialect for contact cooldown: {dialect}")
session.execute(statement)
record = session.get(VerificationContactCooldown, contact_key)
if not record:
raise RuntimeError("Contact cooldown upsert did not produce a row")
allowed = record.claim_id == claim_id
last_sent_at = record.last_sent_at
session.commit()
return 0 if allowed else max(1, cooldown_seconds - (now - last_sent_at))
except Exception as exc:
session.rollback()
logger.error("CONTACT_COOLDOWN_DB_FAIL | error_type=%s", type(exc).__name__)
raise HTTPException(status_code=503, detail="验证码发送频控暂不可用") from exc
finally:
session.close()
def _verification_key(key: str) -> str:
return hashlib.sha256(str(key).strip().casefold().encode("utf-8")).hexdigest()
def _verification_digest(key: str, code: str) -> str:
return hmac.new(JWT_SECRET.encode("utf-8"), f"{_verification_key(key)}\0{code}".encode("utf-8"), hashlib.sha256).hexdigest()
def store_verification_code(key: str, code: str, expires_at: int) -> None:
"""存储跨 worker 可用的一次性验证码,不保存验证码或联系方式明文。"""
session = SessionLocal()
try:
session.merge(VerificationCode(contact_key=_verification_key(key),
code_digest=_verification_digest(key, code),
expires_at=int(expires_at)))
session.commit()
except Exception as exc:
session.rollback()
logger.error("VERIFICATION_CODE_STORE_FAIL | error_type=%s", type(exc).__name__)
raise HTTPException(status_code=503, detail="验证码服务暂不可用") from exc
finally:
session.close()
def consume_verification_code(key: str, code: str, session=None) -> bool:
"""正确且未过期时原子删除验证码;可传入业务事务 session 一并提交。"""
owns_session = session is None
session = session or SessionLocal()
try:
result = session.execute(delete(VerificationCode).where(
VerificationCode.contact_key == _verification_key(key),
VerificationCode.code_digest == _verification_digest(key, code),
VerificationCode.expires_at >= int(time.time()),
))
consumed = result.rowcount == 1
if owns_session:
session.commit()
return consumed
except Exception as exc:
if owns_session:
session.rollback()
logger.error("VERIFICATION_CODE_CONSUME_FAIL | error_type=%s", type(exc).__name__)
raise HTTPException(status_code=503, detail="验证码服务暂不可用") from exc
finally:
if owns_session:
session.close()
|