first commit
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import uvicorn
|
||||
from .app.core.config import load_config
|
||||
from .app.core.logger import setup_logging, get_logger
|
||||
|
||||
|
||||
def serve():
|
||||
config = load_config()
|
||||
env = config["default"]["env"]
|
||||
server_config = config[env]["server"]
|
||||
log_config = config[env]["logging"]
|
||||
setup_logging(log_config)
|
||||
logget = get_logger("app")
|
||||
logget.info(f"启动服务: {server_config}")
|
||||
logget.info(f"启动日志: {log_config}")
|
||||
uvicorn.run("chongming.app:app", **server_config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -0,0 +1,105 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlmodel import SQLModel, text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from .core.config import get_config
|
||||
from .core.jwt_cache import get_jwt_cache
|
||||
from .core.logger import get_logger
|
||||
from .api import api_router
|
||||
|
||||
config = get_config()
|
||||
logger = get_logger("app")
|
||||
|
||||
try:
|
||||
env = config["default"]["env"]
|
||||
except KeyError: # pragma: no cover
|
||||
raise ValueError("配置不存在")
|
||||
|
||||
|
||||
# 生命周期管理
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
from plugins import hello
|
||||
|
||||
hello()
|
||||
database = config["database"]
|
||||
database_type = database["type"]
|
||||
database_config = database.get(database_type, None)
|
||||
if database_config is None:
|
||||
raise ValueError("配置不存在")
|
||||
|
||||
# 启动时:创建引擎和会话工厂,初始化数据库表
|
||||
engine = create_async_engine(database["url"], **database_config)
|
||||
async_session_maker = async_sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
# 将资源存储到 app.state
|
||||
app.state.engine = engine
|
||||
app.state.async_session_maker = async_session_maker
|
||||
|
||||
# 创建表(异步方式)
|
||||
if env == "development":
|
||||
async with engine.begin() as conn:
|
||||
if database_type == "sqlite":
|
||||
await conn.execute(text("PRAGMA journal_mode=WAL"))
|
||||
await conn.execute(text("PRAGMA synchronous=NORMAL"))
|
||||
try:
|
||||
await conn.run_sync(
|
||||
lambda conn: SQLModel.metadata.create_all(
|
||||
bind=conn, checkfirst=True
|
||||
)
|
||||
)
|
||||
except OperationalError as e:
|
||||
if "already exists" in str(e):
|
||||
raise
|
||||
|
||||
yield # 应用运行期间
|
||||
|
||||
# 关闭时:释放引擎
|
||||
await engine.dispose()
|
||||
get_jwt_cache().close()
|
||||
|
||||
|
||||
cors = config[env].get("cors", {})
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(
|
||||
title=config["default"]["app"]["name"],
|
||||
version=config["default"]["app"]["version"],
|
||||
description=config["default"]["app"]["description"],
|
||||
docs_url="/docs" if config[env]["debug"] else None,
|
||||
redoc_url="/redoc" if config[env]["debug"] else None,
|
||||
openapi_url="/openapi.json" if config[env]["debug"] else None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
# 添加 CORS 中间件
|
||||
app.add_middleware(CORSMiddleware, **cors)
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
|
||||
# 根路由
|
||||
@app.get("/", summary="根路径")
|
||||
async def root():
|
||||
"""根路径,返回应用信息"""
|
||||
return {
|
||||
"message": f"欢迎使用 {config["default"]["app"]["name"]}",
|
||||
"version": config["default"]["app"]["version"],
|
||||
"docs": "/docs" if config[env]["debug"] else None,
|
||||
"health": "/api/v1/health",
|
||||
}
|
||||
|
||||
|
||||
# 全局异常处理器
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
"""全局异常处理器"""
|
||||
logger.error(f"全局异常: {exc}")
|
||||
return {
|
||||
"error": "内部服务器错误",
|
||||
"detail": str(exc) if config[env]["debug"] else "请查看服务器日志",
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
API 路由
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .routers import todo, auth
|
||||
|
||||
# 创建主路由
|
||||
api_router = APIRouter()
|
||||
|
||||
# 注册子路由
|
||||
api_router.include_router(todo.router, prefix="/todos", tags=["todo"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
# 导出路由
|
||||
__all__ = ["api_router"]
|
||||
@@ -0,0 +1,31 @@
|
||||
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
|
||||
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> TokenData:
|
||||
"""
|
||||
获取当前用户依赖项
|
||||
|
||||
验证访问令牌并返回用户信息
|
||||
"""
|
||||
auth_service = get_auth_service()
|
||||
|
||||
token = credentials.credentials
|
||||
token_data = await auth_service.validate_access_token(token)
|
||||
|
||||
if token_data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的访问令牌",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return token_data
|
||||
@@ -0,0 +1,134 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.database import get_session
|
||||
from ...core.jwt_cache import get_jwt_cache, TokenData
|
||||
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 ..deps import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["authentication"])
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserRead)
|
||||
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="邮箱已被注册")
|
||||
|
||||
user = await UserService.create_user(
|
||||
session=session,
|
||||
email=user_data.email,
|
||||
password=user_data.password,
|
||||
username=user_data.username,
|
||||
full_name=user_data.full_name,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
user_data: UserLogin,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
user = await UserService.get_user_by_email(user_data.email, session)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="邮箱或密码错误")
|
||||
if not verify_password(user_data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=400, detail="邮箱或密码错误")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="用户被禁用")
|
||||
|
||||
cache = get_jwt_cache()
|
||||
user_data_dict = {
|
||||
"sub": str(user.id),
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
}
|
||||
token = cache.create_token(
|
||||
user_id=str(user.id),
|
||||
user_data=user_data_dict,
|
||||
token_type="access",
|
||||
device_id=None,
|
||||
user_agent=request.headers.get("user-agent") if request else None,
|
||||
ip_address=request.client.host if request else None, # type: ignore
|
||||
)
|
||||
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=dict, summary="刷新访问令牌")
|
||||
async def refresh_token(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
auth_service = get_auth_service()
|
||||
|
||||
refresh_token = credentials.credentials
|
||||
new_access_token = await auth_service.refresh_access_token(
|
||||
refresh_token, session, request
|
||||
)
|
||||
if not new_access_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的刷新令牌",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return {"access_token": new_access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
):
|
||||
auth_service = get_auth_service()
|
||||
token = credentials.credentials
|
||||
await auth_service.logout(token)
|
||||
return {"message": "登出成功"}
|
||||
|
||||
|
||||
@router.post("/logout-all", summary="等出所有设备")
|
||||
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": "所有设备已登出"}
|
||||
|
||||
|
||||
@router.get("/sessions", summary="获取用户会话")
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead, summary="获取当前用户信息")
|
||||
async def get_current_user_info(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: TokenData = Depends(get_current_user),
|
||||
):
|
||||
"""获取当前登录用户的信息"""
|
||||
user = await UserService.get_user_by_id(int(current_user.user_id), session)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
return user
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...model.todo import TodoCreate, TodoUpdate, TodoRead
|
||||
from ...core.database import get_session
|
||||
from ...service.todo import TodoService
|
||||
from ...model.user import User
|
||||
from ..deps import get_current_user
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# -------------------- 路由 --------------------
|
||||
@router.post("/", response_model=TodoRead, dependencies=[Depends(get_current_user)])
|
||||
async def create_todo(
|
||||
todo: TodoCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
return await TodoService.create_todo(todo, session)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/", response_model=List[TodoRead], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
async def read_todos(
|
||||
offset: int = 0, limit: int = 100, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
return await TodoService.get_todos(offset, limit, session)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{todo_id}", response_model=TodoRead, dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
async def read_todo(todo_id: int, session: AsyncSession = Depends(get_session)):
|
||||
todo = await TodoService.get_todo_by_id(todo_id, session)
|
||||
if not todo:
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
return todo
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{todo_id}", response_model=TodoRead, dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
async def update_todo(
|
||||
todo_id: int, todo_update: TodoUpdate, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
todo = await TodoService.update_todo(todo_id, todo_update, session)
|
||||
return todo
|
||||
|
||||
|
||||
@router.delete("/{todo_id}", dependencies=[Depends(get_current_user)])
|
||||
async def delete_todo(todo_id: int, session: AsyncSession = Depends(get_session)):
|
||||
is_deleted = await TodoService.delete_todo(todo_id, session)
|
||||
if not is_deleted:
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,125 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TypedDict, Literal, Optional, cast
|
||||
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
from typing import Required, NotRequired
|
||||
else:
|
||||
import tomli as tomllib
|
||||
from typing_extensions import Required, NotRequired
|
||||
|
||||
# 设置 env 为 development 或 production
|
||||
|
||||
|
||||
class DefaultApp(TypedDict):
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
debug: bool
|
||||
|
||||
|
||||
class Default(TypedDict):
|
||||
app: DefaultApp
|
||||
env: Literal["production", "development"]
|
||||
|
||||
|
||||
class SqliteConnectionArgs(TypedDict, total=False):
|
||||
timeout: int
|
||||
check_same_thread: bool
|
||||
|
||||
|
||||
class DatabaseSqlite(TypedDict, total=False):
|
||||
echo: bool
|
||||
pool_size: int
|
||||
max_overflow: int
|
||||
pool_timeout: int
|
||||
pool_recycle: int
|
||||
pool_pre_ping: bool
|
||||
connect_args: SqliteConnectionArgs
|
||||
|
||||
|
||||
class Database(TypedDict, total=False):
|
||||
type: Required[Literal["sqlite", "mysql", "postgresql"]]
|
||||
url: Required[str]
|
||||
generate_schemas: bool
|
||||
sqlite: DatabaseSqlite
|
||||
mysql: dict
|
||||
postgresql: dict
|
||||
|
||||
|
||||
class Security(TypedDict):
|
||||
secret_key: str
|
||||
algorithm: Literal["HS256"]
|
||||
access_token_expire_minutes: int
|
||||
jwt_cache_db_path: str
|
||||
refresh_token_expire_days: int
|
||||
max_sessions_per_user: int
|
||||
|
||||
|
||||
class Server(TypedDict, total=False):
|
||||
host: Required[str]
|
||||
port: Required[int]
|
||||
reload: bool
|
||||
workers: int
|
||||
|
||||
|
||||
class Logging(TypedDict):
|
||||
level: str
|
||||
file: str
|
||||
format: str
|
||||
max_size: int
|
||||
backup_count: int
|
||||
|
||||
|
||||
class Cors(TypedDict, total=False):
|
||||
allow_origins: list[str]
|
||||
allow_credentials: bool
|
||||
allow_methods: list[str]
|
||||
allow_headers: list[str]
|
||||
|
||||
|
||||
class ModuleSystem(TypedDict, total=False):
|
||||
type: str
|
||||
path: list[str]
|
||||
|
||||
|
||||
class FileSystem(TypedDict, total=False):
|
||||
type: str
|
||||
path: list[str]
|
||||
|
||||
|
||||
class Env(TypedDict, total=False):
|
||||
debug: Required[bool]
|
||||
server: Required[Server]
|
||||
logging: Required[Logging]
|
||||
cors: Cors
|
||||
module_system: ModuleSystem
|
||||
file_system: FileSystem
|
||||
|
||||
|
||||
class Config(TypedDict):
|
||||
default: Default
|
||||
database: Database
|
||||
security: Security
|
||||
development: Env
|
||||
production: Env
|
||||
|
||||
|
||||
def load_config(config_path: Path = Path("config.toml")) -> Config:
|
||||
"""从 TOML 文件加载配置"""
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在: {config_path}")
|
||||
with open(config_path, "rb") as f:
|
||||
return cast(Config, tomllib.load(f))
|
||||
|
||||
|
||||
_config: Optional[Config] = None
|
||||
|
||||
|
||||
def get_config(config_path: str = "config.toml") -> Config:
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = load_config(Path(config_path))
|
||||
return _config
|
||||
@@ -0,0 +1,11 @@
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
# 获取数据库会话的依赖
|
||||
async def get_session(request: Request) -> AsyncGenerator[AsyncSession, None]:
|
||||
async_session_maker = request.app.state.async_session_maker
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,459 @@
|
||||
import jwt
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List, TypedDict
|
||||
from sqlitedict import SqliteDict
|
||||
from pydantic import BaseModel
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
# from pydantic import BaseModel
|
||||
import hashlib
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from .config import get_config
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
if db_path is None:
|
||||
db_path = self.config["security"]["jwt_cache_db_path"]
|
||||
|
||||
db_file_path = Path(db_path)
|
||||
db_file_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.lock = threading.RLock()
|
||||
|
||||
# 主 Token 缓存
|
||||
self.token_db = SqliteDict(
|
||||
rf"{db_path}/tokens.cache",
|
||||
tablename="tokens",
|
||||
autocommit=True,
|
||||
encode=self._json_encode,
|
||||
decode=self._json_decode,
|
||||
journal_mode="WAL",
|
||||
)
|
||||
|
||||
# 黑名单缓存
|
||||
self.blacklist_db = SqliteDict(
|
||||
rf"{db_path}/blacklist.cache",
|
||||
tablename="blacklist",
|
||||
autocommit=True,
|
||||
encode=json.dumps,
|
||||
decode=json.loads,
|
||||
journal_mode="WAL",
|
||||
)
|
||||
|
||||
# 用户会话索引
|
||||
self.user_sessions_db = SqliteDict(
|
||||
rf"{db_path}/user_sessions.cache",
|
||||
tablename="user_sessions",
|
||||
autocommit=True,
|
||||
encode=json.dumps,
|
||||
decode=json.loads,
|
||||
journal_mode="WAL",
|
||||
)
|
||||
|
||||
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.token_db[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_id}:{token_type}"
|
||||
user_tokens = self.user_sessions_db.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.user_sessions_db[key] = user_tokens
|
||||
|
||||
def _enforce_session_limit(self, user_id: str):
|
||||
"""强制用户会话限制"""
|
||||
max_sessions = self.config["security"]["max_sessions_per_user"]
|
||||
|
||||
access_key = f"{user_id}:access"
|
||||
refresh_key = f"{user_id}:refresh"
|
||||
|
||||
# 检查 access token 数量
|
||||
access_tokens = self.user_sessions_db.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 token_hash in self.token_db:
|
||||
del self.token_db[token_hash]
|
||||
|
||||
# 更新索引
|
||||
self.user_sessions_db[access_key] = access_tokens[-max_sessions:]
|
||||
|
||||
# 检查 refresh token 数量
|
||||
refresh_tokens = self.user_sessions_db.get(refresh_key, [])
|
||||
if len(refresh_tokens) > max_sessions:
|
||||
tokens_to_remove = refresh_tokens[:-max_sessions]
|
||||
for token_hash in tokens_to_remove:
|
||||
if token_hash in self.token_db:
|
||||
del self.token_db[token_hash]
|
||||
|
||||
self.user_sessions_db[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)
|
||||
if self._is_blacklisted(token_hash):
|
||||
return None
|
||||
|
||||
# 检查缓存
|
||||
with self.lock:
|
||||
cache_data = self.token_db.get(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.token_db[token_hash] = cache_data
|
||||
return None
|
||||
|
||||
# 更新最后使用时间
|
||||
cache_data["last_used"] = datetime.utcnow().isoformat()
|
||||
self.token_db[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 add_to_blacklist(self, token: str, reason: str = "logout"):
|
||||
"""添加 Token 到黑名单"""
|
||||
token_hash = self._hash_token(token)
|
||||
expire_time = datetime.utcnow() + timedelta(days=7) # 黑名单保留7天
|
||||
|
||||
self.blacklist_db[token_hash] = {
|
||||
"reason": reason,
|
||||
"blacklisted_at": datetime.utcnow().isoformat(),
|
||||
"expires_at": expire_time.isoformat(),
|
||||
}
|
||||
|
||||
def _is_blacklisted(self, token_hash: str) -> bool:
|
||||
"""检查 Token 是否在黑名单中"""
|
||||
with self.lock:
|
||||
if token_hash not in self.blacklist_db:
|
||||
return False
|
||||
|
||||
blacklist_data = self.blacklist_db[token_hash]
|
||||
expires_at = datetime.fromisoformat(blacklist_data["expires_at"])
|
||||
|
||||
# 如果黑名单条目已过期,删除它
|
||||
if datetime.utcnow() > expires_at:
|
||||
del self.blacklist_db[token_hash]
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def invalidate_token(self, token: str):
|
||||
"""使单个 Token 失效"""
|
||||
with self.lock:
|
||||
token_hash = self._hash_token(token)
|
||||
|
||||
# 添加到黑名单
|
||||
self.add_to_blacklist(token)
|
||||
|
||||
# 标记为不活跃
|
||||
if token_hash in self.token_db:
|
||||
cache_data = self.token_db[token_hash]
|
||||
cache_data["is_active"] = False
|
||||
self.token_db[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_id}:{token_type}"]
|
||||
else:
|
||||
keys = [f"{user_id}:access", f"{user_id}:refresh"]
|
||||
|
||||
for key in keys:
|
||||
token_hashes = self.user_sessions_db.get(key, [])
|
||||
for token_hash in token_hashes:
|
||||
# 添加到黑名单
|
||||
# 注意:这里我们只有 token_hash,没有原始 token
|
||||
# 所以只能标记为不活跃
|
||||
if token_hash in self.token_db:
|
||||
cache_data = self.token_db[token_hash]
|
||||
cache_data["is_active"] = False
|
||||
self.token_db[token_hash] = cache_data
|
||||
|
||||
# 清空用户会话索引
|
||||
self.user_sessions_db[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_id}:{token_type}"
|
||||
token_hashes = self.user_sessions_db.get(key, [])
|
||||
|
||||
for token_hash in token_hashes:
|
||||
if token_hash in self.token_db:
|
||||
cache_data = self.token_db[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, data in list(self.token_db.items()):
|
||||
expires_at = datetime.fromisoformat(data["expires_at"])
|
||||
if current_time > expires_at:
|
||||
del self.token_db[token_hash]
|
||||
cleaned_count += 1
|
||||
|
||||
# 清理过期黑名单
|
||||
for token_hash, data in list(self.blacklist_db.items()):
|
||||
expires_at = datetime.fromisoformat(data["expires_at"])
|
||||
if current_time > expires_at:
|
||||
del self.blacklist_db[token_hash]
|
||||
cleaned_count += 1
|
||||
|
||||
return cleaned_count
|
||||
|
||||
def close(self):
|
||||
with self.lock:
|
||||
"""关闭所有数据库连接"""
|
||||
self.token_db.close()
|
||||
self.blacklist_db.close()
|
||||
self.user_sessions_db.close()
|
||||
|
||||
|
||||
# 全局缓存实例
|
||||
_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
|
||||
|
||||
|
||||
# Token 数据模型
|
||||
class TokenData(BaseModel):
|
||||
"""Token 数据模型"""
|
||||
|
||||
user_id: str
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
scopes: List[str] = []
|
||||
@@ -0,0 +1,64 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from .config import Logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
|
||||
class ExcludeWatchFilesFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
# 如果日志记录来自 watchfiles 模块,则不记录(返回 False)
|
||||
return not record.name.startswith("watchfiles")
|
||||
|
||||
|
||||
def setup_logging(log_config: Logging):
|
||||
log_level = getattr(logging, log_config["level"], logging.INFO)
|
||||
log_format = log_config["format"]
|
||||
# 创建文件夹
|
||||
log_file_path = Path(log_config["file"])
|
||||
log_file_dir = log_file_path.parent
|
||||
if log_file_dir != Path("."):
|
||||
log_file_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 设置特定日志级别的过滤
|
||||
logging.getLogger("watchfiles").setLevel(logging.WARNING)
|
||||
|
||||
# 配置根日志记录器
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(log_level)
|
||||
|
||||
# 创建格式化器
|
||||
formatter = logging.Formatter(log_format)
|
||||
|
||||
# 创建轮转文件处理器 - 限制文件大小为 10MB,保留 5 个备份文件
|
||||
max_bytes = log_config.get("max_size", 10 * 1024 * 1024) # 默认 10MB
|
||||
backup_count = log_config.get("backup_count", 5) # 默认保留 5 个备份
|
||||
|
||||
file_handler = RotatingFileHandler(
|
||||
log_config["file"],
|
||||
encoding="utf-8",
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.addFilter(ExcludeWatchFilesFilter())
|
||||
|
||||
# 创建控制台处理器并添加过滤器
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.addFilter(ExcludeWatchFilesFilter())
|
||||
|
||||
# 添加处理器到根日志记录器
|
||||
root_logger.addHandler(file_handler)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
print(
|
||||
f"日志系统已配置 - 级别: {logging.getLevelName(log_level)}, 文件: {log_config['file']} "
|
||||
f"(最大:{max_bytes / 1024 / 1024:.1f}MB, 备份:{backup_count})"
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
# 为每个logger添加过滤器
|
||||
logger.addFilter(ExcludeWatchFilesFilter())
|
||||
return logger
|
||||
@@ -0,0 +1,14 @@
|
||||
from passlib.context import CryptContext
|
||||
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证密码"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""密码哈希"""
|
||||
return pwd_context.hash(password)
|
||||
@@ -0,0 +1,28 @@
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
# 定义模型
|
||||
class TodoBase(SQLModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
completed: bool = False
|
||||
|
||||
|
||||
class Todo(TodoBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
||||
|
||||
class TodoCreate(TodoBase):
|
||||
pass
|
||||
|
||||
|
||||
class TodoRead(TodoBase):
|
||||
id: int
|
||||
|
||||
|
||||
class TodoUpdate(SQLModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
completed: Optional[bool] = None
|
||||
@@ -0,0 +1,36 @@
|
||||
from typing import Optional
|
||||
from sqlmodel import SQLModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserBase(SQLModel):
|
||||
email: str = Field(unique=True, index=True)
|
||||
username: Optional[str] = Field(default=None, max_length=50)
|
||||
full_name: Optional[str] = Field(default=None, max_length=100)
|
||||
is_active: bool = Field(default=True)
|
||||
is_superuser: bool = Field(default=False)
|
||||
created_at: Optional[str] = Field(default=None) # 可自动填充,此处略
|
||||
|
||||
|
||||
class User(UserBase, table=True):
|
||||
"""用户模型"""
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
hashed_password: str
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""用户创建模型"""
|
||||
|
||||
password: str
|
||||
|
||||
|
||||
class UserRead(UserBase):
|
||||
"""用户读取模型"""
|
||||
|
||||
id: int
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
认证服务 - 处理用户认证和 Token 管理
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..model.user import User
|
||||
from .user import UserService
|
||||
from ..core.jwt_cache import get_jwt_cache, TokenResponse, TokenData
|
||||
from ..core.config import get_config
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""认证服务"""
|
||||
|
||||
def __init__(self):
|
||||
self.jwt_cache = get_jwt_cache()
|
||||
self.settings = get_config()
|
||||
self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
async def authenticate_user(
|
||||
self, email: str, password: str, session: AsyncSession
|
||||
) -> Optional[User]:
|
||||
"""验证用户凭据"""
|
||||
user = await UserService.get_user_by_email(email, session)
|
||||
if not user:
|
||||
return None
|
||||
if not self.pwd_context.verify(password, user.hashed_password):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
async def create_tokens(
|
||||
self,
|
||||
user: User,
|
||||
request: Optional[Request] = None,
|
||||
device_id: Optional[str] = None,
|
||||
) -> TokenResponse:
|
||||
"""为用户创建访问和刷新令牌"""
|
||||
|
||||
# 获取用户数据
|
||||
user_data = {
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"is_superuser": user.is_superuser,
|
||||
}
|
||||
|
||||
# 获取设备信息
|
||||
user_agent = None
|
||||
ip_address = None
|
||||
if request:
|
||||
user_agent = request.headers.get("user-agent")
|
||||
ip_address = request.client.host if request.client else None
|
||||
|
||||
# 创建访问令牌
|
||||
access_token = self.jwt_cache.create_token(
|
||||
user_id=str(user.id),
|
||||
user_data=user_data,
|
||||
token_type="access",
|
||||
device_id=device_id,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
# 创建刷新令牌
|
||||
refresh_token = self.jwt_cache.create_token(
|
||||
user_id=str(user.id),
|
||||
user_data=user_data,
|
||||
token_type="refresh",
|
||||
device_id=device_id,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=self.settings["security"]["access_token_expire_minutes"] * 60,
|
||||
refresh_expires_in=getattr(self.settings, "refresh_token_expire_days", 7)
|
||||
* 24
|
||||
* 60
|
||||
* 60,
|
||||
)
|
||||
|
||||
async def validate_access_token(self, token: str) -> Optional[TokenData]:
|
||||
"""验证访问令牌"""
|
||||
validation = self.jwt_cache.validate_token(token, token_type="access")
|
||||
if not validation:
|
||||
return None
|
||||
|
||||
return TokenData(
|
||||
user_id=validation["user_id"],
|
||||
username=validation["payload"]["data"].get("username"),
|
||||
email=validation["payload"]["data"].get("email"),
|
||||
scopes=[], # 可以根据需要添加权限范围
|
||||
)
|
||||
|
||||
async def refresh_access_token(
|
||||
self,
|
||||
refresh_token: str,
|
||||
session: AsyncSession,
|
||||
request: Optional[Request] = None,
|
||||
) -> Optional[str]:
|
||||
"""使用刷新令牌获取新的访问令牌"""
|
||||
validation = self.jwt_cache.validate_token(refresh_token, token_type="access")
|
||||
if not validation:
|
||||
return None
|
||||
|
||||
user_id = validation["user_id"]
|
||||
|
||||
# 获取用户
|
||||
user = await UserService.get_user_by_id(int(user_id), session)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# 创建新的访问令牌
|
||||
user_data = {
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"is_superuser": user.is_superuser,
|
||||
}
|
||||
|
||||
device_id = validation.get("device_id")
|
||||
user_agent = None
|
||||
ip_address = None
|
||||
if request:
|
||||
user_agent = request.headers.get("user-agent")
|
||||
ip_address = request.client.host if request.client else None
|
||||
|
||||
new_access_token = self.jwt_cache.create_token(
|
||||
user_id=user_id,
|
||||
user_data=user_data,
|
||||
token_type="access",
|
||||
device_id=device_id,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
return new_access_token
|
||||
|
||||
async def logout(self, token: str):
|
||||
"""用户登出 - 使令牌失效"""
|
||||
self.jwt_cache.invalidate_token(token)
|
||||
|
||||
async def logout_all(self, user_id: str):
|
||||
"""使用户的所有令牌失效"""
|
||||
self.jwt_cache.invalidate_user_tokens(str(user_id))
|
||||
|
||||
async def get_user_sessions(self, user_id: str):
|
||||
"""获取用户的活跃会话"""
|
||||
return self.jwt_cache.get_user_sessions(str(user_id))
|
||||
|
||||
|
||||
# 全局认证服务实例
|
||||
_auth_service = None
|
||||
|
||||
|
||||
def get_auth_service() -> AuthService:
|
||||
"""获取认证服务实例(单例模式)"""
|
||||
global _auth_service
|
||||
if _auth_service is None:
|
||||
_auth_service = AuthService()
|
||||
return _auth_service
|
||||
@@ -0,0 +1,65 @@
|
||||
from typing import Sequence, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from ..model.todo import TodoCreate, Todo, TodoUpdate
|
||||
|
||||
|
||||
class TodoService:
|
||||
@staticmethod
|
||||
async def create_todo(todo: TodoCreate, session: AsyncSession) -> Todo:
|
||||
db_todo = Todo.model_validate(todo)
|
||||
session.add(db_todo)
|
||||
await session.commit()
|
||||
await session.refresh(db_todo)
|
||||
return db_todo
|
||||
|
||||
@staticmethod
|
||||
async def get_todos(
|
||||
offset: int,
|
||||
limit: int,
|
||||
session: AsyncSession,
|
||||
) -> Sequence[Todo]:
|
||||
result = await session.execute(select(Todo).offset(offset).limit(limit))
|
||||
todos = result.scalars().all()
|
||||
return todos
|
||||
|
||||
@staticmethod
|
||||
async def get_todo_by_id(
|
||||
todo_id: int,
|
||||
session: AsyncSession,
|
||||
) -> Optional[Todo]:
|
||||
todo = await session.get(Todo, todo_id)
|
||||
return todo
|
||||
|
||||
@staticmethod
|
||||
async def update_todo(
|
||||
todo_id: int,
|
||||
todo_update: TodoUpdate,
|
||||
session: AsyncSession,
|
||||
) -> Optional[Todo]:
|
||||
todo = await session.get(Todo, todo_id)
|
||||
if not todo:
|
||||
return None
|
||||
update_data = todo_update.model_dump(exclude_unset=True)
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(todo, key, value)
|
||||
|
||||
session.add(todo)
|
||||
await session.commit()
|
||||
await session.refresh(todo)
|
||||
return todo
|
||||
|
||||
@staticmethod
|
||||
async def delete_todo(
|
||||
todo_id: int,
|
||||
session: AsyncSession,
|
||||
) -> bool:
|
||||
todo = await session.get(Todo, todo_id)
|
||||
if not todo:
|
||||
return False
|
||||
await session.delete(todo)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -0,0 +1,28 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional
|
||||
|
||||
from ..model.user import User
|
||||
from ..core.security import get_password_hash
|
||||
|
||||
|
||||
class UserService:
|
||||
@staticmethod
|
||||
async def get_user_by_email(email: str, session: AsyncSession) -> Optional[User]:
|
||||
result = await session.execute(select(User).where(User.email == email)) # type: ignore
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_id(user_id: int, session: AsyncSession) -> Optional[User]:
|
||||
return await session.get(User, user_id)
|
||||
|
||||
@staticmethod
|
||||
async def create_user(
|
||||
session: AsyncSession, email: str, password: str, **kwargs
|
||||
) -> User:
|
||||
hashed = get_password_hash(password)
|
||||
user = User(email=email, hashed_password=hashed, **kwargs)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
@@ -0,0 +1,253 @@
|
||||
from module_bank import PythonToSQLite
|
||||
from module_bank.encryption import Encryption
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
|
||||
def create_admin():
|
||||
try:
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"../src/chongming/scripts/create_admin.py",
|
||||
]
|
||||
print(f"正在运行 create_admin.py: {' '.join(cmd)}")
|
||||
|
||||
# 创建包含 UTF-8 编码的环境变量
|
||||
env = os.environ.copy()
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
env["PYTHONUTF8"] = "1"
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd="./build",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8", # 明确指定编码
|
||||
errors="replace", # 替换无法解码的字符
|
||||
env=env, # 传递修改后的环境变量
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print("创建管理员用户成功")
|
||||
print(result.stdout)
|
||||
else:
|
||||
print("创建管理员用户失败")
|
||||
# 安全地打印错误信息,避免编码问题
|
||||
try:
|
||||
print("错误信息:", result.stderr)
|
||||
except UnicodeEncodeError:
|
||||
print(
|
||||
"错误信息:", result.stderr.encode("ascii", "ignore").decode("ascii")
|
||||
)
|
||||
except Exception as e:
|
||||
print("创建管理员用户失败")
|
||||
print(f"异常: {e}")
|
||||
|
||||
|
||||
def run_pyarmor_obfuscate():
|
||||
# pyarmor obfuscate --output=obfuscated_script.py original_script.py
|
||||
obfuscate_files = [
|
||||
("main.py", "./"),
|
||||
("server.py", "./"),
|
||||
("utils/launch.py", "./utils"),
|
||||
("utils/config.py", "./utils"),
|
||||
]
|
||||
for file, output in obfuscate_files:
|
||||
cmd = [
|
||||
"pyarmor",
|
||||
"gen",
|
||||
f"--output={output}",
|
||||
file,
|
||||
]
|
||||
result = subprocess.run(cmd, cwd="./build", capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"打包文件: {file} 失败")
|
||||
print(result.stderr)
|
||||
return
|
||||
else:
|
||||
print(result.stdout)
|
||||
print(f"打包文件: {file}")
|
||||
|
||||
|
||||
def run_pyinstaller():
|
||||
"""运行 PyInstaller 打包成可执行文件"""
|
||||
try:
|
||||
# 构建 PyInstaller 命令
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--onefile",
|
||||
"--name",
|
||||
"chongming",
|
||||
"--distpath",
|
||||
".",
|
||||
"--workpath",
|
||||
"./build_temp",
|
||||
"--specpath",
|
||||
"./specs",
|
||||
"--clean",
|
||||
# 隐藏导入
|
||||
"--hidden-import",
|
||||
"utils",
|
||||
"--hidden-import",
|
||||
"utils.launch",
|
||||
"--hidden-import",
|
||||
"utils.config",
|
||||
"--hidden-import",
|
||||
"server",
|
||||
"--hidden-import",
|
||||
"module_bank",
|
||||
"--hidden-import",
|
||||
"fastapi",
|
||||
"--hidden-import",
|
||||
"fastapi.middleware.cors",
|
||||
"--hidden-import",
|
||||
"uvicorn",
|
||||
"--hidden-import",
|
||||
"sqlmodel",
|
||||
"--hidden-import",
|
||||
"aiosqlite",
|
||||
"--hidden-import",
|
||||
"pyjwt",
|
||||
"--hidden-import",
|
||||
"sqlitedict",
|
||||
"--hidden-import",
|
||||
"passlib",
|
||||
"--hidden-import",
|
||||
"passlib.context",
|
||||
"--hidden-import",
|
||||
"passlib.handlers.bcrypt",
|
||||
"--hidden-import",
|
||||
"jwt",
|
||||
# 其他选项
|
||||
"--icon",
|
||||
"../../public/chongming.ico",
|
||||
"main.py",
|
||||
]
|
||||
|
||||
print(f"正在运行 PyInstaller: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, cwd="./build", capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
print("PyInstaller 打包成功!")
|
||||
print(result.stdout)
|
||||
else:
|
||||
print("PyInstaller 打包失败!")
|
||||
print("错误信息:", result.stderr)
|
||||
|
||||
except FileNotFoundError:
|
||||
print("错误:未找到 PyInstaller,请先安装 PyInstaller:pip install pyinstaller")
|
||||
except Exception as e:
|
||||
print(f"PyInstaller 执行出错: {e}")
|
||||
|
||||
|
||||
async def init_database():
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from ..app.core.config import get_config
|
||||
from sqlmodel import SQLModel, text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
database = get_config()["database"]
|
||||
database_type = database["type"]
|
||||
database_config = database.get(database_type, None)
|
||||
if database_config is None:
|
||||
raise ValueError("配置不存在")
|
||||
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///./build/database.db", **database_config
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
if database_type == "sqlite":
|
||||
await conn.execute(text("PRAGMA journal_mode=WAL"))
|
||||
await conn.execute(text("PRAGMA synchronous=NORMAL"))
|
||||
try:
|
||||
await conn.run_sync(
|
||||
lambda conn: SQLModel.metadata.create_all(bind=conn, checkfirst=True)
|
||||
)
|
||||
except OperationalError as e:
|
||||
if "already exists" in str(e):
|
||||
raise
|
||||
|
||||
|
||||
async def main():
|
||||
# 打包模块
|
||||
key = Encryption.generate_key()
|
||||
resources_path = Path("resources")
|
||||
if not resources_path.exists():
|
||||
resources_path.mkdir()
|
||||
app_packer = PythonToSQLite("./resources/app.mbank", key)
|
||||
app_packer.pack_directory("src/chongming/app", "app")
|
||||
app_packer.verify_package_structure()
|
||||
app_packer.delete_source_code(None)
|
||||
|
||||
plugin_packer = PythonToSQLite("./resources/plugins.mbank", key)
|
||||
plugin_packer.pack_directory("src/plugins", "plugins")
|
||||
plugin_packer.verify_package_structure()
|
||||
plugin_packer.delete_source_code(None)
|
||||
|
||||
# 创建 build 目录
|
||||
build_path = Path("build")
|
||||
if build_path.exists() and build_path.is_dir():
|
||||
shutil.rmtree("build")
|
||||
|
||||
os.makedirs("build", exist_ok=True)
|
||||
|
||||
# 复制 utils 文件夹到 build 文件夹
|
||||
utils_path = Path("utils")
|
||||
if utils_path.exists():
|
||||
target_utils_path = Path("build") / "utils"
|
||||
shutil.copytree(utils_path, target_utils_path, dirs_exist_ok=True)
|
||||
print(f"已复制 utils 文件夹到 {target_utils_path}")
|
||||
|
||||
# 复制 resources 文件夹到 build 文件夹
|
||||
resources_path = Path("resources")
|
||||
if resources_path.exists():
|
||||
target_resources_path = Path("build") / "resources"
|
||||
shutil.copytree(resources_path, target_resources_path, dirs_exist_ok=True)
|
||||
print(f"已复制 resources 文件夹到 {target_resources_path}")
|
||||
|
||||
print("模块打包完成")
|
||||
|
||||
# 复制 main.py 到 build 文件夹
|
||||
shutil.copy("public/main.py", "build")
|
||||
shutil.copy("public/server.py", "build")
|
||||
# 复制 config.toml 到 build 文件夹
|
||||
shutil.copy("public/config.toml", "build")
|
||||
# 复制 utils 文件夹到 build 文件夹
|
||||
shutil.copytree("public/utils", "build/utils")
|
||||
|
||||
with open("./build/utils/launch.py", "r", encoding="utf-8") as f:
|
||||
context = f.read()
|
||||
context = context.replace("key = None", f"key = '{key}'")
|
||||
with open("./build/utils/launch.py", "w", encoding="utf-8") as f:
|
||||
f.write(context)
|
||||
|
||||
# 混淆
|
||||
run_pyarmor_obfuscate()
|
||||
|
||||
# 运行 PyInstaller
|
||||
run_pyinstaller()
|
||||
|
||||
# 清理临时文件
|
||||
shutil.rmtree(r"build/build_temp", ignore_errors=True)
|
||||
shutil.rmtree(r"build/specs", ignore_errors=True)
|
||||
shutil.rmtree(r"build/utils", ignore_errors=True)
|
||||
shutil.rmtree(r"build/pyarmor_runtime_000000", ignore_errors=True)
|
||||
os.remove(r"build/main.py")
|
||||
os.remove(r"build/server.py")
|
||||
# os.remove(r"build/config.toml")
|
||||
|
||||
print("构建完成")
|
||||
|
||||
# 初始化数据库
|
||||
await init_database()
|
||||
|
||||
|
||||
def build():
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
import shutil
|
||||
# 需要遍历的目录
|
||||
root_dir = "./"
|
||||
# 遍历目录
|
||||
for dirpath, dirnames, filenames in os.walk(root_dir):
|
||||
if "__pycache__" in dirnames:
|
||||
# 获取 __pycache__ 目录的全路径
|
||||
pycache_dir = os.path.join(dirpath, "__pycache__")
|
||||
# 删除目录
|
||||
shutil.rmtree(pycache_dir)
|
||||
print(f"Removed: {pycache_dir}")
|
||||
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def generate_markdown_from_py_files(directory, output_file):
|
||||
with open(output_file, "w", encoding="utf-8") as md_file:
|
||||
for root, dirs, files in os.walk(directory):
|
||||
# 排除 venv 目录
|
||||
dirs[:] = [d for d in dirs if d != ".venv"]
|
||||
dirs[:] = [d for d in dirs if d != ".vscode"]
|
||||
# dirs[:] = [d for d in dirs if d != "scripts"]
|
||||
dirs[:] = [d for d in dirs if d != "build"]
|
||||
for file in files:
|
||||
if (
|
||||
file.endswith(".py")
|
||||
or file.endswith(".rs")
|
||||
or file.endswith(".toml")
|
||||
or file.endswith(".md")
|
||||
):
|
||||
file_path = os.path.join(root, file)
|
||||
if file == "output.md":
|
||||
continue
|
||||
md_file.write(f"`{file_path}`\n")
|
||||
md_file.write("```python\n")
|
||||
with open(file_path, "r", encoding="utf-8") as py_file:
|
||||
md_file.write(py_file.read())
|
||||
md_file.write("\n```\n\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 指定目录和输出文件名
|
||||
target_directory = sys.argv[1] # 替换为你的目标目录
|
||||
output_markdown_file = "output.md" # 输出的 Markdown 文件名
|
||||
|
||||
generate_markdown_from_py_files(target_directory, output_markdown_file)
|
||||
print(f"Markdown 文件已生成:{output_markdown_file}")
|
||||
Reference in New Issue
Block a user