v0.1测试

This commit is contained in:
2026-04-07 17:07:13 +08:00
parent 593973f598
commit 6b1b586fe3
80 changed files with 9073 additions and 32 deletions

11
backend/utils/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================

136
backend/utils/database.py Normal file
View File

@@ -0,0 +1,136 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
import aiomysql
from typing import Optional, Dict, Any, List
from contextlib import asynccontextmanager
from config import settings
from utils.logger import get_logger
logger = get_logger(__name__)
# 连接池实例
_pool: Optional[aiomysql.Pool] = None
async def init_db_pool() -> None:
"""初始化数据库连接池"""
global _pool
try:
_pool = await aiomysql.create_pool(
host=settings.DB_HOST,
port=settings.DB_PORT,
user=settings.DB_USER,
password=settings.DB_PASSWORD,
db=settings.DB_NAME,
minsize=1,
maxsize=settings.DB_POOL_SIZE,
maxsize=settings.DB_MAX_OVERFLOW,
autocommit=False,
charset='utf8mb4',
cursorclass=aiomysql.DictCursor
)
logger.info("数据库连接池初始化成功")
except Exception as e:
logger.error(f"数据库连接池初始化失败: {e}")
raise
async def close_db_pool() -> None:
"""关闭数据库连接池"""
global _pool
if _pool:
_pool.close()
await _pool.wait_closed()
logger.info("数据库连接池已关闭")
def get_pool() -> aiomysql.Pool:
"""获取连接池实例"""
if _pool is None:
raise RuntimeError("数据库连接池未初始化")
return _pool
@asynccontextmanager
async def get_connection():
"""获取数据库连接(上下文管理器)"""
pool = get_pool()
async with pool.acquire() as conn:
async with conn.cursor() as cursor:
yield cursor
await conn.commit()
@asynccontextmanager
async def get_transaction():
"""获取事务连接"""
pool = get_pool()
async with pool.acquire() as conn:
async with conn.cursor() as cursor:
try:
yield cursor
await conn.commit()
except Exception:
await conn.rollback()
raise
async def execute_query(sql: str, params: tuple = None) -> List[Dict[str, Any]]:
"""执行查询SQL"""
async with get_connection() as cursor:
await cursor.execute(sql, params)
return await cursor.fetchall()
async def execute_one(sql: str, params: tuple = None) -> Optional[Dict[str, Any]]:
"""执行查询SQL单条"""
async with get_connection() as cursor:
await cursor.execute(sql, params)
return await cursor.fetchone()
async def execute_insert(sql: str, params: tuple = None) -> int:
"""执行插入SQL返回自增ID"""
async with get_connection() as cursor:
await cursor.execute(sql, params)
return cursor.lastrowid
async def execute_update(sql: str, params: tuple = None) -> int:
"""执行更新SQL返回影响行数"""
async with get_connection() as cursor:
result = await cursor.execute(sql, params)
return result
async def execute_many(sql: str, params_list: list) -> int:
"""批量执行SQL"""
async with get_connection() as cursor:
await cursor.executemany(sql, params_list)
return cursor.rowcount
async def call_procedure(proc_name: str, args: tuple = None) -> List[Dict[str, Any]]:
"""调用存储过程"""
async with get_connection() as cursor:
if args:
await cursor.callproc(proc_name, args)
else:
await cursor.callproc(proc_name)
# 获取结果
result = []
for result_set in cursor.fetchall():
if result_set:
result.extend(result_set)
return result

View File

