Add Redis-based one-time download token support
Integrate Redis as a backend for one-time download token validation, with in-memory fallback. Added RedisManager for connection lifecycle, TokenStore for atomic token usage checks, and related configuration via environment variables. Updated download flow to ensure tokens are single-use, and improved API robustness for batch operations. Updated dependencies to include redis and cachetools.
This commit is contained in:
88
service/redis/__init__.py
Normal file
88
service/redis/__init__.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Redis 服务模块
|
||||
|
||||
提供 Redis 异步客户端的生命周期管理。
|
||||
通过环境变量配置连接参数,未配置时不初始化客户端。
|
||||
"""
|
||||
from typing import ClassVar
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from loguru import logger as l
|
||||
|
||||
from utils.conf import appmeta
|
||||
|
||||
|
||||
class RedisManager:
|
||||
"""
|
||||
Redis 连接管理器
|
||||
|
||||
使用 ClassVar 管理全局单例客户端。
|
||||
"""
|
||||
|
||||
_client: ClassVar[aioredis.Redis | None] = None
|
||||
"""Redis 客户端实例"""
|
||||
|
||||
_is_available: ClassVar[bool] = False
|
||||
"""Redis 是否可用"""
|
||||
|
||||
@classmethod
|
||||
def is_configured(cls) -> bool:
|
||||
"""检查是否配置了 Redis"""
|
||||
return appmeta.redis_url is not None
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
"""检查 Redis 是否可用"""
|
||||
return cls._is_available
|
||||
|
||||
@classmethod
|
||||
def get_client(cls) -> aioredis.Redis | None:
|
||||
"""
|
||||
获取 Redis 客户端实例。
|
||||
|
||||
:return: Redis 客户端,未配置或连接失败时返回 None
|
||||
"""
|
||||
return cls._client if cls._is_available else None
|
||||
|
||||
@classmethod
|
||||
async def connect(cls) -> None:
|
||||
"""
|
||||
连接 Redis 服务器。
|
||||
|
||||
在应用启动时调用,如果未配置 Redis 则跳过。
|
||||
"""
|
||||
if not cls.is_configured():
|
||||
l.info("未配置 REDIS_URL,跳过 Redis 连接")
|
||||
return
|
||||
|
||||
try:
|
||||
cls._client = aioredis.Redis(
|
||||
host=appmeta.redis_url,
|
||||
port=appmeta.redis_port,
|
||||
password=appmeta.redis_password,
|
||||
db=appmeta.redis_db,
|
||||
protocol=appmeta.redis_protocol,
|
||||
)
|
||||
|
||||
# 测试连接
|
||||
await cls._client.ping()
|
||||
cls._is_available = True
|
||||
l.info(f"Redis 连接成功: {appmeta.redis_url}:{appmeta.redis_port}")
|
||||
|
||||
except Exception as e:
|
||||
l.warning(f"Redis 连接失败,将使用内存缓存作为降级方案: {e}")
|
||||
cls._client = None
|
||||
cls._is_available = False
|
||||
|
||||
@classmethod
|
||||
async def disconnect(cls) -> None:
|
||||
"""
|
||||
断开 Redis 连接。
|
||||
|
||||
在应用关闭时调用。
|
||||
"""
|
||||
if cls._client is not None:
|
||||
await cls._client.close()
|
||||
cls._client = None
|
||||
cls._is_available = False
|
||||
l.info("Redis 连接已关闭")
|
||||
65
service/redis/token_store.py
Normal file
65
service/redis/token_store.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
一次性令牌状态存储
|
||||
|
||||
支持 Redis(首选)和内存缓存(降级)两种存储后端。
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from typing import ClassVar
|
||||
|
||||
from cachetools import TTLCache
|
||||
from loguru import logger as l
|
||||
|
||||
from . import RedisManager
|
||||
|
||||
|
||||
class TokenStore:
|
||||
"""
|
||||
一次性令牌存储管理器
|
||||
|
||||
根据 Redis 可用性自动选择存储后端:
|
||||
- Redis 可用:使用 Redis(支持分布式部署)
|
||||
- Redis 不可用:使用内存缓存(仅单实例)
|
||||
"""
|
||||
|
||||
_memory_cache: ClassVar[TTLCache[str, bool]] = TTLCache(maxsize=10000, ttl=3600)
|
||||
"""内存缓存降级方案"""
|
||||
|
||||
@classmethod
|
||||
async def mark_used(cls, jti: str, ttl: timedelta | int) -> bool:
|
||||
"""
|
||||
标记令牌为已使用(原子操作)。
|
||||
|
||||
:param jti: 令牌唯一标识符(JWT ID)
|
||||
:param ttl: 过期时间
|
||||
:return: True 表示首次标记成功(可以使用),False 表示已被使用
|
||||
"""
|
||||
ttl_seconds = int(ttl.total_seconds()) if isinstance(ttl, timedelta) else ttl
|
||||
client = RedisManager.get_client()
|
||||
|
||||
if client is not None:
|
||||
# 使用 Redis SETNX 原子操作
|
||||
key = f"download_token:{jti}"
|
||||
result = await client.set(key, "1", nx=True, ex=ttl_seconds)
|
||||
return result is not None
|
||||
else:
|
||||
# 降级使用内存缓存
|
||||
if jti in cls._memory_cache:
|
||||
return False
|
||||
cls._memory_cache[jti] = True
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
async def is_used(cls, jti: str) -> bool:
|
||||
"""
|
||||
检查令牌是否已被使用。
|
||||
|
||||
:param jti: 令牌唯一标识符
|
||||
:return: True 表示已被使用
|
||||
"""
|
||||
client = RedisManager.get_client()
|
||||
|
||||
if client is not None:
|
||||
key = f"download_token:{jti}"
|
||||
return await client.exists(key) > 0
|
||||
else:
|
||||
return jti in cls._memory_cache
|
||||
Reference in New Issue
Block a user