modify: 更新日志配置
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import time
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -8,23 +9,19 @@ from fastapi.staticfiles import StaticFiles
|
||||
from sqlmodel import SQLModel, text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .core.config import get_config
|
||||
from .core.cache import get_cache
|
||||
from .core.logger import get_logger
|
||||
from .core.scheduler import get_task_service_instance
|
||||
from .core.static_files import SVFSStaticFiles
|
||||
from .api import api_router
|
||||
from .task import init_tasks_callback
|
||||
|
||||
|
||||
class TaskRequest(BaseModel):
|
||||
task_name: str
|
||||
interval: int
|
||||
from plugins.scheduler.scheduler import get_task_service_instance
|
||||
|
||||
|
||||
config = get_config()
|
||||
cache = get_cache()
|
||||
logger = get_logger("app")
|
||||
|
||||
|
||||
@@ -38,6 +35,9 @@ except KeyError: # pragma: no cover
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
from plugins import hello
|
||||
from plugins.jwt.jwt_cache import get_jwt_cache
|
||||
|
||||
get_jwt_cache(config, cache)
|
||||
|
||||
hello()
|
||||
database = config["database"]
|
||||
@@ -73,7 +73,9 @@ async def lifespan(app: FastAPI):
|
||||
raise
|
||||
|
||||
# 启动时:初始化任务服务
|
||||
task_service = get_task_service_instance(async_session_maker)
|
||||
task_service = get_task_service_instance(
|
||||
config, get_logger("scheduler"), async_session_maker
|
||||
)
|
||||
app.state.task_service = task_service
|
||||
await task_service.start(init_tasks_callback)
|
||||
|
||||
@@ -82,7 +84,7 @@ async def lifespan(app: FastAPI):
|
||||
# 关闭时:释放引擎
|
||||
await task_service.shutdown(wait=True)
|
||||
await engine.dispose()
|
||||
get_cache().close()
|
||||
cache.close()
|
||||
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
@@ -104,6 +106,21 @@ app.add_middleware(CORSMiddleware, **cors)
|
||||
app.include_router(api_router, prefix=config["default"]["prefix"])
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def access_log_middleware(request: Request, call_next):
|
||||
start = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
duration = (time.perf_counter() - start) * 1000
|
||||
client = request.client.host if request.client else "-"
|
||||
|
||||
logger.info(
|
||||
f"{request.method} {request.url.path} status={response.status_code} "
|
||||
f"duration={duration:.2f}ms client={client}",
|
||||
extra={"status_code": response.status_code}, # ← 关键
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
# 根路由
|
||||
@app.get("/", summary="根路径")
|
||||
async def root():
|
||||
@@ -116,6 +133,14 @@ async def root():
|
||||
}
|
||||
|
||||
|
||||
# from pydantic import BaseModel
|
||||
|
||||
|
||||
# class TaskRequest(BaseModel):
|
||||
# task_name: str
|
||||
# interval: int
|
||||
|
||||
|
||||
# @app.post("/schedule")
|
||||
# async def add_task(
|
||||
# request: TaskRequest, task_service: TaskService = Depends(get_task_service)
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from ..service.auth import get_auth_service
|
||||
from ..core.jwt_cache import TokenData
|
||||
from plugins.jwt.jwt_cache import TokenData
|
||||
|
||||
|
||||
access_scheme = HTTPBearer(scheme_name="AccessToken")
|
||||
|
||||
@@ -5,13 +5,15 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.database import get_session
|
||||
from ...core.jwt_cache import TokenData, TokenResponse, RefreshTokenResponse
|
||||
from ...core.security import verify_password
|
||||
from ...service.user import UserService
|
||||
from ...service.auth import get_auth_service
|
||||
from ...model.user import UserCreate, UserRead, UserLogin
|
||||
from ...model.user import UserCreate, UserRead, UserLogin, Userlogout, UserSession
|
||||
from ...model.http_error import HTTPError, Detail
|
||||
from ..deps import get_current_user
|
||||
|
||||
from plugins.jwt.jwt_cache import TokenData, TokenResponse, RefreshTokenResponse
|
||||
|
||||
|
||||
router = APIRouter(tags=["authentication"])
|
||||
|
||||
@@ -19,16 +21,23 @@ access_scheme = HTTPBearer(scheme_name="AccessToken")
|
||||
refresh_scheme = HTTPBearer(scheme_name="RefreshToken")
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserRead)
|
||||
@router.post(
|
||||
"/register",
|
||||
response_model=UserRead,
|
||||
responses={400: {"model": HTTPError, "description": "邮箱已被注册"}},
|
||||
)
|
||||
async def register(
|
||||
user_data: UserCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
print(user_data.password)
|
||||
|
||||
existing = await UserService.get_user_by_email(user_data.email, session)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="邮箱已被注册")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=Detail(
|
||||
message="邮箱已被注册", code=status.HTTP_400_BAD_REQUEST
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
user = await UserService.create_user(
|
||||
session=session,
|
||||
@@ -40,7 +49,15 @@ async def register(
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=TokenResponse,
|
||||
responses={
|
||||
400: {"model": HTTPError, "description": "邮箱不存在"},
|
||||
401: {"model": HTTPError, "description": "密码错误"},
|
||||
402: {"model": HTTPError, "description": "用户被禁用"},
|
||||
},
|
||||
)
|
||||
async def login(
|
||||
user_data: UserLogin,
|
||||
request: Request,
|
||||
@@ -48,16 +65,29 @@ async def login(
|
||||
):
|
||||
user = await UserService.get_user_by_email(user_data.email, session)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="邮箱或密码错误")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=Detail(message="邮箱不存在", code=400).model_dump()
|
||||
)
|
||||
if not verify_password(user_data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=400, detail="邮箱或密码错误")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=Detail(message="密码错误", code=401).model_dump()
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="用户被禁用")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=Detail(message="用户被禁用", code=402).model_dump()
|
||||
)
|
||||
auth_service = get_auth_service()
|
||||
return await auth_service.create_tokens(user, request, device_id=None)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshTokenResponse, summary="刷新访问令牌")
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=RefreshTokenResponse,
|
||||
responses={
|
||||
401: {"model": HTTPError, "description": "无效的刷新令牌"},
|
||||
},
|
||||
summary="刷新访问令牌",
|
||||
)
|
||||
async def refresh_token(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(refresh_scheme),
|
||||
@@ -72,28 +102,28 @@ async def refresh_token(
|
||||
if not new_access_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的刷新令牌",
|
||||
detail=Detail(message="无效的刷新令牌", code=401).model_dump(),
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return {"access_token": new_access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
@router.post("/logout", response_model=Userlogout)
|
||||
async def logout(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(access_scheme),
|
||||
):
|
||||
auth_service = get_auth_service()
|
||||
token = credentials.credentials
|
||||
await auth_service.logout(token)
|
||||
return {"message": "登出成功"}
|
||||
return {"message": "登出成功", "code": 200}
|
||||
|
||||
|
||||
@router.post("/logout-all", summary="登出所有设备")
|
||||
@router.post("/logout-all", summary="登出所有设备", response_model=Userlogout)
|
||||
async def logout_all(current_user: TokenData = Depends(get_current_user)):
|
||||
auth_service = get_auth_service()
|
||||
await auth_service.logout_all(current_user.user_id)
|
||||
|
||||
return {"message": "所有设备已登出"}
|
||||
return {"message": "所有设备已登出", "code": 200}
|
||||
|
||||
|
||||
@router.get("/sessions", summary="获取用户会话")
|
||||
@@ -102,14 +132,17 @@ async def get_sessions(current_user: TokenData = Depends(get_current_user)):
|
||||
auth_service = get_auth_service()
|
||||
sessions = await auth_service.get_user_sessions(current_user.user_id)
|
||||
|
||||
return {
|
||||
"user_id": current_user.user_id,
|
||||
"sessions": sessions,
|
||||
"count": len(sessions),
|
||||
}
|
||||
return UserSession(
|
||||
user_id=current_user.user_id, sessions=sessions, count=len(sessions)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead, summary="获取当前用户信息")
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=UserRead,
|
||||
summary="获取当前用户信息",
|
||||
responses={404: {"model": HTTPError, "description": "用户不存在"}},
|
||||
)
|
||||
async def get_current_user_info(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: TokenData = Depends(get_current_user),
|
||||
@@ -117,6 +150,9 @@ async def get_current_user_info(
|
||||
"""获取当前登录用户的信息"""
|
||||
user = await UserService.get_user_by_id(UUID(current_user.user_id), session)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=Detail(message="用户不存在", code=404).model_dump(),
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
@@ -64,6 +64,7 @@ class Server(TypedDict, total=False):
|
||||
port: Required[int]
|
||||
reload: bool
|
||||
workers: int
|
||||
access_log: bool
|
||||
|
||||
|
||||
class Logging(TypedDict):
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
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
|
||||
|
||||
from .config import get_config
|
||||
from .cache import get_cache
|
||||
|
||||
|
||||
@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, db_path: Optional[str] = None):
|
||||
self.config = get_config()
|
||||
|
||||
self.lock = threading.RLock()
|
||||
|
||||
self.cache = get_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() -> JWTCache:
|
||||
"""获取 JWT 缓存实例(单例模式)"""
|
||||
global _jwt_cache
|
||||
if _jwt_cache is None:
|
||||
_jwt_cache = JWTCache()
|
||||
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] = []
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
import logging
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
@@ -12,6 +13,36 @@ server_config = config[env]["server"]
|
||||
log_config = config[env]["logging"]
|
||||
|
||||
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
"""为 status_code 添加颜色的日志格式化器"""
|
||||
|
||||
COLORS = {
|
||||
"2": "\033[32m", # 绿色 2xx
|
||||
"3": "\033[36m", # 青色 3xx
|
||||
"4": "\033[33m", # 黄色 4xx
|
||||
"5": "\033[31m", # 红色 5xx
|
||||
}
|
||||
RESET = "\033[0m"
|
||||
|
||||
def format(self, record):
|
||||
# 保存原始消息
|
||||
original_msg = record.msg
|
||||
# 如果日志记录包含 status_code 属性,则为其添加颜色
|
||||
status_code = getattr(record, "status_code", None)
|
||||
if status_code and isinstance(status_code, int):
|
||||
first_digit = str(status_code)[0]
|
||||
color = self.COLORS.get(first_digit, "")
|
||||
colored_status = f"{color}{status_code}{self.RESET}"
|
||||
# 替换消息中的 "status=数字" 为带颜色的版本
|
||||
record.msg = re.sub(
|
||||
rf"status={status_code}\b", f"status={colored_status}", original_msg
|
||||
)
|
||||
result = super().format(record)
|
||||
# 恢复原始消息,避免影响其他 handler
|
||||
record.msg = original_msg
|
||||
return result
|
||||
|
||||
|
||||
class ExcludeWatchFilesFilter(logging.Filter):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -29,7 +60,13 @@ class ExcludeWatchFilesFilter(logging.Filter):
|
||||
return not record.name.startswith(self._filter_starts_with_tulpe)
|
||||
|
||||
|
||||
_configured_loggers = set()
|
||||
|
||||
|
||||
def setup_logging(logger: logging.Logger, log_name: str = "app"):
|
||||
if log_name in _configured_loggers:
|
||||
return
|
||||
_configured_loggers.add(log_name)
|
||||
log_level = getattr(logging, log_config["level"], logging.INFO)
|
||||
log_format = log_config["format"]
|
||||
# 创建文件夹
|
||||
@@ -41,7 +78,8 @@ def setup_logging(logger: logging.Logger, log_name: str = "app"):
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# 创建格式化器
|
||||
formatter = logging.Formatter(log_format)
|
||||
plain_formatter = logging.Formatter(log_format)
|
||||
color_formatter = ColoredFormatter(log_format)
|
||||
|
||||
# 创建轮转文件处理器 - 限制文件大小为 10MB,保留 5 个备份文件
|
||||
max_bytes = log_config.get("max_size", 10 * 1024 * 1024) # 默认 10MB
|
||||
@@ -53,12 +91,12 @@ def setup_logging(logger: logging.Logger, log_name: str = "app"):
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.setFormatter(plain_formatter)
|
||||
file_handler.addFilter(ExcludeWatchFilesFilter())
|
||||
|
||||
# 创建控制台处理器并添加过滤器
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setFormatter(color_formatter)
|
||||
console_handler.addFilter(ExcludeWatchFilesFilter())
|
||||
|
||||
# 添加处理器到根日志记录器
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
"""
|
||||
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
|
||||
|
||||
from ..core.config import get_config
|
||||
from ..core.logger import get_logger
|
||||
|
||||
|
||||
config = get_config()
|
||||
logger = get_logger("scheduler")
|
||||
|
||||
|
||||
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, 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()
|
||||
|
||||
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
|
||||
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
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
logger.info(f"添加一次性任务: {job.id}")
|
||||
return job
|
||||
|
||||
async def remove_job(self, job_id: str) -> bool:
|
||||
"""删除任务"""
|
||||
try:
|
||||
self.scheduler.remove_job(job_id)
|
||||
logger.info(f"删除任务: {job_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"删除任务失败 {job_id}: {e}")
|
||||
return False
|
||||
|
||||
async def pause_job(self, job_id: str) -> bool:
|
||||
"""暂停任务"""
|
||||
try:
|
||||
self.scheduler.pause_job(job_id)
|
||||
logger.info(f"暂停任务: {job_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"暂停任务失败 {job_id}: {e}")
|
||||
return False
|
||||
|
||||
async def resume_job(self, job_id: str) -> bool:
|
||||
"""恢复任务"""
|
||||
try:
|
||||
self.scheduler.resume_job(job_id)
|
||||
logger.info(f"恢复任务: {job_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
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)
|
||||
logger.info(f"重新调度任务: {job_id}")
|
||||
return job
|
||||
except Exception as e:
|
||||
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(async_session_maker=None):
|
||||
global _task_service
|
||||
if _task_service is None:
|
||||
_task_service = TaskService(async_session_maker)
|
||||
return _task_service
|
||||
|
||||
|
||||
async def get_task_service(request: Request) -> AsyncGenerator:
|
||||
return request.app.state.task_service
|
||||
@@ -0,0 +1,11 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Detail(BaseModel):
|
||||
message: str
|
||||
code: int
|
||||
|
||||
|
||||
class HTTPError(BaseModel):
|
||||
detail: Optional[Detail] = None
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Optional, List, TYPE_CHECKING
|
||||
from typing import Optional, List, Dict, TYPE_CHECKING
|
||||
|
||||
from sqlmodel import SQLModel, Field, Relationship
|
||||
from pydantic import BaseModel
|
||||
@@ -42,3 +42,14 @@ class UserRead(UserBase):
|
||||
class UserLogin(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class Userlogout(BaseModel):
|
||||
code: int = 200
|
||||
message: str = "logout success"
|
||||
|
||||
|
||||
class UserSession(BaseModel):
|
||||
user_id: str
|
||||
sessions: List[Dict[str, Optional[str]]]
|
||||
count: int
|
||||
|
||||
@@ -8,10 +8,11 @@ from passlib.context import CryptContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..model.user import User
|
||||
from ..core.jwt_cache import get_jwt_cache, TokenResponse, TokenData
|
||||
from ..core.config import get_config
|
||||
from .user import UserService
|
||||
|
||||
from plugins.jwt.jwt_cache import get_jwt_cache, TokenResponse, TokenData
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""认证服务"""
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from ..core.scheduler import TaskService
|
||||
from ..core.config import get_config
|
||||
from .dev_init_db import init_db
|
||||
|
||||
from plugins.scheduler.scheduler import TaskService
|
||||
|
||||
|
||||
config = get_config()
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from ..core.scheduler import logger
|
||||
from ..core.scheduler import get_task_service_instance
|
||||
from ..core.logger import get_logger
|
||||
from ..core.constant import PermissionConstant
|
||||
from ..service.user import UserService
|
||||
from ..service.permission import PermissionService
|
||||
|
||||
from plugins.scheduler.scheduler import get_task_service_instance
|
||||
|
||||
|
||||
logger = get_logger("scheduler")
|
||||
|
||||
|
||||
async def dev_init_admin(session):
|
||||
admin_user = await UserService.get_user_by_email("admin", session)
|
||||
|
||||
@@ -250,6 +250,13 @@ async def main():
|
||||
with open("./build/utils/launch.py", "w", encoding="utf-8") as f:
|
||||
f.write(context)
|
||||
|
||||
jwt_key = Encryption.generate_key()
|
||||
with open("./build/config.toml", "r", encoding="utf-8") as f:
|
||||
context = f.read()
|
||||
context = context.replace("your-secret-key-change-in-production", jwt_key)
|
||||
with open("build/config.toml", "w", encoding="utf-8") as f:
|
||||
f.write(context)
|
||||
|
||||
# 混淆
|
||||
run_pyarmor_obfuscate()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user