@@ -0,0 +1,86 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from jose import jwt, JWTError
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from config import settings
from utils.logger import get_logger
logger = get_logger(__name__)
class JWTHandler:
"""JWT Token处理类"""
@staticmethod
def create_token(user_id: int, username: str, user_type: str, student_id: int = None, role: str = None) -> str:
"""
创建JWT Token
"""
payload = {
'user_id': user_id,
'username': username,
'user_type': user_type,
'student_id': student_id,
'role': role,
'exp': datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES),
'iat': datetime.utcnow(),
'iss': settings.APP_NAME
}
token = jwt.encode(
payload,
settings.JWT_SECRET_KEY,
algorithm=settings.JWT_ALGORITHM
)
return token
@staticmethod
def verify_token(token: str) -> Optional[Dict[str, Any]]:
"""
验证JWT Token
返回: 解码后的payload失败返回None
"""
try:
payload = jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM],
options={'verify_exp': True}
)
return payload
except jwt.ExpiredSignatureError:
logger.warning("JWT Token已过期")
return None
except jwt.JWTError as e:
logger.warning(f"JWT Token验证失败: {e}")
return None
@staticmethod
def get_user_id_from_token(token: str) -> Optional[int]:
"""从Token中获取用户ID"""
payload = JWTHandler.verify_token(token)
if payload:
return payload.get('user_id')
return None
@staticmethod
def get_user_type_from_token(token: str) -> Optional[str]:
"""从Token中获取用户类型"""
payload = JWTHandler.verify_token(token)
if payload:
return payload.get('user_type')
return None
jwt_handler = JWTHandler()

102
backend/utils/logger.py Normal file
View File

@@ -0,0 +1,102 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
import sys
from loguru import logger
from pathlib import Path
from fastapi import Request
from config import settings
# 日志目录
LOG_DIR = Path(__file__).parent.parent / "logs"
LOG_DIR.mkdir(exist_ok=True)
def setup_logger():
"""配置日志系统"""
# 移除默认处理器
logger.remove()
# 控制台输出仅INFO及以上
logger.add(
sys.stdout,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan> - <level>{message}</level>",
level=settings.LOG_LEVEL,
colorize=True
)
# 应用日志(轮转)
logger.add(
LOG_DIR / "app.log",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name} | {message}",
rotation=settings.LOG_MAX_BYTES,
retention=settings.LOG_RETENTION_DAYS,
compression="gz",
encoding="utf-8",
level="DEBUG"
)
# 错误日志(单独记录)
logger.add(
LOG_DIR / "error.log",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name} | {message}",
rotation=settings.LOG_MAX_BYTES,
retention=settings.LOG_RETENTION_DAYS * 2,
compression="gz",
encoding="utf-8",
level="ERROR"
)
# 访问日志
logger.add(
LOG_DIR / "access.log",
format="{time:YYYY-MM-DD HH:mm:ss} | {message}",
rotation="1 day",
retention="90 days",
compression="gz",
encoding="utf-8",
filter=lambda record: record["extra"].get("type") == "access"
)
# 操作日志
logger.add(
LOG_DIR / "operation.log",
format="{time:YYYY-MM-DD HH:mm:ss} | {message}",
rotation=settings.LOG_MAX_BYTES,
retention=settings.LOG_RETENTION_DAYS,
compression="gz",
encoding="utf-8",
filter=lambda record: record["extra"].get("type") == "operation"
)
return logger
def get_logger(name: str):
"""获取日志记录器"""
return logger.bind(name=name)
def log_access(request: Request):
"""记录访问日志"""
logger.bind(type="access").info(f"{request.method} {request.url.path} - {request.client.host}")
def log_operation(operator_id: int, operator_name: str, action: str, details: str = ""):
"""记录操作日志"""
logger.bind(type="operation").info(f"用户[{operator_id}:{operator_name}] 执行 {action} - {details}")
# 导出logger
__all__ = ["setup_logger", "get_logger", "log_access", "log_operation", "logger"]

View File

