lbb123 commited on
Commit
2a76bb6
·
verified ·
1 Parent(s): 35d46ba

Upload 3 files

Browse files
Files changed (3) hide show
  1. app/cache.py +133 -0
  2. app/db.py +4 -1
  3. app/security.py +48 -7
app/cache.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """内存缓存与限流计数,减少对数据库的依赖。
2
+
3
+ key 缓存:首次校验后把 key 的校验信息与配额缓存在内存,
4
+ 后续请求命中缓存即不查库;后台改 key 时清缓存。
5
+ RPM:用内存滑动窗口计数,不再查 request_logs 表。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+ from dataclasses import dataclass
12
+ from typing import Dict, List, Set
13
+
14
+ import hashlib
15
+
16
+
17
+ @dataclass
18
+ class CachedKey:
19
+ """key 的缓存校验信息(不含敏感的完整 key)。"""
20
+ id: int
21
+ name: str
22
+ prefix: str
23
+ key_hash: str
24
+ is_active: bool
25
+ expires_at: float | None # epoch 秒
26
+ allowed_models: list[str] | None
27
+ rpm_limit: int | None
28
+ tpm_limit: int | None
29
+ token_quota: int | None
30
+ tokens_used: int
31
+ # 负载快照时间,用于判断是否需要回库刷新 tokens_used
32
+ loaded_at: float
33
+
34
+
35
+ class KeyCache:
36
+ """按 prefix 索引的 key 内存缓存。"""
37
+
38
+ def __init__(self) -> None:
39
+ self._by_prefix: Dict[str, CachedKey] = {}
40
+ self._by_id: Dict[int, str] = {} # id -> prefix,用于按 id 失效
41
+ self._lock = asyncio.Lock()
42
+
43
+ def get(self, prefix: str) -> CachedKey | None:
44
+ return self._by_prefix.get(prefix)
45
+
46
+ async def put(self, k: CachedKey) -> None:
47
+ async with self._lock:
48
+ self._by_prefix[k.prefix] = k
49
+ self._by_id[k.id] = k.prefix
50
+
51
+ async def invalidate_id(self, key_id: int) -> None:
52
+ """按 id 失效(后台改/删 key 时调用)。"""
53
+ async with self._lock:
54
+ prefix = self._by_id.pop(key_id, None)
55
+ if prefix:
56
+ self._by_prefix.pop(prefix, None)
57
+
58
+ async def invalidate_prefix(self, prefix: str) -> None:
59
+ async with self._lock:
60
+ ck = self._by_prefix.pop(prefix, None)
61
+ if ck:
62
+ self._by_id.pop(ck.id, None)
63
+
64
+ async def clear(self) -> None:
65
+ async with self._lock:
66
+ self._by_prefix.clear()
67
+ self._by_id.clear()
68
+
69
+ async def add_usage(self, key_id: int, tokens: int) -> None:
70
+ """成功转发后把用量累加到内存缓存(不回库),保持配额判断近实时。"""
71
+ async with self._lock:
72
+ prefix = self._by_id.get(key_id)
73
+ if prefix:
74
+ ck = self._by_prefix.get(prefix)
75
+ if ck:
76
+ ck.tokens_used += tokens
77
+
78
+
79
+ class RPMCounter:
80
+ """内存滑动窗口 RPM 计数,按 key_id 维度。"""
81
+
82
+ def __init__(self, window: float = 60.0) -> None:
83
+ self._window = window
84
+ self._hits: Dict[int, List[float]] = {}
85
+ self._lock = asyncio.Lock()
86
+
87
+ async def hit_and_check(self, key_id: int, limit: int | None) -> bool:
88
+ """记录一次命中并返回是否在限额内(True=放行,False=超限)。"""
89
+ if not limit:
90
+ return True
91
+ now = time.time()
92
+ cutoff = now - self._window
93
+ async with self._lock:
94
+ hits = self._hits.get(key_id)
95
+ if hits is None:
96
+ hits = []
97
+ self._hits[key_id] = hits
98
+ # 清掉过期命中
99
+ hits[:] = [t for t in hits if t > cutoff]
100
+ if len(hits) >= limit:
101
+ return False
102
+ hits.append(now)
103
+ return True
104
+
105
+
106
+ key_cache = KeyCache()
107
+ rpm_counter = RPMCounter()
108
+
109
+
110
+ def verify_key_hash(raw: str, digest: str) -> bool:
111
+ return hashlib.sha256(raw.encode()).hexdigest() == digest
112
+
113
+
114
+ async def cached_key_from(raw: str, db_loader) -> CachedKey | None:
115
+ """先查缓存,未命中则调 db_loader(prefix) 回库加载并缓存。"""
116
+ prefix = raw[:20]
117
+ ck = key_cache.get(prefix)
118
+ if ck is not None and verify_key_hash(raw, ck.key_hash):
119
+ return ck
120
+ # 未命中或哈希不匹配,回库
121
+ db_key = await db_loader(prefix)
122
+ if db_key is None:
123
+ return None
124
+ exp = db_key.expires_at.timestamp() if db_key.expires_at else None
125
+ ck = CachedKey(
126
+ id=db_key.id, name=db_key.name, prefix=db_key.prefix, key_hash=db_key.key_hash,
127
+ is_active=db_key.is_active, expires_at=exp, allowed_models=db_key.allowed_models,
128
+ rpm_limit=db_key.rpm_limit, tpm_limit=db_key.tpm_limit,
129
+ token_quota=db_key.token_quota, tokens_used=db_key.tokens_used or 0,
130
+ loaded_at=time.time(),
131
+ )
132
+ await key_cache.put(ck)
133
+ return ck
app/db.py CHANGED
@@ -42,7 +42,10 @@ def _normalize_db_url() -> tuple[object, dict]:
42
 
43
  # 兼容 Neon/Supabase 的 PgBouncer 连接池:关闭预编译语句缓存,
44
  # 否则跨事务会报 "prepared statement ... does not exist"
45
- connect_args.setdefault("statement_cache_size", 0)
 
 
 
46
 
47
  return url, connect_args
48
 
 
42
 
43
  # 兼容 Neon/Supabase 的 PgBouncer 连接池:关闭预编译语句缓存,
44
  # 否则跨事务会报 "prepared statement ... does not exist"
45
+ # asyncpg 专属:兼容 Neon/Supabase 的 PgBouncer 连接池,否则跨事务会报
46
+ # "prepared statement ... does not exist"(仅 asyncpg 驱动可设)
47
+ if url.drivername == "postgresql+asyncpg":
48
+ connect_args.setdefault("statement_cache_size", 0)
49
 
50
  return url, connect_args
51
 
app/security.py CHANGED
@@ -12,6 +12,7 @@ from passlib.context import CryptContext
12
  from sqlalchemy import select
13
  from sqlalchemy.ext.asyncio import AsyncSession
14
 
 
15
  from .config import get_settings
16
  from .db import get_db
17
  from .models import AdminUser, ApiKey
@@ -69,11 +70,22 @@ async def get_admin(
69
  return user
70
 
71
 
 
 
 
 
 
 
 
72
  async def get_valid_api_key(
73
  credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
74
  db: AsyncSession = Depends(get_db),
75
- ) -> ApiKey:
76
- """代理接口依赖:校验客户端 relay key。"""
 
 
 
 
77
  if not credentials or not credentials.credentials:
78
  raise HTTPException(
79
  status_code=401,
@@ -81,10 +93,39 @@ async def get_valid_api_key(
81
  headers={"WWW-Authenticate": "Bearer"},
82
  )
83
  raw = credentials.credentials.strip()
84
- prefix = raw[:20]
85
- key = (await db.execute(select(ApiKey).where(ApiKey.prefix == prefix))).scalar_one_or_none()
86
- if not key or not key.is_active or not verify_api_key(raw, key.key_hash):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  raise HTTPException(status_code=401, detail="无效的 API Key")
88
- if key.expires_at and key.expires_at < datetime.now(timezone.utc):
89
  raise HTTPException(status_code=401, detail="API Key 已过期")
90
- return key
 
 
 
 
 
 
 
 
 
 
 
 
12
  from sqlalchemy import select
13
  from sqlalchemy.ext.asyncio import AsyncSession
14
 
15
+ from . import cache
16
  from .config import get_settings
17
  from .db import get_db
18
  from .models import AdminUser, ApiKey
 
70
  return user
71
 
72
 
73
+ async def _load_key_from_db(prefix: str, db: AsyncSession) -> ApiKey | None:
74
+ """从数据库按前缀加载 key。"""
75
+ return (
76
+ await db.execute(select(ApiKey).where(ApiKey.prefix == prefix))
77
+ ).scalar_one_or_none()
78
+
79
+
80
  async def get_valid_api_key(
81
  credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
82
  db: AsyncSession = Depends(get_db),
83
+ ) -> cache.CachedKey:
84
+ """代理接口依赖:校验客户端 relay key,优先走内存缓存。
85
+
86
+ 缓存未命中时回库加载,加载失败则抛 401(无效 key)。
87
+ 返回 CachedKey(内存缓存对象),供后续配额/日志使用,避免再查库。
88
+ """
89
  if not credentials or not credentials.credentials:
90
  raise HTTPException(
91
  status_code=401,
 
93
  headers={"WWW-Authenticate": "Bearer"},
94
  )
95
  raw = credentials.credentials.strip()
96
+
97
+ # 先查内存缓存
98
+ ck = cache.key_cache.get(raw[:20])
99
+ if ck is not None and verify_api_key(raw, ck.key_hash):
100
+ if not ck.is_active:
101
+ raise HTTPException(status_code=401, detail="无效的 API Key")
102
+ if ck.expires_at and ck.expires_at < datetime.now(timezone.utc).timestamp():
103
+ raise HTTPException(status_code=401, detail="API Key 已过期")
104
+ return ck
105
+
106
+ # 缓存未命中或哈希不匹配,回库加载
107
+ # 缓存未命中或哈希不匹配,回库加载(数据库异常时降级:缓存可用则继续,否则 503)
108
+ try:
109
+ db_key = await _load_key_from_db(raw[:20], db)
110
+ except Exception:
111
+ # 数据库挂了:若缓存里还有这个 key(哈希匹配但已过期/停用等分支没进),仍可用
112
+ ck = cache.key_cache.get(raw[:20])
113
+ if ck is not None and verify_api_key(raw, ck.key_hash):
114
+ return ck
115
+ raise HTTPException(status_code=503, detail="数据库暂时不可用,请稍后再试")
116
+ if not db_key or not db_key.is_active or not verify_api_key(raw, db_key.key_hash):
117
  raise HTTPException(status_code=401, detail="无效的 API Key")
118
+ if db_key.expires_at and db_key.expires_at < datetime.now(timezone.utc):
119
  raise HTTPException(status_code=401, detail="API Key 已过期")
120
+
121
+ # 缓存校验信息
122
+ exp = db_key.expires_at.timestamp() if db_key.expires_at else None
123
+ ck = cache.CachedKey(
124
+ id=db_key.id, name=db_key.name, prefix=db_key.prefix, key_hash=db_key.key_hash,
125
+ is_active=db_key.is_active, expires_at=exp, allowed_models=db_key.allowed_models,
126
+ rpm_limit=db_key.rpm_limit, tpm_limit=db_key.tpm_limit,
127
+ token_quota=db_key.token_quota, tokens_used=db_key.tokens_used or 0,
128
+ loaded_at=datetime.now(timezone.utc).timestamp(),
129
+ )
130
+ await cache.key_cache.put(ck)
131
+ return ck