modify: 更新日志配置

This commit is contained in:
2026-04-26 10:20:32 +08:00
parent 55463a9d07
commit 00b9e0ebc0
83 changed files with 4050 additions and 1755 deletions
View File
+382
View File
@@ -0,0 +1,382 @@
import json
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List
from pydantic import BaseModel
from dataclasses import dataclass, asdict
import hashlib
import threading
import jwt
@dataclass
class TokenCacheData:
"""Token 缓存数据结构"""
token_hash: str
user_id: str
device_id: Optional[str] = None
user_agent: Optional[str] = None
ip_address: Optional[str] = None
created_at: Optional[str] = None
expires_at: Optional[str] = None
last_used: Optional[str] = None
is_active: bool = True
payload_data: Optional[Dict[str, Any]] = None
token_type: str = "access" # access 或 refresh
def __post_init__(self):
if self.created_at is None:
self.created_at = datetime.utcnow().isoformat()
if self.last_used is None:
self.last_used = datetime.utcnow().isoformat()
class JWTCache:
"""JWT Token 缓存管理器"""
def __init__(self, config, cache):
self.config = config
self.lock = threading.RLock()
self.cache = cache
self.secret_key = self.config["security"]["secret_key"]
self.algorithm = self.config["security"]["algorithm"]
def _json_encode(self, obj):
"""自定义 JSON 编码器,处理 datetime 对象"""
def convert_datetime(o):
if isinstance(o, datetime):
return o.isoformat()
raise TypeError(
f"Object of type {o.__class__.__name__} is not JSON serializable"
)
return json.dumps(obj, default=convert_datetime)
def _json_decode(self, s):
"""自定义 JSON 解码器"""
return json.loads(s)
def _hash_token(self, token: str) -> str:
"""Token 哈希化(避免存储明文 Token)"""
return hashlib.sha256(token.encode()).hexdigest()
def create_token(
self,
user_id: str,
user_data: Dict[str, Any],
token_type: str = "access",
device_id: Optional[str] = None,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
) -> str:
"""
创建并缓存 JWT Token
Args:
user_id: 用户ID
user_data: 用户数据
token_type: Token 类型(access 或 refresh)
device_id: 设备ID
user_agent: 用户代理
ip_address: IP地址
Returns:
JWT Token 字符串
"""
with self.lock:
# 计算过期时间
if token_type == "access":
expire_minutes = self.config["security"]["access_token_expire_minutes"]
expires_delta = timedelta(minutes=expire_minutes)
else:
expire_days = self.config["security"]["refresh_token_expire_days"]
expires_delta = timedelta(days=expire_days)
expire = datetime.utcnow() + expires_delta
# 构建 payload
payload = {
"sub": user_id,
"exp": expire,
"iat": datetime.utcnow(),
"type": token_type,
"data": user_data,
"device_id": device_id,
"jti": self._generate_jti(), # JWT ID,唯一标识
}
# 生成 JWT Token
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
if isinstance(token, bytes):
token = token.decode("utf-8")
# 创建缓存数据
token_hash = self._hash_token(token)
cache_data = TokenCacheData(
token_hash=token_hash,
user_id=user_id,
device_id=device_id,
user_agent=user_agent,
ip_address=ip_address,
expires_at=expire.isoformat(), # 转换为字符串
payload_data=payload,
token_type=token_type,
)
# 存储到缓存
self.cache.set(f"token:{token_hash}", asdict(cache_data))
# 更新用户会话索引
self._update_user_session_index(user_id, token_hash, token_type)
# 检查用户会话数限制
self._enforce_session_limit(user_id)
return token
def _generate_jti(self) -> str:
"""生成唯一的 JWT ID"""
import uuid
return str(uuid.uuid4())
def _update_user_session_index(
self, user_id: str, token_hash: str, token_type: str
):
"""更新用户会话索引"""
with self.lock:
key = f"user:{user_id}:{token_type}"
user_tokens = self.cache.get(key, [])
# 添加新 token
user_tokens.append(token_hash)
# 只保留最近的一定数量的 token
max_tokens = 100 # 每个用户每种类型最多保留100个 token
if len(user_tokens) > max_tokens:
user_tokens = user_tokens[-max_tokens:]
self.cache.set(key, user_tokens)
def _enforce_session_limit(self, user_id: str):
"""强制用户会话限制"""
max_sessions = self.config["security"]["max_sessions_per_user"]
access_key = f"user:{user_id}:access"
refresh_key = f"user:{user_id}:refresh"
# 检查 access token 数量
access_tokens = self.cache.get(access_key, [])
if len(access_tokens) > max_sessions:
# 删除最早的 token
tokens_to_remove = access_tokens[:-max_sessions]
for token_hash in tokens_to_remove:
if self.cache.exists(f"token:{token_hash}"):
self.cache.delete(f"token:{token_hash}")
# 更新索引
self.cache.set(access_key, access_tokens[-max_sessions:])
# 检查 refresh token 数量
refresh_tokens = self.cache.get(refresh_key, [])
if len(refresh_tokens) > max_sessions:
tokens_to_remove = refresh_tokens[:-max_sessions]
for token_hash in tokens_to_remove:
if self.cache.exists(f"token:{token_hash}"):
self.cache.delete(f"token:{token_hash}")
self.cache.set(refresh_key, refresh_tokens[-max_sessions:])
def validate_token(
self, token: str, token_type: str = "access"
) -> Optional[Dict[str, Any]]:
"""
验证 JWT Token
Args:
token: JWT Token
token_type: 期望的 Token 类型
Returns:
验证成功返回用户数据,失败返回 None
"""
try:
# 解码验证 Token
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
# 检查 Token 类型
print(f"payload: {payload}")
if payload.get("type") != token_type:
return None
user_id = payload.get("sub")
jti = payload.get("jti")
if not user_id or not jti:
return None
token_hash = self._hash_token(token)
# 检查缓存
with self.lock:
cache_data = self.cache.get(f"token:{token_hash}")
if not cache_data:
return None
# 检查 Token 是否激活
if not cache_data.get("is_active", True):
return None
# 检查是否过期
expires_at = datetime.fromisoformat(cache_data["expires_at"])
if datetime.utcnow() > expires_at:
# 标记为不活跃
cache_data["is_active"] = False
self.cache.set(f"token:{token_hash}", cache_data)
return None
# 更新最后使用时间
cache_data["last_used"] = datetime.utcnow().isoformat()
self.cache.set(f"token:{token_hash}", cache_data)
return {
"user_id": user_id,
"device_id": cache_data.get("device_id"),
"payload": payload,
"cache_data": cache_data,
}
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
except Exception:
return None
def invalidate_token(self, token: str):
"""使单个 Token 失效"""
with self.lock:
token_hash = self._hash_token(token)
# 标记为不活跃
if self.cache.exists(f"token:{token_hash}"):
cache_data = self.cache.get(f"token:{token_hash}")
cache_data["is_active"] = False
self.cache.set(f"token:{token_hash}", cache_data)
def invalidate_user_tokens(self, user_id: str, token_type: Optional[str] = None):
"""使用户的所有 Token 失效"""
with self.lock:
if token_type:
keys = [f"user:{user_id}:{token_type}"]
else:
keys = [f"user:{user_id}:access", f"user:{user_id}:refresh"]
for key in keys:
token_hashes = self.cache.get(key, [])
for token_hash in token_hashes:
# 添加到黑名单
# 注意:这里我们只有 token_hash,没有原始 token
# 所以只能标记为不活跃
if self.cache.exists(f"token:{token_hash}"):
cache_data = self.cache.get(f"token:{token_hash}")
cache_data["is_active"] = False
self.cache.set(f"token:{token_hash}", cache_data)
# 清空用户会话索引
self.cache.set(key, [])
def get_user_sessions(self, user_id: str) -> List[Dict[str, Any]]:
"""获取用户的所有活跃会话"""
with self.lock:
sessions = []
for token_type in ["access", "refresh"]:
key = f"user:{user_id}:{token_type}"
token_hashes = self.cache.get(key, [])
for token_hash in token_hashes:
if self.cache.exists(f"token:{token_hash}"):
cache_data = self.cache.get(f"token:{token_hash}")
# 检查是否活跃且未过期
if cache_data.get("is_active", True):
expires_at = datetime.fromisoformat(
cache_data["expires_at"]
)
if datetime.utcnow() <= expires_at:
sessions.append(
{
"token_type": token_type,
"device_id": cache_data.get("device_id"),
"user_agent": cache_data.get("user_agent"),
"ip_address": cache_data.get("ip_address"),
"created_at": cache_data["created_at"],
"last_used": cache_data["last_used"],
"expires_at": cache_data["expires_at"],
}
)
return sessions
def cleanup_expired(self):
"""清理过期数据"""
with self.lock:
cleaned_count = 0
current_time = datetime.utcnow()
# 清理过期 Token
for token_hash in self.cache.keys("token:*"):
expires_at = datetime.fromisoformat(self.cache.get(token_hash))
if current_time > expires_at:
self.cache.delete(token_hash)
cleaned_count += 1
return cleaned_count
# 全局缓存实例
_jwt_cache = None
def get_jwt_cache(config=None, cache=None) -> JWTCache:
"""获取 JWT 缓存实例(单例模式)"""
global _jwt_cache
if _jwt_cache is None:
if config is None or cache is None:
raise ValueError("参数 config 和 cache 不能为空")
_jwt_cache = JWTCache(config, cache)
return _jwt_cache
# Token 响应模型
class TokenResponse(BaseModel):
"""Token 响应模型"""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
refresh_expires_in: int
class RefreshTokenResponse(BaseModel):
"""刷新 Token 响应模型"""
access_token: str
token_type: str = "bearer"
# Token 数据模型
class TokenData(BaseModel):
"""Token 数据模型"""
user_id: str
username: Optional[str] = None
email: Optional[str] = None
scopes: List[str] = []
View File
View File
View File
+284
View File
@@ -0,0 +1,284 @@
"""
APScheduler 集成模块
提供持久化任务调度服务
"""
import os
import sys
import tempfile
from contextlib import suppress
from typing import Dict, List, Optional, Callable, AsyncGenerator
from datetime import datetime
from fastapi import Request
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.executors.asyncio import AsyncIOExecutor
from apscheduler.job import Job
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
def acquire_scheduler_lock():
lock_path = os.path.join(tempfile.gettempdir(), "chongming_scheduler.lock")
lock_file = open(lock_path, "w")
try:
if sys.platform == "win32":
import msvcrt
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_NB | fcntl.LOCK_EX)
return lock_file, True
except (IOError, OSError):
lock_file.close()
return None, False
class TaskService:
"""任务调度服务"""
def __init__(self, config, logger, async_session_maker=None):
self.async_session_maker = async_session_maker
# 使用独立的 SQLite 数据库存储任务(与业务数据库分开)
self.job_store_db_path = config.get("scheduler", {}).get(
"job_store_path", "scheduler_jobs.db"
)
job_store_url = f"sqlite:///{self.job_store_db_path}"
# 配置 job stores 和 executors
jobstores = {"default": SQLAlchemyJobStore(url=job_store_url)}
executors = {"default": AsyncIOExecutor()}
job_defaults = {
"coalesce": True, # 合并错过的任务
"max_instances": 3, # 最大并发实例数
"misfire_grace_time": 30, # 错过任务的宽限时间(秒)
}
self.scheduler = AsyncIOScheduler(
jobstores=jobstores,
executors=executors,
job_defaults=job_defaults,
timezone="Asia/Shanghai",
)
self._started = False
self.lock_file, self.is_scheduler_worker = acquire_scheduler_lock()
self.logger = logger
async def start(self, init_tasks_callback: Optional[Callable] = None):
"""启动调度器"""
if self.is_scheduler_worker:
if not self._started:
self.scheduler.start()
self._started = True
self.logger.info("任务调度器已启动")
if init_tasks_callback:
await init_tasks_callback(self)
async def shutdown(self, wait: bool = True):
"""关闭调度器"""
if self.lock_file:
with suppress(Exception):
for job in await self.get_jobs():
await self.pause_job(job.get("id", ""))
if self._started:
self.scheduler.shutdown(wait=wait)
self._started = False
self.logger.info("任务调度器已关闭")
if sys.platform == "win32":
import msvcrt
msvcrt.locking(self.lock_file.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_UN)
self.lock_file.close()
async def add_interval_job(
self,
func: Callable,
seconds: int = 0,
minutes: int = 0,
hours: int = 0,
days: int = 0,
job_id: Optional[str] = None,
args: Optional[List] = None,
kwargs: Optional[Dict] = None,
replace_existing: bool = True,
) -> Job:
"""添加间隔执行任务"""
trigger = IntervalTrigger(
seconds=seconds, minutes=minutes, hours=hours, days=days
)
job = self.scheduler.add_job(
func,
trigger=trigger,
id=job_id,
args=args or [],
kwargs=kwargs or {},
replace_existing=replace_existing,
)
self.logger.info(f"添加间隔任务: {job.id}")
return job
async def add_cron_job(
self,
func: Callable,
job_id: Optional[str] = None,
year: Optional[int] = None,
month: Optional[int] = None,
day: Optional[int] = None,
week: Optional[int] = None,
day_of_week: Optional[int] = None,
hour: Optional[int] = None,
minute: Optional[int] = None,
second: Optional[int] = None,
args: Optional[List] = None,
kwargs: Optional[Dict] = None,
replace_existing: bool = True,
) -> Job:
"""添加 cron 表达式任务"""
trigger = CronTrigger(
year=year,
month=month,
day=day,
week=week,
day_of_week=day_of_week,
hour=hour,
minute=minute,
second=second,
)
job = self.scheduler.add_job(
func,
trigger=trigger,
id=job_id,
args=args or [],
kwargs=kwargs or {},
replace_existing=replace_existing,
)
self.logger.info(f"添加 cron 任务: {job.id}")
return job
async def add_date_job(
self,
func: Callable,
run_date: Optional[datetime] = None,
job_id: Optional[str] = None,
args: Optional[List] = None,
kwargs: Optional[Dict] = None,
replace_existing: bool = True,
) -> Job:
"""添加一次性任务"""
trigger = DateTrigger(run_date=run_date)
job = self.scheduler.add_job(
func,
trigger=trigger,
id=job_id,
args=args or [],
kwargs=kwargs or {},
replace_existing=replace_existing,
)
self.logger.info(f"添加一次性任务: {job.id}")
return job
async def remove_job(self, job_id: str) -> bool:
"""删除任务"""
try:
self.scheduler.remove_job(job_id)
self.logger.info(f"删除任务: {job_id}")
return True
except Exception as e:
self.logger.error(f"删除任务失败 {job_id}: {e}")
return False
async def pause_job(self, job_id: str) -> bool:
"""暂停任务"""
try:
self.scheduler.pause_job(job_id)
self.logger.info(f"暂停任务: {job_id}")
return True
except Exception as e:
self.logger.error(f"暂停任务失败 {job_id}: {e}")
return False
async def resume_job(self, job_id: str) -> bool:
"""恢复任务"""
try:
self.scheduler.resume_job(job_id)
self.logger.info(f"恢复任务: {job_id}")
return True
except Exception as e:
self.logger.error(f"恢复任务失败 {job_id}: {e}")
return False
async def get_job(self, job_id: str) -> Optional[Dict]:
"""获取任务信息"""
job = self.scheduler.get_job(job_id)
if job:
return self._job_to_dict(job)
return None
async def get_jobs(self) -> List[Dict]:
"""获取所有任务"""
jobs = self.scheduler.get_jobs()
return [self._job_to_dict(job) for job in jobs]
async def reschedule_job(
self, job_id: str, trigger_type: str, **trigger_args
) -> Optional[Job]:
"""重新调度任务(修改触发时间)"""
try:
job = self.scheduler.get_job(job_id)
if not job:
return None
if trigger_type == "interval":
trigger = IntervalTrigger(**trigger_args)
elif trigger_type == "cron":
trigger = CronTrigger(**trigger_args)
elif trigger_type == "date":
trigger = DateTrigger(**trigger_args)
else:
raise ValueError(f"不支持的 trigger 类型: {trigger_type}")
job.reschedule(trigger=trigger)
self.logger.info(f"重新调度任务: {job_id}")
return job
except Exception as e:
self.logger.error(f"重新调度任务失败 {job_id}: {e}")
return None
def _job_to_dict(self, job: Job) -> Dict:
"""将 Job 对象转换为字典"""
return {
"id": job.id,
"name": job.name,
"next_run_time": (
job.next_run_time.isoformat() if job.next_run_time else None
),
"trigger": str(job.trigger),
"pending": job.pending,
"args": job.args,
"kwargs": job.kwargs,
}
_task_service: Optional[TaskService] = None
def get_task_service_instance(config=None, logger=None, async_session_maker=None):
global _task_service
if _task_service is None:
if config is None or logger is None:
raise ValueError("请传入 config 和 logger")
_task_service = TaskService(config, logger, async_session_maker)
return _task_service
async def get_task_service(request: Request) -> AsyncGenerator:
return request.app.state.task_service
View File
View File