@@ -0,0 +1,140 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
import redis.asyncio as redis
from typing import Optional, Any
import json
from config import settings
from utils.logger import get_logger
logger = get_logger(__name__)
# Redis客户端实例
_redis_client: Optional[redis.Redis] = None
async def init_redis_pool() -> None:
"""初始化Redis连接池"""
global _redis_client
try:
_redis_client = redis.from_url(
settings.REDIS_URL,
max_connections=settings.REDIS_MAX_CONNECTIONS,
decode_responses=True
)
# 测试连接
await _redis_client.ping()
logger.info("Redis连接池初始化成功")
except Exception as e:
logger.error(f"Redis连接池初始化失败: {e}")
raise
async def close_redis_pool() -> None:
"""关闭Redis连接池"""
global _redis_client
if _redis_client:
await _redis_client.close()
logger.info("Redis连接池已关闭")
def get_redis() -> redis.Redis:
"""获取Redis客户端"""
if _redis_client is None:
raise RuntimeError("Redis客户端未初始化")
return _redis_client
class RedisClient:
"""Redis操作封装类"""
@staticmethod
async def set(key: str, value: Any, expire: int = None) -> bool:
"""设置缓存"""
client = get_redis()
if isinstance(value, (dict, list)):
value = json.dumps(value, ensure_ascii=False)
else:
value = str(value)
if expire:
return await client.setex(key, expire, value)
return await client.set(key, value)
@staticmethod
async def get(key: str) -> Optional[str]:
"""获取缓存"""
client = get_redis()
return await client.get(key)
@staticmethod
async def get_json(key: str) -> Optional[Any]:
"""获取JSON格式缓存"""
value = await RedisClient.get(key)
if value:
try:
return json.loads(value)
except json.JSONDecodeError:
return value
return None
@staticmethod
async def delete(key: str) -> int:
"""删除缓存"""
client = get_redis()
return await client.delete(key)
@staticmethod
async def exists(key: str) -> bool:
"""检查key是否存在"""
client = get_redis()
return await client.exists(key) > 0
@staticmethod
async def expire(key: str, seconds: int) -> bool:
"""设置过期时间"""
client = get_redis()
return await client.expire(key, seconds)
@staticmethod
async def set_user_token(user_id: int, token: str, expire: int = None) -> bool:
"""设置用户Token缓存"""
key = f"user_token:{user_id}"
expire = expire or settings.JWT_EXPIRE_MINUTES * 60
return await RedisClient.set(key, token, expire)
@staticmethod
async def get_user_token(user_id: int) -> Optional[str]:
"""获取用户Token"""
key = f"user_token:{user_id}"
return await RedisClient.get(key)
@staticmethod
async def delete_user_token(user_id: int) -> int:
"""删除用户Token"""
key = f"user_token:{user_id}"
return await RedisClient.delete(key)
@staticmethod
async def set_login_attempts(username: str) -> int:
"""记录登录失败次数"""
key = f"login_attempts:{username}"
attempts = await RedisClient.get(key)
attempts = int(attempts) + 1 if attempts else 1
await RedisClient.set(key, attempts, 900) # 15分钟锁定
return attempts
@staticmethod
async def clear_login_attempts(username: str) -> None:
"""清除登录失败记录"""
key = f"login_attempts:{username}"
await RedisClient.delete(key)

106
backend/utils/response.py Normal file
View File

