feat: add all application source code
- apps/api: FastAPI backend with Dockerfile - apps/web: Next.js frontend with Dockerfile - apps/sensor: Signal collection agent - packages: shared packages Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
229
apps/api/src/core/redis_client.py
Normal file
229
apps/api/src/core/redis_client.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Redis Client - AWOOOI 分散式狀態儲存
|
||||
=====================================
|
||||
Phase 6.1.1: Multi-Sig Redis 遷移
|
||||
|
||||
Features:
|
||||
- 非同步連線池 (Connection Pool)
|
||||
- Lifespan 管理 (啟動/關閉)
|
||||
- 分散式鎖 (Distributed Lock)
|
||||
- 環境變數驅動 (禁止硬編碼 IP)
|
||||
|
||||
統帥鐵律:
|
||||
- 所有 Redis 操作必須使用此模組
|
||||
- 禁止在其他地方直接建立 Redis 連線
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Connection Pool
|
||||
# =============================================================================
|
||||
|
||||
_redis_pool: redis.Redis | None = None
|
||||
|
||||
|
||||
async def init_redis_pool() -> redis.Redis:
|
||||
"""
|
||||
初始化 Redis 連線池
|
||||
|
||||
統帥鐵律: 連線池在 Lifespan 啟動時建立
|
||||
"""
|
||||
global _redis_pool
|
||||
|
||||
if _redis_pool is not None:
|
||||
return _redis_pool
|
||||
|
||||
_redis_pool = redis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
max_connections=20,
|
||||
socket_timeout=5.0,
|
||||
socket_connect_timeout=5.0,
|
||||
)
|
||||
|
||||
# 測試連線
|
||||
try:
|
||||
await _redis_pool.ping()
|
||||
logger.info(
|
||||
"redis_pool_initialized",
|
||||
url=settings.REDIS_URL.split("@")[-1], # 隱藏密碼
|
||||
)
|
||||
except redis.ConnectionError as e:
|
||||
logger.error("redis_connection_failed", error=str(e))
|
||||
raise
|
||||
|
||||
return _redis_pool
|
||||
|
||||
|
||||
async def close_redis_pool() -> None:
|
||||
"""
|
||||
關閉 Redis 連線池
|
||||
|
||||
統帥鐵律: 連線池在 Lifespan 關閉時回收
|
||||
"""
|
||||
global _redis_pool
|
||||
|
||||
if _redis_pool is not None:
|
||||
await _redis_pool.close()
|
||||
_redis_pool = None
|
||||
logger.info("redis_pool_closed")
|
||||
|
||||
|
||||
def get_redis() -> redis.Redis:
|
||||
"""
|
||||
取得 Redis 連線
|
||||
|
||||
Raises:
|
||||
RuntimeError: 若連線池未初始化
|
||||
"""
|
||||
if _redis_pool is None:
|
||||
raise RuntimeError("Redis pool not initialized. Call init_redis_pool() first.")
|
||||
return _redis_pool
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Distributed Lock (分散式鎖)
|
||||
# =============================================================================
|
||||
|
||||
class RedisLock:
|
||||
"""
|
||||
Redis 分散式鎖
|
||||
|
||||
防禦場景:
|
||||
- 防止 Web + Telegram 同時簽核導致 Race Condition
|
||||
- 防止 K8s Executor 被觸發兩次
|
||||
|
||||
使用方式:
|
||||
async with RedisLock("approval:123:lock", timeout=10):
|
||||
# Critical section
|
||||
await execute_approval()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
timeout: int = 30,
|
||||
blocking_timeout: float = 5.0,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
key: 鎖的 Redis Key
|
||||
timeout: 鎖的自動過期時間 (秒)
|
||||
blocking_timeout: 等待取得鎖的最大時間 (秒)
|
||||
"""
|
||||
self.key = f"lock:{key}"
|
||||
self.timeout = timeout
|
||||
self.blocking_timeout = blocking_timeout
|
||||
self._lock_value: str | None = None
|
||||
|
||||
async def acquire(self) -> bool:
|
||||
"""
|
||||
嘗試取得鎖
|
||||
|
||||
Returns:
|
||||
bool: 是否成功取得鎖
|
||||
"""
|
||||
import uuid
|
||||
|
||||
redis_client = get_redis()
|
||||
self._lock_value = str(uuid.uuid4())
|
||||
|
||||
# 使用 SET NX EX 實現原子操作
|
||||
acquired = await redis_client.set(
|
||||
self.key,
|
||||
self._lock_value,
|
||||
nx=True, # Only set if not exists
|
||||
ex=self.timeout, # Expire in timeout seconds
|
||||
)
|
||||
|
||||
if acquired:
|
||||
logger.debug("redis_lock_acquired", key=self.key)
|
||||
return True
|
||||
|
||||
# 如果沒有立即取得,則等待
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
while asyncio.get_event_loop().time() - start_time < self.blocking_timeout:
|
||||
await asyncio.sleep(0.1)
|
||||
acquired = await redis_client.set(
|
||||
self.key,
|
||||
self._lock_value,
|
||||
nx=True,
|
||||
ex=self.timeout,
|
||||
)
|
||||
if acquired:
|
||||
logger.debug("redis_lock_acquired_after_wait", key=self.key)
|
||||
return True
|
||||
|
||||
logger.warning("redis_lock_timeout", key=self.key)
|
||||
return False
|
||||
|
||||
async def release(self) -> bool:
|
||||
"""
|
||||
釋放鎖
|
||||
|
||||
使用 Lua Script 確保只釋放自己持有的鎖 (防止誤刪)
|
||||
|
||||
Returns:
|
||||
bool: 是否成功釋放
|
||||
"""
|
||||
if self._lock_value is None:
|
||||
return False
|
||||
|
||||
redis_client = get_redis()
|
||||
|
||||
# Lua script: 只有當值匹配時才刪除 (原子操作)
|
||||
lua_script = """
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
"""
|
||||
|
||||
result = await redis_client.eval(lua_script, 1, self.key, self._lock_value)
|
||||
|
||||
if result:
|
||||
logger.debug("redis_lock_released", key=self.key)
|
||||
return True
|
||||
else:
|
||||
logger.warning("redis_lock_release_failed", key=self.key)
|
||||
return False
|
||||
|
||||
async def __aenter__(self) -> "RedisLock":
|
||||
acquired = await self.acquire()
|
||||
if not acquired:
|
||||
raise RuntimeError(f"Failed to acquire lock: {self.key}")
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
await self.release()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Context Manager
|
||||
# =============================================================================
|
||||
|
||||
@asynccontextmanager
|
||||
async def redis_context() -> AsyncGenerator[redis.Redis, None]:
|
||||
"""
|
||||
Redis 連線 Context Manager
|
||||
|
||||
用於需要獨立連線的場景
|
||||
"""
|
||||
client = get_redis()
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
pass # 使用連線池,不需要關閉
|
||||
Reference in New Issue
Block a user