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()