@@ -0,0 +1,106 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Any, Optional, Dict, List
from fastapi.responses import JSONResponse
class ResponseCode:
"""响应状态码"""
SUCCESS = 200
CREATED = 201
BAD_REQUEST = 400
UNAUTHORIZED = 401
FORBIDDEN = 403
NOT_FOUND = 404
CONFLICT = 409
UNPROCESSABLE = 422
INTERNAL_ERROR = 500
def success_response(data: Any = None, message: str = "操作成功") -> JSONResponse:
"""成功响应"""
return JSONResponse(
status_code=ResponseCode.SUCCESS,
content={
"success": True,
"code": ResponseCode.SUCCESS,
"message": message,
"data": data
}
)
def error_response(
message: str = "操作失败",
code: int = ResponseCode.BAD_REQUEST,
data: Any = None
) -> JSONResponse:
"""错误响应"""
return JSONResponse(
status_code=code,
content={
"success": False,
"code": code,
"message": message,
"data": data
}
)
def unauthorized_response(message: str = "未授权,请重新登录") -> JSONResponse:
"""未授权响应"""
return JSONResponse(
status_code=ResponseCode.UNAUTHORIZED,
content={
"success": False,
"code": ResponseCode.UNAUTHORIZED,
"message": message,
"data": None
}
)
def forbidden_response(message: str = "权限不足") -> JSONResponse:
"""禁止访问响应"""
return JSONResponse(
status_code=ResponseCode.FORBIDDEN,
content={
"success": False,
"code": ResponseCode.FORBIDDEN,
"message": message,
"data": None
}
)
def not_found_response(message: str = "资源不存在") -> JSONResponse:
"""资源不存在响应"""
return JSONResponse(
status_code=ResponseCode.NOT_FOUND,
content={
"success": False,
"code": ResponseCode.NOT_FOUND,
"message": message,
"data": None
}
)
def paginated_response(items: List[Any], total: int, page: int, page_size: int) -> Dict:
"""分页响应数据"""
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size
}

131
backend/utils/security.py Normal file
View File

@@ -0,0 +1,131 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
import hashlib
import secrets
import re
from config import settings
class SecurityUtils:
"""安全工具类"""
@staticmethod
def sha1_md5_password(password: str) -> str:
"""
双重加密sha1 + md5
流程:原始密码 -> sha1 -> 加盐 -> md5
"""
# 第一层SHA1
sha1_hash = hashlib.sha1(password.encode('utf-8')).hexdigest()
# 加盐
salted = sha1_hash + settings.PASSWORD_SALT
# 第二层MD5
md5_hash = hashlib.md5(salted.encode('utf-8')).hexdigest()
return md5_hash
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return SecurityUtils.sha1_md5_password(plain_password) == hashed_password
@staticmethod
def generate_random_password(length: int = 8) -> str:
"""生成随机密码"""
alphabet = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'
return ''.join(secrets.choice(alphabet) for _ in range(length))
@staticmethod
def validate_password_strength(password: str) -> tuple:
"""
验证密码强度
返回: (是否有效, 错误信息)
"""
if len(password) < 6:
return False, "密码长度至少6位"
if len(password) > 20:
return False, "密码长度不能超过20位"
# 检查是否包含至少一个数字
if not any(c.isdigit() for c in password):
return False, "密码必须包含至少一个数字"
# 检查是否包含至少一个字母
if not any(c.isalpha() for c in password):
return False, "密码必须包含至少一个字母"
return True, ""
@staticmethod
def sanitize_string(value: str, max_length: int = 255) -> str:
"""
清理字符串输入
- 去除首尾空格
- 限制长度
- 转义特殊字符
"""
if not value:
return ""
# 去除首尾空格
value = value.strip()
# 限制长度
if len(value) > max_length:
value = value[:max_length]
# 转义HTML特殊字符防止XSS
html_chars = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;'
}
for char, escape in html_chars.items():
value = value.replace(char, escape)
return value
@staticmethod
def validate_student_no(student_no: str) -> bool:
"""验证学号格式(数字+字母长度4-20"""
if not student_no:
return False
if len(student_no) < 4 or len(student_no) > 20:
return False
# 字母数字组合
return student_no.isalnum()
@staticmethod
def validate_phone(phone: str) -> bool:
"""验证手机号格式(中国手机号)"""
if not phone:
return False
pattern = r'^1[3-9]\d{9}$'
return bool(re.match(pattern, phone))
@staticmethod
def validate_points_change(points: int, max_abs: int = 100) -> tuple:
"""
验证分值变动
返回: (是否有效, 错误信息)
"""
if points == 0:
return False, "分值不能为0"
if abs(points) > max_abs:
return f"单次分值变动不能超过{max_abs}"
return True, ""
# 单例导出
security = SecurityUtils()