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

164
backend/.env.example Normal file
View File

@@ -0,0 +1,164 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
# ===========================================
# FastAPI 应用配置
# ===========================================
# 应用名称 - 显示在API文档和日志中
APP_NAME=班级操行分管理系统
# 运行环境 - production(生产) / development(开发) / testing(测试)
# 生产环境会自动启用HTTPS重定向
APP_ENV=production
# 调试模式 - true开启详细错误信息生产环境必须为false
DEBUG=False
# 应用密钥 - 用于会话加密必须32位以上随机字符串
# 生成方法: openssl rand -hex 32
SECRET_KEY=your-super-secret-key-min-32-characters-long
# API版本号 - 用于路由前缀,如 /api/v1
API_VERSION=v1
# ===========================================
# MySQL 数据库配置
# ===========================================
# 数据库主机地址 - 本地用127.0.0.1远程用实际IP
DB_HOST=127.0.0.1
# 数据库端口 - MySQL默认3306
DB_PORT=3306
# 数据库用户名 - 建议创建专用账户不要用root
DB_USER=class_admin
# 数据库密码 - 使用强密码,包含大小写数字特殊字符
DB_PASSWORD=your-strong-db-password
# 数据库名称 - 固定为 classmanagerdb
DB_NAME=classmanagerdb
# 连接池大小 - 同时保持的数据库连接数,根据并发量调整
DB_POOL_SIZE=10
# 最大溢出连接 - 连接池满后最多额外创建的连接数
DB_MAX_OVERFLOW=20
# ===========================================
# Redis 缓存配置
# ===========================================
# Redis主机地址
REDIS_HOST=127.0.0.1
# Redis端口 - 默认6379
REDIS_PORT=6379
# Redis密码 - 建议设置,防止未授权访问
REDIS_PASSWORD=your-redis-password
# Redis数据库编号 - 0-15建议使用独立数据库避免冲突
REDIS_DB=0
# 最大连接数 - 根据并发量调整建议50-200
REDIS_MAX_CONNECTIONS=50
# ===========================================
# JWT 认证配置
# ===========================================
# JWT密钥 - 用于签名Token必须32位以上随机字符串
# 生成方法: openssl rand -hex 32
JWT_SECRET_KEY=your-jwt-secret-key-min-32-chars
# JWT签名算法 - HS256对称加密
JWT_ALGORITHM=HS256
# Token过期时间分钟- 30分钟无操作需重新登录
JWT_EXPIRE_MINUTES=30
# ===========================================
# 密码加密配置
# ===========================================
# 密码盐值 - 固定字符串用于SHA1+MD5双重加密
# 生成后不可更改,否则所有密码失效
# 生成方法: openssl rand -hex 16
PASSWORD_SALT=your-fixed-salt-string-for-password-hash
# ===========================================
# 调试入口配置
# ===========================================
# 调试入口路径 - 随机字符串,用于添加第一批管理员
# 建议使用32位随机字符串只有开发人员知道此路径
# 添加完管理员后建议注释掉debug路由
# 生成方法: openssl rand -hex 16 | sed 's/\(..\)/\1/g' | cut -c1-32
DEBUG_PATH=/a7k9x2m4q8w1e3r5t6y7u8i9o0p1z2x3
# ===========================================
# 扣分规则配置
# ===========================================
# 注意:这些规则仅用于建议,实际操作时可选择是否应用扣分
# 作业未提交扣分 - 科代表标记未提交时建议扣分数值
DEDUCTION_HOMEWORK_NOT_SUBMIT=2
# 作业迟交扣分 - 迟交作业建议扣分数值
DEDUCTION_HOMEWORK_LATE=1
# 缺勤扣分 - 缺勤建议扣分数值
DEDUCTION_ATTENDANCE_ABSENT=5
# 迟到扣分 - 迟到建议扣分数值
DEDUCTION_ATTENDANCE_LATE=2
# 请假扣分 - 请假建议扣分数值
DEDUCTION_ATTENDANCE_LEAVE=1
# ===========================================
# 劳动委员固定分值配置
# ===========================================
# 劳动委员加分值 - 固定为1分
LABOR_POINTS_ADD=1
# 劳动委员扣分值 - 固定为-1分
LABOR_POINTS_SUBTRACT=-1
# ===========================================
# 班长加减分限制配置
# ===========================================
# 班长最大单次加分值
MONITOR_MAX_ADD=5
# 班长最大单次扣分值(负数)
MONITOR_MAX_SUBTRACT=-5
# ===========================================
# 日志配置
# ===========================================
# 日志级别 - DEBUG/INFO/WARNING/ERROR
# 生产环境建议INFO开发环境可用DEBUG
LOG_LEVEL=INFO
# 单日志文件最大大小(字节)- 100MB = 104857600
LOG_MAX_BYTES=104857600
# 日志备份数量 - 保留30个历史日志文件
LOG_BACKUP_COUNT=30
# 日志保留天数 - 操作日志保留1年访问日志保留90天
LOG_RETENTION_DAYS=365
# ===========================================
# CORS 跨域配置
# ===========================================
# 允许的跨域域名 - 多个域名用英文逗号分隔,不要有空格
# 生产环境必须指定具体域名,不能用 *
CORS_ORIGINS=https://your-frontend-domain.com,http://localhost:8080
# ===========================================
# 上传文件配置
# ===========================================
# 最大上传文件大小(字节)- 5MB = 5242880
MAX_UPLOAD_SIZE=5242880
# 允许的文件扩展名 - 多个用英文逗号分隔
ALLOWED_EXTENSIONS=json
# ===========================================
# 学生初始配置
# ===========================================
# 学生初始操行分 - 新生导入时的默认分数默认60分
STUDENT_INITIAL_POINTS=60

107
backend/config.py Normal file
View File

@@ -0,0 +1,107 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
import os
from dotenv import load_dotenv
from typing import List
# 加载环境变量
load_dotenv()
class Settings:
"""应用配置类"""
# ========== 应用配置 ==========
APP_NAME: str = os.getenv("APP_NAME", "班级操行分管理系统")
APP_ENV: str = os.getenv("APP_ENV", "production")
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
SECRET_KEY: str = os.getenv("SECRET_KEY", "")
API_VERSION: str = os.getenv("API_VERSION", "v1")
# ========== 数据库配置 ==========
DB_HOST: str = os.getenv("DB_HOST", "127.0.0.1")
DB_PORT: int = int(os.getenv("DB_PORT", "3306"))
DB_USER: str = os.getenv("DB_USER", "root")
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "")
DB_NAME: str = os.getenv("DB_NAME", "classmanagerdb")
DB_POOL_SIZE: int = int(os.getenv("DB_POOL_SIZE", "10"))
DB_MAX_OVERFLOW: int = int(os.getenv("DB_MAX_OVERFLOW", "20"))
# ========== Redis配置 ==========
REDIS_HOST: str = os.getenv("REDIS_HOST", "127.0.0.1")
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "")
REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
REDIS_MAX_CONNECTIONS: int = int(os.getenv("REDIS_MAX_CONNECTIONS", "50"))
@property
def REDIS_URL(self) -> str:
"""获取Redis连接URL"""
if self.REDIS_PASSWORD:
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
# ========== JWT配置 ==========
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "")
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
JWT_EXPIRE_MINUTES: int = int(os.getenv("JWT_EXPIRE_MINUTES", "30"))
# ========== 密码加密 ==========
PASSWORD_SALT: str = os.getenv("PASSWORD_SALT", "")
# ========== 调试入口 ==========
DEBUG_PATH: str = os.getenv("DEBUG_PATH", "/debug_add_admin")
# ========== 扣分规则 ==========
DEDUCTION_HOMEWORK_NOT_SUBMIT: int = int(os.getenv("DEDUCTION_HOMEWORK_NOT_SUBMIT", "2"))
DEDUCTION_HOMEWORK_LATE: int = int(os.getenv("DEDUCTION_HOMEWORK_LATE", "1"))
DEDUCTION_ATTENDANCE_ABSENT: int = int(os.getenv("DEDUCTION_ATTENDANCE_ABSENT", "5"))
DEDUCTION_ATTENDANCE_LATE: int = int(os.getenv("DEDUCTION_ATTENDANCE_LATE", "2"))
DEDUCTION_ATTENDANCE_LEAVE: int = int(os.getenv("DEDUCTION_ATTENDANCE_LEAVE", "1"))
# ========== 劳动委员固定分值 ==========
LABOR_POINTS_ADD: int = int(os.getenv("LABOR_POINTS_ADD", "1"))
LABOR_POINTS_SUBTRACT: int = int(os.getenv("LABOR_POINTS_SUBTRACT", "-1"))
# ========== 班长加减分限制 ==========
MONITOR_MAX_ADD: int = int(os.getenv("MONITOR_MAX_ADD", "5"))
MONITOR_MAX_SUBTRACT: int = int(os.getenv("MONITOR_MAX_SUBTRACT", "-5"))
# ========== 日志配置 ==========
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
LOG_MAX_BYTES: int = int(os.getenv("LOG_MAX_BYTES", "104857600"))
LOG_BACKUP_COUNT: int = int(os.getenv("LOG_BACKUP_COUNT", "30"))
LOG_RETENTION_DAYS: int = int(os.getenv("LOG_RETENTION_DAYS", "365"))
# ========== CORS配置 ==========
CORS_ORIGINS: List[str] = os.getenv("CORS_ORIGINS", "http://localhost:8080").split(",")
# ========== 上传配置 ==========
MAX_UPLOAD_SIZE: int = int(os.getenv("MAX_UPLOAD_SIZE", "5242880"))
ALLOWED_EXTENSIONS: set = set(os.getenv("ALLOWED_EXTENSIONS", "json").split(","))
# ========== 学生初始配置 ==========
STUDENT_INITIAL_POINTS: int = int(os.getenv("STUDENT_INITIAL_POINTS", "60"))
def validate(self) -> None:
"""验证必要配置是否存在"""
required_configs = [
("SECRET_KEY", self.SECRET_KEY),
("JWT_SECRET_KEY", self.JWT_SECRET_KEY),
("PASSWORD_SALT", self.PASSWORD_SALT),
]
for name, value in required_configs:
if not value:
raise ValueError(f"配置 {name} 不能为空")
settings = Settings()

97
backend/main.py Normal file
View File

@@ -0,0 +1,97 @@
# ===========================================
# 班级操行分管理系统 - FastAPI 主入口
# ===========================================
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
# 版权所有: Copyright (c) 2024 Sea Network Technology Studio
# ===========================================
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import uvicorn
from config import settings
from utils.logger import setup_logger, log_access
from utils.database import init_db_pool, close_db_pool
from utils.redis_client import init_redis_pool, close_redis_pool
from routes import auth, student, parent, admin, subject, debug
# 设置日志
logger = setup_logger()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
logger.info("正在启动应用...")
await init_db_pool()
await init_redis_pool()
logger.info(f"{settings.APP_NAME} 启动完成")
yield
logger.info("正在关闭应用...")
await close_db_pool()
await close_redis_pool()
logger.info("应用已关闭")
# 创建FastAPI应用
app = FastAPI(
title=settings.APP_NAME,
version=settings.API_VERSION,
debug=settings.DEBUG,
lifespan=lifespan
)
# 访问日志中间件
@app.middleware("http")
async def access_log_middleware(request: Request, call_next):
log_access(request)
response = await call_next(request)
return response
# CORS中间件
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(auth.router, prefix="/api/auth", tags=["认证"])
app.include_router(student.router, prefix="/api/student", tags=["学生端"])
app.include_router(parent.router, prefix="/api/parent", tags=["家长端"])
app.include_router(admin.router, prefix="/api/admin", tags=["管理端"])
app.include_router(subject.router, prefix="/api/subject", tags=["科目管理"])
app.include_router(debug.router, tags=["调试"])
@app.get("/")
async def root():
"""根路径健康检查"""
return {"status": "ok", "message": f"{settings.APP_NAME} API 运行中"}
@app.get("/health")
async def health_check():
"""健康检查接口"""
return {"status": "healthy"}
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG
)

View File

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

View File

@@ -0,0 +1,111 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from fastapi import Request, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from starlette.middleware.base import BaseHTTPMiddleware
from typing import Optional, Dict, Any, Tuple
import re
from utils.jwt_handler import jwt_handler
from utils.redis_client import RedisClient
from utils.response import unauthorized_response
from utils.logger import get_logger
logger = get_logger(__name__)
# 不需要认证的路由
PUBLIC_PATHS = [
r'^/$',
r'^/health$',
r'^/api/auth/login$',
r'^/api/auth/logout$',
r'^/debug/.*$', # 调试入口
]
# 不需要Token验证但需要记录访问的路由
OPEN_PATHS = [
r'^/api/auth/change-password$',
]
def is_public_path(path: str) -> bool:
"""检查是否为公开路径"""
for pattern in PUBLIC_PATHS:
if re.match(pattern, path):
return True
return False
class AuthMiddleware(BaseHTTPMiddleware):
"""JWT认证中间件"""
async def dispatch(self, request: Request, call_next):
path = request.url.path
# 公开路径跳过认证
if is_public_path(path):
return await call_next(request)
# 获取Authorization头
auth_header = request.headers.get("Authorization")
if not auth_header:
return unauthorized_response("缺少认证令牌")
# 解析Bearer Token
try:
scheme, token = auth_header.split()
if scheme.lower() != "bearer":
return unauthorized_response("认证格式错误")
except ValueError:
return unauthorized_response("认证格式错误")
# 验证Token
payload = jwt_handler.verify_token(token)
if not payload:
return unauthorized_response("令牌无效或已过期")
# 验证Redis中的Token
user_id = payload.get("user_id")
stored_token = await RedisClient.get_user_token(user_id)
if not stored_token or stored_token != token:
return unauthorized_response("令牌已失效,请重新登录")
# 将用户信息存储到request.state
request.state.user_id = payload.get("user_id")
request.state.username = payload.get("username")
request.state.user_type = payload.get("user_type")
request.state.student_id = payload.get("student_id")
request.state.role = payload.get("role")
# 刷新Token过期时间
from config import settings
await RedisClient.expire(f"user_token:{user_id}", settings.JWT_EXPIRE_MINUTES * 60)
return await call_next(request)
async def get_current_user(request: Request) -> Dict[str, Any]:
"""获取当前登录用户信息"""
return {
"user_id": request.state.user_id,
"username": request.state.username,
"user_type": request.state.user_type,
"student_id": request.state.student_id,
"role": request.state.role
}
async def get_current_user_id(request: Request) -> int:
"""获取当前用户ID"""
return request.state.user_id

View File

@@ -0,0 +1,197 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from fastapi import Request, HTTPException
from typing import List, Optional, Callable
from functools import wraps
from utils.response import forbidden_response
from utils.database import execute_one
from utils.logger import get_logger
logger = get_logger(__name__)
class PermissionChecker:
"""权限检查器"""
@staticmethod
async def get_user_role(user_id: int) -> Optional[str]:
"""获取用户的管理员角色"""
sql = """
SELECT role_type FROM admin_roles
WHERE user_id = %s
LIMIT 1
"""
result = await execute_one(sql, (user_id,))
return result["role_type"] if result else None
@staticmethod
async def get_user_class_id(user_id: int) -> Optional[int]:
"""获取用户管理的班级ID"""
sql = """
SELECT class_id FROM admin_roles
WHERE user_id = %s
LIMIT 1
"""
result = await execute_one(sql, (user_id,))
return result["class_id"] if result else None
@staticmethod
async def get_user_subject_ids(user_id: int) -> List[int]:
"""获取科代表管理的科目ID列表"""
sql = """
SELECT subject_id FROM admin_roles
WHERE user_id = %s AND role_type = '科代表'
"""
results = await execute_one(sql, (user_id,))
if results:
return [r["subject_id"] for r in results] if isinstance(results, list) else [results["subject_id"]]
return []
@staticmethod
async def check_is_teacher(user_id: int) -> bool:
"""检查是否为班主任"""
role = await PermissionChecker.get_user_role(user_id)
return role == "班主任"
@staticmethod
async def check_is_monitor(user_id: int) -> bool:
"""检查是否为班长"""
role = await PermissionChecker.get_user_role(user_id)
return role == "班长"
@staticmethod
async def check_is_subject_rep(user_id: int, subject_id: int = None) -> bool:
"""检查是否为科代表"""
role = await PermissionChecker.get_user_role(user_id)
if role != "科代表":
return False
if subject_id:
subject_ids = await PermissionChecker.get_user_subject_ids(user_id)
return subject_id in subject_ids
return True
@staticmethod
async def check_is_attendance_rep(user_id: int) -> bool:
"""检查是否为考勤委员"""
role = await PermissionChecker.get_user_role(user_id)
return role == "考勤委员"
@staticmethod
async def check_is_labor_rep(user_id: int) -> bool:
"""检查是否为劳动委员"""
role = await PermissionChecker.get_user_role(user_id)
return role == "劳动委员"
@staticmethod
async def check_can_revoke(user_id: int, record_id: int) -> bool:
"""
检查是否可以撤销扣分记录
班主任:可以撤销任何记录
班长:可以撤销任何记录
其他:只能撤销自己的记录
"""
# 获取记录信息
sql = "SELECT recorder_id FROM conduct_records WHERE record_id = %s"
record = await execute_one(sql, (record_id,))
if not record:
return False
role = await PermissionChecker.get_user_role(user_id)
# 班主任或班长可以撤销任何记录
if role in ["班主任", "班长"]:
return True
# 其他人只能撤销自己的记录
return record["recorder_id"] == user_id
@staticmethod
async def check_can_manage_student(user_id: int, student_id: int) -> bool:
"""检查是否可以管理该学生(同班级)"""
# 获取学生班级
sql = "SELECT class_id FROM students WHERE student_id = %s"
student = await execute_one(sql, (student_id,))
if not student:
return False
# 获取管理员管理的班级
admin_class_id = await PermissionChecker.get_user_class_id(user_id)
return admin_class_id == student["class_id"]
def require_auth(func: Callable):
"""需要认证的装饰器"""
@wraps(func)
async def wrapper(*args, **kwargs):
request = kwargs.get('request')
if not request or not hasattr(request, 'state') or not hasattr(request.state, 'user_id'):
return forbidden_response("请先登录")
return await func(*args, **kwargs)
return wrapper
def require_role(roles: List[str]):
"""需要特定角色的装饰器"""
def decorator(func: Callable):
@wraps(func)
async def wrapper(*args, **kwargs):
request = kwargs.get('request')
if not request or not hasattr(request, 'state'):
return forbidden_response("请先登录")
user_id = request.state.user_id
user_role = await PermissionChecker.get_user_role(user_id)
if user_role not in roles:
return forbidden_response(f"需要{','.join(roles)}权限")
return await func(*args, **kwargs)
return wrapper
return decorator
def require_teacher(func: Callable):
"""需要班主任权限的装饰器"""
@wraps(func)
async def wrapper(*args, **kwargs):
request = kwargs.get('request')
if not request or not hasattr(request, 'state'):
return forbidden_response("请先登录")
user_id = request.state.user_id
is_teacher = await PermissionChecker.check_is_teacher(user_id)
if not is_teacher:
return forbidden_response("需要班主任权限")
return await func(*args, **kwargs)
return wrapper
def require_monitor(func: Callable):
"""需要班长权限的装饰器"""
@wraps(func)
async def wrapper(*args, **kwargs):
request = kwargs.get('request')
if not request or not hasattr(request, 'state'):
return forbidden_response("请先登录")
user_id = request.state.user_id
is_monitor = await PermissionChecker.check_is_monitor(user_id)
if not is_monitor:
return forbidden_response("需要班长权限")
return await func(*args, **kwargs)
return wrapper

View File

@@ -0,0 +1,121 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from typing import Dict, Any
import re
class SanitizeMiddleware(BaseHTTPMiddleware):
"""输入过滤中间件"""
async def dispatch(self, request: Request, call_next):
# 只处理POST、PUT、PATCH请求
if request.method in ["POST", "PUT", "PATCH"]:
# 获取请求体
body = await request.body()
if body:
import json
try:
data = json.loads(body)
# 清理数据
cleaned_data = self._sanitize_data(data)
# 替换请求体
request._body = json.dumps(cleaned_data).encode()
except:
pass
response = await call_next(request)
return response
def _sanitize_data(self, data: Any) -> Any:
"""递归清理数据"""
if isinstance(data, dict):
return {k: self._sanitize_data(v) for k, v in data.items()}
elif isinstance(data, list):
return [self._sanitize_data(item) for item in data]
elif isinstance(data, str):
return self._sanitize_string(data)
else:
return data
def _sanitize_string(self, value: str) -> str:
"""清理字符串"""
if not value:
return ""
# 去除首尾空格
value = value.strip()
# 限制长度
if len(value) > 1000:
value = value[:1000]
# 转义HTML特殊字符
html_chars = {
'&': '&',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;'
}
for char, escape in html_chars.items():
value = value.replace(char, escape)
return value
def sanitize_input(value: str, max_length: int = 255) -> str:
"""清理单个输入值"""
if not value:
return ""
value = value.strip()
if len(value) > max_length:
value = value[:max_length]
return value
def validate_points(points: int, min_val: int = -100, max_val: int = 100) -> tuple:
"""
验证分值
返回: (是否有效, 错误信息)
"""
if points == 0:
return False, "分值不能为0"
if points < min_val or points > max_val:
return False, f"分值必须在{min_val}{max_val}之间"
return True, ""
def validate_reason(reason: str) -> tuple:
"""
验证原因
返回: (是否有效, 错误信息)
"""
if not reason or not reason.strip():
return False, "原因不能为空"
if len(reason) > 255:
return False, "原因长度不能超过255个字符"
return True, ""
def validate_date(date_str: str) -> bool:
"""验证日期格式 YYYY-MM-DD"""
if not date_str:
return False
pattern = r'^\d{4}-\d{2}-\d{2}$'
if not re.match(pattern, date_str):
return False
return True

View File

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

View File

@@ -0,0 +1,60 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Optional, Dict, Any, List
from utils.database import execute_one, execute_query, execute_insert, execute_update
class AdminRoleModel:
"""管理员角色数据模型"""
@staticmethod
async def get_by_user_id(user_id: int) -> Optional[Dict[str, Any]]:
sql = """
SELECT ar.*, c.class_name, s.subject_name
FROM admin_roles ar
LEFT JOIN classes c ON ar.class_id = c.class_id
LEFT JOIN subjects s ON ar.subject_id = s.subject_id
WHERE ar.user_id = %s
LIMIT 1
"""
return await execute_one(sql, (user_id,))
@staticmethod
async def get_class_id_by_user(user_id: int) -> Optional[int]:
sql = "SELECT class_id FROM admin_roles WHERE user_id = %s LIMIT 1"
result = await execute_one(sql, (user_id,))
return result["class_id"] if result else None
@staticmethod
async def get_by_class(class_id: int) -> List[Dict[str, Any]]:
sql = """
SELECT ar.*, u.real_name, u.username
FROM admin_roles ar
JOIN users u ON ar.user_id = u.user_id
WHERE ar.class_id = %s
ORDER BY ar.role_type
"""
return await execute_query(sql, (class_id,))
@staticmethod
async def create(user_id: int, role_type: str, class_id: int, subject_id: int = None) -> int:
sql = """
INSERT INTO admin_roles (user_id, role_type, class_id, subject_id)
VALUES (%s, %s, %s, %s)
"""
return await execute_insert(sql, (user_id, role_type, class_id, subject_id))
@staticmethod
async def delete(user_id: int) -> bool:
sql = "DELETE FROM admin_roles WHERE user_id = %s"
result = await execute_update(sql, (user_id,))
return result > 0

View File

@@ -0,0 +1,98 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Optional, Dict, Any, List
from datetime import datetime
from utils.database import execute_one, execute_query, execute_insert, execute_update
class AttendanceModel:
"""考勤数据模型"""
@staticmethod
async def get_student_records(student_id: int, month: str = None) -> List[Dict[str, Any]]:
sql = """
SELECT attendance_id, date, status, reason, deduction_applied, created_at
FROM attendance_records
WHERE student_id = %s
"""
params = [student_id]
if month:
sql += " AND DATE_FORMAT(date, '%%Y-%%m') = %s"
params.append(month)
sql += " ORDER BY date DESC"
return await execute_query(sql, tuple(params))
@staticmethod
async def get_class_records(
class_id: int,
date: str = None,
student_id: int = None
) -> List[Dict[str, Any]]:
sql = """
SELECT ar.*, s.name as student_name, s.student_no
FROM attendance_records ar
JOIN students s ON ar.student_id = s.student_id
WHERE s.class_id = %s
"""
params = [class_id]
if date:
sql += " AND ar.date = %s"
params.append(date)
if student_id:
sql += " AND ar.student_id = %s"
params.append(student_id)
sql += " ORDER BY ar.date DESC, s.student_no"
return await execute_query(sql, tuple(params))
@staticmethod
async def create_record(
student_id: int,
date: str,
status: str,
reason: str = None,
recorder_id: int = None
) -> int:
# 检查是否已存在当天记录
existing = await execute_one(
"SELECT attendance_id FROM attendance_records WHERE student_id = %s AND date = %s",
(student_id, date)
)
if existing:
# 更新已有记录
sql = """
UPDATE attendance_records
SET status = %s, reason = %s, recorder_id = %s
WHERE student_id = %s AND date = %s
"""
await execute_update(sql, (status, reason, recorder_id, student_id, date))
return existing["attendance_id"]
else:
# 插入新记录
sql = """
INSERT INTO attendance_records (student_id, date, status, reason, recorder_id)
VALUES (%s, %s, %s, %s, %s)
"""
return await execute_insert(sql, (student_id, date, status, reason, recorder_id))
@staticmethod
async def mark_deduction_applied(attendance_id: int) -> bool:
sql = "UPDATE attendance_records SET deduction_applied = 1 WHERE attendance_id = %s"
result = await execute_update(sql, (attendance_id,))
return result > 0

View File

@@ -0,0 +1,35 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Optional, Dict, Any, List
from utils.database import execute_one, execute_query, execute_insert, execute_update
class ClassModel:
"""班级数据模型"""
@staticmethod
async def get_by_id(class_id: int) -> Optional[Dict[str, Any]]:
sql = "SELECT * FROM classes WHERE class_id = %s"
return await execute_one(sql, (class_id,))
@staticmethod
async def get_all() -> List[Dict[str, Any]]:
sql = "SELECT * FROM classes ORDER BY class_id"
return await execute_query(sql)
@staticmethod
async def create(class_name: str, grade: str = None, academic_year: str = None) -> int:
sql = """
INSERT INTO classes (class_name, grade, academic_year)
VALUES (%s, %s, %s)
"""
return await execute_insert(sql, (class_name, grade, academic_year))

166
backend/models/conduct.py Normal file
View File

@@ -0,0 +1,166 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Optional, List, Dict, Any
from datetime import datetime
from utils.database import execute_one, execute_query, execute_insert, execute_update, call_procedure
from utils.logger import get_logger
logger = get_logger(__name__)
class ConductModel:
"""操行分数据模型"""
@staticmethod
async def create_record(
student_id: int,
points_change: int,
reason: str,
recorder_id: int,
recorder_name: str = None,
related_type: str = 'manual',
related_id: int = None
) -> int:
"""创建操行分记录"""
sql = """
INSERT INTO conduct_records
(student_id, points_change, reason, recorder_id, recorder_name, related_type, related_id)
VALUES (%s, %s, %s, %s, %s, %s, %s)
"""
return await execute_insert(sql, (
student_id, points_change, reason, recorder_id, recorder_name, related_type, related_id
))
@staticmethod
async def get_student_records(
student_id: int,
limit: int = 50,
offset: int = 0,
include_revoked: bool = False
) -> List[Dict[str, Any]]:
"""获取学生操行分记录"""
sql = """
SELECT cr.*, u.real_name as recorder_name
FROM conduct_records cr
LEFT JOIN users u ON cr.recorder_id = u.user_id
WHERE cr.student_id = %s
"""
if not include_revoked:
sql += " AND cr.is_revoked = 0"
sql += " ORDER BY cr.created_at DESC LIMIT %s OFFSET %s"
return await execute_query(sql, (student_id, limit, offset))
@staticmethod
async def get_records_by_recorder(
recorder_id: int,
limit: int = 50,
offset: int = 0
) -> List[Dict[str, Any]]:
"""获取操作人提交的记录"""
sql = """
SELECT cr.*, s.name as student_name
FROM conduct_records cr
JOIN students s ON cr.student_id = s.student_id
WHERE cr.recorder_id = %s AND cr.is_revoked = 0
ORDER BY cr.created_at DESC
LIMIT %s OFFSET %s
"""
return await execute_query(sql, (recorder_id, limit, offset))
@staticmethod
async def get_all_records(
class_id: int = None,
limit: int = 100,
offset: int = 0,
start_date: str = None,
end_date: str = None
) -> List[Dict[str, Any]]:
"""获取所有记录(班主任/班长专用)"""
sql = """
SELECT cr.*, s.name as student_name, s.student_no, u.real_name as recorder_name
FROM conduct_records cr
JOIN students s ON cr.student_id = s.student_id
JOIN users u ON cr.recorder_id = u.user_id
WHERE cr.is_revoked = 0
"""
params = []
if class_id:
sql += " AND s.class_id = %s"
params.append(class_id)
if start_date:
sql += " AND DATE(cr.created_at) >= %s"
params.append(start_date)
if end_date:
sql += " AND DATE(cr.created_at) <= %s"
params.append(end_date)
sql += " ORDER BY cr.created_at DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
return await execute_query(sql, tuple(params))
@staticmethod
async def get_record_by_id(record_id: int) -> Optional[Dict[str, Any]]:
"""根据ID获取记录"""
sql = """
SELECT cr.*, s.name as student_name, s.total_points
FROM conduct_records cr
JOIN students s ON cr.student_id = s.student_id
WHERE cr.record_id = %s
"""
return await execute_one(sql, (record_id,))
@staticmethod
async def revoke_record(record_id: int, revoker_id: int) -> bool:
"""撤销记录"""
try:
await call_procedure('revoke_conduct_record', (record_id, revoker_id))
return True
except Exception as e:
logger.error(f"撤销记录失败: {e}")
return False
@staticmethod
async def batch_create_records(records_data: List[Dict]) -> List[Dict]:
"""批量创建操行分记录"""
results = []
for record in records_data:
try:
record_id = await ConductModel.create_record(
student_id=record.get('student_id'),
points_change=record.get('points_change'),
reason=record.get('reason'),
recorder_id=record.get('recorder_id'),
recorder_name=record.get('recorder_name')
)
results.append({
'student_id': record.get('student_id'),
'success': True,
'record_id': record_id
})
except Exception as e:
results.append({
'student_id': record.get('student_id'),
'success': False,
'error': str(e)
})
return results
@staticmethod
async def get_student_total_points(student_id: int) -> int:
"""获取学生当前总分"""
sql = "SELECT total_points FROM students WHERE student_id = %s"
result = await execute_one(sql, (student_id,))
return result['total_points'] if result else 100

118
backend/models/homework.py Normal file
View File

@@ -0,0 +1,118 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Optional, Dict, Any, List
from utils.database import execute_one, execute_query, execute_insert, execute_update
class HomeworkModel:
"""作业数据模型"""
@staticmethod
async def get_assignments_by_class(class_id: int) -> List[Dict[str, Any]]:
sql = """
SELECT a.*, s.subject_name, u.real_name as created_by_name
FROM assignments a
JOIN subjects s ON a.subject_id = s.subject_id
JOIN users u ON a.created_by = u.user_id
WHERE a.class_id = %s
ORDER BY a.deadline ASC, a.created_at DESC
"""
return await execute_query(sql, (class_id,))
@staticmethod
async def get_assignments_by_subjects(class_id: int, subject_ids: List[int]) -> List[Dict[str, Any]]:
if not subject_ids:
return []
placeholders = ','.join(['%s'] * len(subject_ids))
sql = f"""
SELECT a.*, s.subject_name, u.real_name as created_by_name
FROM assignments a
JOIN subjects s ON a.subject_id = s.subject_id
JOIN users u ON a.created_by = u.user_id
WHERE a.class_id = %s AND a.subject_id IN ({placeholders})
ORDER BY a.deadline ASC, a.created_at DESC
"""
params = [class_id] + subject_ids
return await execute_query(sql, tuple(params))
@staticmethod
async def get_student_homework(student_id: int) -> List[Dict[str, Any]]:
sql = """
SELECT a.assignment_id, a.title, a.description, a.deadline,
s.subject_name, hs.status, hs.submit_time, hs.comments, hs.deduction_applied
FROM assignments a
JOIN subjects s ON a.subject_id = s.subject_id
LEFT JOIN homework_submissions hs ON a.assignment_id = hs.assignment_id AND hs.student_id = %s
WHERE a.class_id = (SELECT class_id FROM students WHERE student_id = %s)
ORDER BY a.deadline ASC, a.created_at DESC
"""
return await execute_query(sql, (student_id, student_id))
@staticmethod
async def get_submission(submission_id: int) -> Optional[Dict[str, Any]]:
sql = """
SELECT hs.*, a.title, a.subject_id, a.assignment_id, s.name as student_name
FROM homework_submissions hs
JOIN assignments a ON hs.assignment_id = a.assignment_id
JOIN students s ON hs.student_id = s.student_id
WHERE hs.submission_id = %s
"""
return await execute_one(sql, (submission_id,))
@staticmethod
async def create_assignment(
class_id: int,
subject_id: int,
title: str,
description: str,
deadline: str,
created_by: int
) -> int:
sql = """
INSERT INTO assignments (class_id, subject_id, title, description, deadline, created_by)
VALUES (%s, %s, %s, %s, %s, %s)
"""
assignment_id = await execute_insert(sql, (class_id, subject_id, title, description, deadline, created_by))
# 为班级所有学生创建提交记录
from models.student import StudentModel
students = await StudentModel.get_by_class(class_id)
for student in students:
sql_sub = """
INSERT INTO homework_submissions (assignment_id, student_id, status)
VALUES (%s, %s, 'not_submitted')
"""
await execute_insert(sql_sub, (assignment_id, student["student_id"]))
return assignment_id
@staticmethod
async def update_submission(
submission_id: int,
status: str,
comments: str = None,
updated_by: int = None
) -> bool:
sql = """
UPDATE homework_submissions
SET status = %s, comments = %s, updated_by = %s, updated_at = NOW()
WHERE submission_id = %s
"""
result = await execute_update(sql, (status, comments, updated_by, submission_id))
return result > 0
@staticmethod
async def mark_deduction_applied(submission_id: int) -> bool:
sql = "UPDATE homework_submissions SET deduction_applied = 1 WHERE submission_id = %s"
result = await execute_update(sql, (submission_id,))
return result > 0

146
backend/models/student.py Normal file
View File

@@ -0,0 +1,146 @@
# ===========================================
# 班级操行分管理系统 - 学生数据模型
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Optional, List, Dict, Any
from utils.database import execute_one, execute_query, execute_insert, execute_update, execute_many
from utils.security import security
from utils.logger import get_logger
logger = get_logger(__name__)
class StudentModel:
"""学生数据模型"""
@staticmethod
async def get_by_id(student_id: int) -> Optional[Dict[str, Any]]:
"""根据ID获取学生信息"""
sql = """
SELECT s.*
FROM students s
WHERE s.student_id = %s
"""
return await execute_one(sql, (student_id,))
@staticmethod
async def get_by_student_no(student_no: str) -> Optional[Dict[str, Any]]:
"""根据学号获取学生信息"""
sql = """
SELECT s.*
FROM students s
WHERE s.student_no = %s
"""
return await execute_one(sql, (student_no,))
@staticmethod
async def get_all(include_disabled: bool = False) -> List[Dict[str, Any]]:
"""获取所有学生列表(单班级)"""
sql = """
SELECT student_id, student_no, name, total_points, parent_phone, status
FROM students
WHERE 1=1
"""
if not include_disabled:
sql += " AND status = 1"
sql += " ORDER BY student_no"
return await execute_query(sql)
@staticmethod
async def create(
student_no: str,
name: str,
class_id: int,
parent_phone: str = None,
initial_points: int = 60
) -> int:
"""创建学生初始操行分默认60分"""
sql = """
INSERT INTO students (student_no, name, class_id, parent_phone, total_points)
VALUES (%s, %s, %s, %s, %s)
"""
return await execute_insert(sql, (student_no, name, class_id, parent_phone, initial_points))
@staticmethod
async def update(student_id: int, name: str = None, parent_phone: str = None, status: int = None) -> bool:
"""更新学生信息"""
updates = []
params = []
if name is not None:
updates.append("name = %s")
params.append(name)
if parent_phone is not None:
updates.append("parent_phone = %s")
params.append(parent_phone)
if status is not None:
updates.append("status = %s")
params.append(status)
if not updates:
return True
params.append(student_id)
sql = f"UPDATE students SET {', '.join(updates)} WHERE student_id = %s"
result = await execute_update(sql, tuple(params))
return result > 0
@staticmethod
async def delete(student_id: int) -> bool:
"""删除学生(软删除)"""
sql = "UPDATE students SET status = 0 WHERE student_id = %s"
result = await execute_update(sql, (student_id,))
return result > 0
@staticmethod
async def update_total_points(student_id: int, points_change: int) -> bool:
"""更新学生总分"""
sql = "UPDATE students SET total_points = total_points + %s WHERE student_id = %s"
result = await execute_update(sql, (points_change, student_id))
return result > 0
@staticmethod
async def get_ranking(limit: int = 50) -> List[Dict[str, Any]]:
"""获取学生排行(单班级)"""
sql = """
SELECT student_id, student_no, name, total_points,
RANK() OVER (ORDER BY total_points DESC) as rank
FROM students
WHERE status = 1
ORDER BY total_points DESC
LIMIT %s
"""
return await execute_query(sql, (limit,))
@staticmethod
async def batch_create(students_data: List[Dict], initial_points: int = 60) -> List[Dict]:
"""批量创建学生"""
results = []
for student in students_data:
try:
student_id = await StudentModel.create(
student_no=student.get('student_no'),
name=student.get('name'),
class_id=1, # 单班级固定为1
parent_phone=student.get('parent_phone'),
initial_points=initial_points
)
results.append({
'student_no': student.get('student_no'),
'success': True,
'student_id': student_id
})
except Exception as e:
results.append({
'student_no': student.get('student_no'),
'success': False,
'error': str(e)
})
return results

82
backend/models/subject.py Normal file
View File

@@ -0,0 +1,82 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Optional, Dict, Any, List
from utils.database import execute_one, execute_query, execute_insert, execute_update
class SubjectModel:
"""科目数据模型"""
@staticmethod
async def get_all(is_active: bool = None) -> List[Dict[str, Any]]:
if is_active is not None:
sql = "SELECT * FROM subjects WHERE is_active = %s ORDER BY sort_order, subject_id"
return await execute_query(sql, (1 if is_active else 0,))
else:
sql = "SELECT * FROM subjects ORDER BY sort_order, subject_id"
return await execute_query(sql)
@staticmethod
async def get_by_id(subject_id: int) -> Optional[Dict[str, Any]]:
sql = "SELECT * FROM subjects WHERE subject_id = %s"
return await execute_one(sql, (subject_id,))
@staticmethod
async def get_by_name(subject_name: str) -> Optional[Dict[str, Any]]:
sql = "SELECT * FROM subjects WHERE subject_name = %s"
return await execute_one(sql, (subject_name,))
@staticmethod
async def create(subject_name: str, subject_code: str = None, sort_order: int = 0) -> int:
sql = """
INSERT INTO subjects (subject_name, subject_code, sort_order)
VALUES (%s, %s, %s)
"""
return await execute_insert(sql, (subject_name, subject_code, sort_order))
@staticmethod
async def update(subject_id: int, **kwargs) -> bool:
updates = []
params = []
if "subject_name" in kwargs:
updates.append("subject_name = %s")
params.append(kwargs["subject_name"])
if "subject_code" in kwargs:
updates.append("subject_code = %s")
params.append(kwargs["subject_code"])
if "is_active" in kwargs:
updates.append("is_active = %s")
params.append(1 if kwargs["is_active"] else 0)
if "sort_order" in kwargs:
updates.append("sort_order = %s")
params.append(kwargs["sort_order"])
if not updates:
return True
params.append(subject_id)
sql = f"UPDATE subjects SET {', '.join(updates)} WHERE subject_id = %s"
result = await execute_update(sql, tuple(params))
return result > 0
@staticmethod
async def delete(subject_id: int) -> bool:
sql = "UPDATE subjects SET is_active = 0 WHERE subject_id = %s"
result = await execute_update(sql, (subject_id,))
return result > 0
@staticmethod
async def activate(subject_id: int) -> bool:
sql = "UPDATE subjects SET is_active = 1 WHERE subject_id = %s"
result = await execute_update(sql, (subject_id,))
return result > 0

101
backend/models/user.py Normal file
View File

@@ -0,0 +1,101 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from utils.database import execute_one, execute_insert, execute_update
from utils.security import security
from utils.logger import get_logger
logger = get_logger(__name__)
class UserModel:
"""用户数据模型"""
@staticmethod
async def get_by_username(username: str) -> dict:
"""根据用户名获取用户"""
sql = """
SELECT user_id, username, password_hash, real_name, user_type,
student_id, status, need_change_password, last_login_time, last_login_ip
FROM users
WHERE username = %s AND status = 1
"""
return await execute_one(sql, (username,))
@staticmethod
async def get_by_user_id(user_id: int) -> dict:
"""根据用户ID获取用户"""
sql = """
SELECT user_id, username, real_name, user_type, student_id,
need_change_password, status
FROM users
WHERE user_id = %s
"""
return await execute_one(sql, (user_id,))
@staticmethod
async def create_student(username: str, password: str, real_name: str, student_id: int) -> int:
"""创建学生账号"""
password_hash = security.sha1_md5_password(password)
sql = """
INSERT INTO users (username, password_hash, real_name, user_type, student_id, need_change_password)
VALUES (%s, %s, %s, 'student', %s, 1)
"""
return await execute_insert(sql, (username, password_hash, real_name, student_id))
@staticmethod
async def create_parent(username: str, password: str, real_name: str, student_id: int) -> int:
"""创建家长账号"""
password_hash = security.sha1_md5_password(password)
sql = """
INSERT INTO users (username, password_hash, real_name, user_type, student_id, need_change_password)
VALUES (%s, %s, %s, 'parent', %s, 0)
"""
return await execute_insert(sql, (username, password_hash, real_name, student_id))
@staticmethod
async def create_admin(username: str, password: str, real_name: str) -> int:
"""创建管理员账号"""
password_hash = security.sha1_md5_password(password)
sql = """
INSERT INTO users (username, password_hash, real_name, user_type, need_change_password)
VALUES (%s, %s, %s, 'admin', 1)
"""
return await execute_insert(sql, (username, password_hash, real_name))
@staticmethod
async def update_password(user_id: int, new_password: str) -> bool:
"""更新密码"""
password_hash = security.sha1_md5_password(new_password)
sql = """
UPDATE users
SET password_hash = %s, need_change_password = 0
WHERE user_id = %s
"""
result = await execute_update(sql, (password_hash, user_id))
return result > 0
@staticmethod
async def update_last_login(user_id: int, ip: str) -> None:
"""更新最后登录信息"""
sql = """
UPDATE users
SET last_login_time = NOW(), last_login_ip = %s
WHERE user_id = %s
"""
await execute_update(sql, (ip, user_id))
@staticmethod
async def check_username_exists(username: str) -> bool:
"""检查用户名是否存在"""
sql = "SELECT 1 FROM users WHERE username = %s"
result = await execute_one(sql, (username,))
return result is not None

11
backend/requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
python-dotenv==1.0.0
aiomysql==0.2.0
redis==5.0.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
pydantic==2.5.0
pydantic-settings==2.1.0
python-multipart==0.0.6
loguru==0.7.2

View File

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

64
backend/routes/admin.py Normal file
View File

@@ -0,0 +1,64 @@
# ===========================================
# 班级操行分管理系统 - 管理端路由
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
# 在 admin.py 中修改导入接口
@router.post("/students/import")
async def import_students(
request: Request,
file: UploadFile = File(...)
):
"""
批量导入学生JSON格式
初始操行分默认为60分
"""
user = await get_current_user(request)
# 检查权限(仅班主任)
is_teacher = await PermissionChecker.check_is_teacher(user["user_id"])
if not is_teacher:
return error_response(message="仅班主任可导入学生", code=403)
# 检查文件大小
file_size = 0
content = await file.read()
file_size = len(content)
if file_size > settings.MAX_UPLOAD_SIZE:
return error_response(message=f"文件大小不能超过{settings.MAX_UPLOAD_SIZE // 1024 // 1024}MB")
# 检查文件扩展名
filename = file.filename or ""
extension = filename.split('.')[-1].lower() if '.' in filename else ''
if extension not in settings.ALLOWED_EXTENSIONS:
return error_response(message=f"不支持的文件类型,仅支持 {', '.join(settings.ALLOWED_EXTENSIONS)}")
# 解析JSON
try:
import json
data = json.loads(content.decode('utf-8'))
students = data.get("students", [])
except json.JSONDecodeError as e:
return error_response(message=f"JSON格式错误: {str(e)}")
except UnicodeDecodeError:
return error_response(message="文件编码错误请使用UTF-8编码")
if not students:
return error_response(message="文件中没有学生数据")
# 导入学生初始操行分60分
result = await AdminService.import_students(
students=students,
operator_id=user["user_id"],
initial_points=60
)
return success_response(data=result, message=f"导入完成: 成功{result['success_count']}人,失败{result['failed_count']}")

100
backend/routes/auth.py Normal file
View File

@@ -0,0 +1,100 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from fastapi import APIRouter, Request, HTTPException
from typing import Dict, Any
from schemas.auth import LoginRequest, ChangePasswordRequest
from services.auth_service import AuthService
from middleware.permission import get_current_user
from utils.response import success_response, error_response, unauthorized_response
from utils.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
@router.post("/login")
async def login(request: LoginRequest, http_request: Request):
"""
用户登录
"""
# 获取客户端IP
client_ip = http_request.client.host
result = await AuthService.login(
username=request.username,
password=request.password,
ip=client_ip
)
if result["success"]:
return success_response(
data={
"token": result["token"],
"user_id": result["user_id"],
"username": result["username"],
"real_name": result["real_name"],
"user_type": result["user_type"],
"need_change_password": result["need_change_password"],
"redirect": result["redirect"]
},
message="登录成功"
)
else:
return error_response(message=result["message"], code=401)
@router.post("/logout")
async def logout(request: Request):
"""
用户登出
"""
user = await get_current_user(request)
result = await AuthService.logout(user["user_id"])
if result["success"]:
return success_response(message="登出成功")
else:
return error_response(message=result["message"])
@router.post("/change-password")
async def change_password(request: Request, req: ChangePasswordRequest):
"""
修改密码
"""
user = await get_current_user(request)
result = await AuthService.change_password(
user_id=user["user_id"],
old_password=req.old_password,
new_password=req.new_password
)
if result["success"]:
return success_response(message="密码修改成功,请重新登录")
else:
return error_response(message=result["message"])
@router.get("/me")
async def get_current_user_info(request: Request):
"""
获取当前用户信息
"""
user = await get_current_user(request)
# 获取用户详细信息
from services.auth_service import AuthService
user_info = await AuthService.get_user_info(user["user_id"])
return success_response(data=user_info)

69
backend/routes/debug.py Normal file
View File

@@ -0,0 +1,69 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from fastapi import APIRouter, Request, HTTPException
from pydantic import BaseModel
from typing import Optional
from config import settings
from services.admin_service import AdminService
from utils.response import success_response, error_response
from utils.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
class AddAdminDebugRequest(BaseModel):
"""添加管理员请求"""
username: str
password: str
real_name: str
role_type: str # 班主任/班长/科代表/考勤委员/劳动委员
class_id: int
subject_id: Optional[int] = None
@router.post(settings.DEBUG_PATH)
async def debug_add_admin(request: Request, req: AddAdminDebugRequest):
"""
调试入口 - 添加第一批管理员
注意:此接口仅用于首次部署,使用后建议注释掉此路由
"""
# 检查是否已存在管理员
from models.user import UserModel
existing = await UserModel.get_by_username(req.username)
if existing:
return error_response(message="用户名已存在")
# 创建管理员账号
result = await AdminService.add_admin(
username=req.username,
real_name=req.real_name,
password=req.password,
role_type=req.role_type,
class_id=req.class_id,
subject_id=req.subject_id,
operator_id=0 # 系统添加
)
if result["success"]:
logger.info(f"调试入口创建管理员: {req.username} ({req.role_type})")
return success_response(
data={
"username": req.username,
"password": req.password,
"role_type": req.role_type
},
message=f"管理员 {req.username} 创建成功"
)
else:
return error_response(message=result["message"])

66
backend/routes/parent.py Normal file
View File

@@ -0,0 +1,66 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from fastapi import APIRouter, Request, Query
from typing import Optional
from middleware.permission import get_current_user
from services.parent_service import ParentService
from utils.response import success_response, error_response
from utils.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
@router.get("/child/conduct")
async def get_child_conduct(request: Request):
"""
获取子女操行分(仅总分)
"""
user = await get_current_user(request)
if user["user_type"] != "parent":
return error_response(message="仅限家长访问", code=403)
result = await ParentService.get_child_conduct(user["user_id"])
return success_response(data=result)
@router.get("/child/homework")
async def get_child_homework(request: Request):
"""
获取子女作业情况
"""
user = await get_current_user(request)
if user["user_type"] != "parent":
return error_response(message="仅限家长访问", code=403)
result = await ParentService.get_child_homework(user["user_id"])
return success_response(data=result)
@router.get("/child/attendance")
async def get_child_attendance(request: Request):
"""
获取子女考勤记录
"""
user = await get_current_user(request)
if user["user_type"] != "parent":
return error_response(message="仅限家长访问", code=403)
result = await ParentService.get_child_attendance(user["user_id"])
return success_response(data=result)

120
backend/routes/student.py Normal file
View File

@@ -0,0 +1,120 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from fastapi import APIRouter, Request, Query
from typing import Optional
from middleware.permission import get_current_user
from services.student_service import StudentService
from utils.response import success_response, error_response
from utils.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
@router.get("/conduct/{student_id}")
async def get_conduct_history(
request: Request,
student_id: int,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0)
):
"""
获取学生操行分历史
"""
user = await get_current_user(request)
# 权限检查:只能查看自己的信息(学生)或同班(管理员)
if user["user_type"] == "student" and user["student_id"] != student_id:
return error_response(message="无权查看其他学生信息", code=403)
result = await StudentService.get_conduct_history(
student_id=student_id,
limit=limit,
offset=offset
)
return success_response(data=result)
@router.get("/homework/{student_id}")
async def get_homework_status(request: Request, student_id: int):
"""
获取学生作业情况
"""
user = await get_current_user(request)
# 权限检查
if user["user_type"] == "student" and user["student_id"] != student_id:
return error_response(message="无权查看其他学生信息", code=403)
result = await StudentService.get_homework_status(student_id)
return success_response(data=result)
@router.get("/attendance/{student_id}")
async def get_attendance_records(
request: Request,
student_id: int,
month: Optional[str] = None
):
"""
获取学生考勤记录
"""
user = await get_current_user(request)
# 权限检查
if user["user_type"] == "student" and user["student_id"] != student_id:
return error_response(message="无权查看其他学生信息", code=403)
result = await StudentService.get_attendance_records(
student_id=student_id,
month=month
)
return success_response(data=result)
@router.get("/ranking")
async def get_ranking(
request: Request,
class_id: Optional[int] = None,
limit: int = Query(50, ge=1, le=100)
):
"""
获取操行分排行榜
"""
user = await get_current_user(request)
result = await StudentService.get_ranking(
user_id=user["user_id"],
class_id=class_id,
limit=limit
)
return success_response(data=result)
@router.get("/my-info")
async def get_my_info(request: Request):
"""
获取当前学生个人信息
"""
user = await get_current_user(request)
if user["user_type"] != "student":
return error_response(message="仅限学生访问", code=403)
result = await StudentService.get_student_info(user["student_id"])
return success_response(data=result)

105
backend/routes/subject.py Normal file
View File

@@ -0,0 +1,105 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from fastapi import APIRouter, Request, Query
from typing import Optional
from middleware.permission import get_current_user, PermissionChecker
from services.subject_service import SubjectService
from schemas.subject import CreateSubjectRequest, UpdateSubjectRequest
from utils.response import success_response, error_response
from utils.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
@router.get("/list")
async def get_subjects(
request: Request,
is_active: Optional[bool] = None
):
"""
获取科目列表
"""
user = await get_current_user(request)
result = await SubjectService.get_subjects(is_active=is_active)
return success_response(data=result)
@router.post("/create")
async def create_subject(request: Request, req: CreateSubjectRequest):
"""
创建科目(班主任)
"""
user = await get_current_user(request)
is_teacher = await PermissionChecker.check_is_teacher(user["user_id"])
if not is_teacher:
return error_response(message="仅班主任可创建科目", code=403)
result = await SubjectService.create_subject(
subject_name=req.subject_name,
subject_code=req.subject_code,
sort_order=req.sort_order
)
if result["success"]:
return success_response(data=result, message="科目创建成功")
else:
return error_response(message=result["message"])
@router.put("/update/{subject_id}")
async def update_subject(
request: Request,
subject_id: int,
req: UpdateSubjectRequest
):
"""
更新科目(班主任)
"""
user = await get_current_user(request)
is_teacher = await PermissionChecker.check_is_teacher(user["user_id"])
if not is_teacher:
return error_response(message="仅班主任可更新科目", code=403)
result = await SubjectService.update_subject(
subject_id=subject_id,
**req.dict(exclude_none=True)
)
if result["success"]:
return success_response(message="科目更新成功")
else:
return error_response(message=result["message"])
@router.delete("/delete/{subject_id}")
async def delete_subject(request: Request, subject_id: int):
"""
删除科目(软删除,班主任)
"""
user = await get_current_user(request)
is_teacher = await PermissionChecker.check_is_teacher(user["user_id"])
if not is_teacher:
return error_response(message="仅班主任可删除科目", code=403)
result = await SubjectService.delete_subject(subject_id)
if result["success"]:
return success_response(message="科目已禁用")
else:
return error_response(message=result["message"])

View File

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

81
backend/schemas/admin.py Normal file
View File

@@ -0,0 +1,81 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import date, datetime
class AddPointsRequest(BaseModel):
"""加减分请求"""
student_ids: List[int] = Field(..., description="学生ID列表")
points_change: int = Field(..., description="分数变动")
reason: str = Field(..., min_length=1, max_length=255, description="原因")
class AddPointsResponse(BaseModel):
"""加减分响应"""
success_count: int
fail_count: int
details: List[dict]
class RevokeRequest(BaseModel):
"""撤销请求"""
record_id: int = Field(..., description="记录ID")
class ImportStudentsRequest(BaseModel):
"""导入学生请求"""
students: List[dict] = Field(..., description="学生列表")
class ImportResult(BaseModel):
"""导入结果"""
total: int
success: int
failed: int
errors: List[str]
class AddAdminRequest(BaseModel):
"""添加管理员请求"""
username: str = Field(..., min_length=1, max_length=50, description="登录账号")
real_name: str = Field(..., min_length=1, max_length=50, description="真实姓名")
password: Optional[str] = Field(None, description="密码(不填则自动生成)")
role_type: str = Field(..., description="角色类型")
class_id: int = Field(..., description="班级ID")
subject_id: Optional[int] = Field(None, description="科目ID科代表需要")
class AddAdminResponse(BaseModel):
"""添加管理员响应"""
success: bool
username: str
password: Optional[str] = None
message: str
class UpdateHomeworkStatusRequest(BaseModel):
"""更新作业状态请求"""
submission_id: int
status: str
comments: Optional[str] = None
apply_deduction: bool = False
class AddAttendanceRequest(BaseModel):
"""添加考勤请求"""
student_id: int
date: date
status: str
reason: Optional[str] = None
apply_deduction: bool = False

54
backend/schemas/auth.py Normal file
View File

@@ -0,0 +1,54 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from pydantic import BaseModel, Field
from typing import Optional
class LoginRequest(BaseModel):
"""登录请求"""
username: str = Field(..., min_length=1, max_length=50, description="用户名")
password: str = Field(..., min_length=1, max_length=50, description="密码")
class LoginResponse(BaseModel):
"""登录响应"""
success: bool
token: str
user_id: int
username: str
real_name: str
user_type: str
need_change_password: bool
redirect: str
class ChangePasswordRequest(BaseModel):
"""修改密码请求"""
old_password: str = Field(..., min_length=1, max_length=50, description="原密码")
new_password: str = Field(..., min_length=6, max_length=20, description="新密码")
class ChangePasswordResponse(BaseModel):
"""修改密码响应"""
success: bool
message: str
class UserInfo(BaseModel):
"""用户信息"""
user_id: int
username: str
real_name: str
user_type: str
student_id: Optional[int] = None
role: Optional[str] = None
need_change_password: bool

View File

@@ -0,0 +1,79 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import date, datetime
class StudentInfo(BaseModel):
"""学生信息"""
student_id: int
student_no: str
name: str
class_id: int
class_name: Optional[str] = None
total_points: int
parent_phone: Optional[str] = None
status: int
class ConductRecord(BaseModel):
"""操行分记录"""
record_id: int
student_id: int
student_name: Optional[str] = None
points_change: int
reason: str
recorder_id: int
recorder_name: str
related_type: str
is_revoked: bool
created_at: datetime
class ConductHistoryResponse(BaseModel):
"""操行分历史响应"""
student_id: int
student_name: str
total_points: int
records: List[ConductRecord]
class HomeworkSubmission(BaseModel):
"""作业提交情况"""
assignment_id: int
title: str
subject: str
deadline: date
status: str
submit_time: Optional[datetime] = None
comments: Optional[str] = None
deduction_applied: bool
class AttendanceRecord(BaseModel):
"""考勤记录"""
attendance_id: int
date: date
status: str
reason: Optional[str] = None
deduction_applied: bool
class StudentRanking(BaseModel):
"""学生排行"""
student_id: int
student_no: str
name: str
class_name: str
total_points: int
rank_in_class: int

View File

@@ -0,0 +1,43 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from pydantic import BaseModel, Field
from typing import Optional, List
class SubjectInfo(BaseModel):
"""科目信息"""
subject_id: int
subject_name: str
subject_code: Optional[str] = None
is_active: bool
sort_order: int
class CreateSubjectRequest(BaseModel):
"""创建科目请求"""
subject_name: str = Field(..., min_length=1, max_length=50, description="科目名称")
subject_code: Optional[str] = Field(None, max_length=20, description="科目代码")
sort_order: int = Field(0, description="排序序号")
class UpdateSubjectRequest(BaseModel):
"""更新科目请求"""
subject_name: Optional[str] = Field(None, max_length=50, description="科目名称")
subject_code: Optional[str] = Field(None, max_length=20, description="科目代码")
is_active: Optional[bool] = Field(None, description="是否启用")
sort_order: Optional[int] = Field(None, description="排序序号")
class SubjectListResponse(BaseModel):
"""科目列表响应"""
subjects: List[SubjectInfo]
total: int

View File

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

View File

@@ -0,0 +1,298 @@
# ===========================================
# 班级操行分管理系统 - 管理员服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Dict, Any, List, Optional
from datetime import datetime
from models.user import UserModel
from models.student import StudentModel
from models.admin_role import AdminRoleModel
from utils.security import security
from utils.logger import get_logger
logger = get_logger(__name__)
class AdminService:
"""管理员服务"""
@staticmethod
async def get_students(
page: int = 1,
page_size: int = 20,
search: str = None
) -> Dict[str, Any]:
"""获取所有学生列表(单班级)"""
offset = (page - 1) * page_size
sql = """
SELECT student_id, student_no, name, total_points, parent_phone, status
FROM students
WHERE status = 1
"""
params = []
if search:
sql += " AND (student_no LIKE %s OR name LIKE %s)"
params.extend([f"%{search}%", f"%{search}%"])
sql += " ORDER BY student_no LIMIT %s OFFSET %s"
params.extend([page_size, offset])
students = await execute_query(sql, tuple(params))
# 获取总数
count_sql = "SELECT COUNT(*) as total FROM students WHERE status = 1"
if search:
count_sql += " AND (student_no LIKE %s OR name LIKE %s)"
total_result = await execute_one(count_sql, (f"%{search}%", f"%{search}%"))
else:
total_result = await execute_one(count_sql)
total = total_result["total"] if total_result else 0
return {
"students": students,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size
}
@staticmethod
async def import_students(
students: List[Dict],
operator_id: int,
initial_points: int = 60
) -> Dict[str, Any]:
"""
批量导入学生(单班级版本)
初始操行分默认60分
"""
results = []
success_count = 0
for student in students:
student_no = student.get("student_no", "").strip()
name = student.get("name", "").strip()
parent_phone = student.get("parent_phone", "").strip()
password = student.get("password", "").strip()
# 验证必填字段
if not student_no or not name:
results.append({
"student_no": student_no,
"success": False,
"error": "学号或姓名不能为空"
})
continue
# 验证学号格式
if not security.validate_student_no(student_no):
results.append({
"student_no": student_no,
"success": False,
"error": "学号格式错误4-20位字母数字组合"
})
continue
# 验证手机号格式(如果有)
if parent_phone and not security.validate_phone(parent_phone):
results.append({
"student_no": student_no,
"success": False,
"error": "手机号格式错误"
})
continue
# 检查学号是否已存在
existing = await StudentModel.get_by_student_no(student_no)
if existing:
results.append({
"student_no": student_no,
"success": False,
"error": "学号已存在"
})
continue
# 设置初始密码
init_password = password if password else "123456"
# 验证密码强度(可选)
is_valid, msg = security.validate_password_strength(init_password)
if not is_valid:
results.append({
"student_no": student_no,
"success": False,
"error": f"密码不符合要求: {msg}"
})
continue
# 创建学生初始操行分60分
student_id = await StudentModel.create(
student_no=student_no,
name=name,
class_id=1, # 单班级固定为1
parent_phone=parent_phone if parent_phone else None,
initial_points=initial_points
)
# 创建学生账号
await UserModel.create_student(
username=student_no,
password=init_password,
real_name=name,
student_id=student_id
)
# 创建家长账号(如果有手机号)
if parent_phone:
parent_exists = await UserModel.get_by_username(parent_phone)
if not parent_exists:
parent_name = f"{name}家长"
await UserModel.create_parent(
username=parent_phone,
password=init_password,
real_name=parent_name,
student_id=student_id
)
else:
# 手机号已被占用
results.append({
"student_no": student_no,
"success": True,
"student_id": student_id,
"warning": f"家长手机号 {parent_phone} 已被其他账号使用,未创建家长账号"
})
success_count += 1
continue
results.append({
"student_no": student_no,
"success": True,
"student_id": student_id,
"parent_phone": parent_phone if parent_phone else None
})
success_count += 1
logger.info(f"用户[{operator_id}] 导入学生: {student_no} - {name} (初始分:{initial_points})")
return {
"success": True,
"total": len(students),
"success_count": success_count,
"failed_count": len(students) - success_count,
"results": results
}
@staticmethod
async def add_student(
student_no: str,
name: str,
parent_phone: Optional[str],
operator_id: int,
initial_points: int = 60
) -> Dict[str, Any]:
"""新增学生"""
# 验证学号格式
if not security.validate_student_no(student_no):
return {"success": False, "message": "学号格式错误4-20位字母数字组合"}
# 验证手机号格式(如果有)
if parent_phone and not security.validate_phone(parent_phone):
return {"success": False, "message": "手机号格式错误"}
# 检查学号是否已存在
existing = await StudentModel.get_by_student_no(student_no)
if existing:
return {"success": False, "message": "学号已存在"}
# 创建学生初始操行分60分
student_id = await StudentModel.create(
student_no=student_no,
name=name,
class_id=1, # 单班级固定为1
parent_phone=parent_phone if parent_phone else None,
initial_points=initial_points
)
# 创建学生账号
await UserModel.create_student(
username=student_no,
password="123456",
real_name=name,
student_id=student_id
)
# 创建家长账号
if parent_phone:
parent_exists = await UserModel.get_by_username(parent_phone)
if not parent_exists:
await UserModel.create_parent(
username=parent_phone,
password="123456",
real_name=f"{name}家长",
student_id=student_id
)
logger.info(f"用户[{operator_id}] 新增学生: {student_no} - {name}")
return {"success": True, "student_id": student_id, "student_no": student_no, "name": name}
@staticmethod
async def add_admin(
username: str,
real_name: str,
password: Optional[str],
role_type: str,
operator_id: int
) -> Dict[str, Any]:
"""添加管理员(单班级)"""
# 检查用户名是否已存在
existing = await UserModel.get_by_username(username)
if existing:
return {"success": False, "message": "用户名已存在"}
# 生成随机密码(如果未提供)
if not password:
password = security.generate_random_password()
# 创建管理员账号
user_id = await UserModel.create_admin(
username=username,
password=password,
real_name=real_name
)
# 分配角色班级ID固定为1
await AdminRoleModel.create(
user_id=user_id,
role_type=role_type,
class_id=1, # 单班级固定为1
subject_id=None # 单班级版本暂不支持科目关联
)
logger.info(f"用户[{operator_id}] 添加管理员: {username} ({role_type})")
return {
"success": True,
"user_id": user_id,
"username": username,
"password": password,
"role_type": role_type
}
@staticmethod
async def get_admins() -> Dict[str, Any]:
"""获取管理员列表(单班级)"""
admins = await AdminRoleModel.get_by_class(1) # 班级ID固定为1
return {"admins": admins}

View File

@@ -0,0 +1,114 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Dict, Any, Optional
from datetime import datetime
from models.attendance import AttendanceModel
from models.student import StudentModel
from models.conduct import ConductModel
from middleware.permission import PermissionChecker
from config import settings
from utils.logger import get_logger
logger = get_logger(__name__)
class AttendanceService:
"""考勤服务"""
@staticmethod
async def add_attendance(
student_id: int,
date: str,
status: str,
reason: Optional[str],
apply_deduction: bool,
recorder_id: int
) -> Dict[str, Any]:
"""添加考勤记录"""
# 检查权限
role = await PermissionChecker.get_user_role(recorder_id)
if role not in ["班主任", "考勤委员"]:
return {"success": False, "message": "无权进行此操作"}
# 检查是否同班级
can_manage = await PermissionChecker.check_can_manage_student(recorder_id, student_id)
if not can_manage:
return {"success": False, "message": "无权操作该学生"}
# 添加考勤记录
attendance_id = await AttendanceModel.create_record(
student_id=student_id,
date=date,
status=status,
reason=reason,
recorder_id=recorder_id
)
if not attendance_id:
return {"success": False, "message": "添加考勤记录失败"}
# 应用扣分
if apply_deduction and status in ["absent", "late", "leave"]:
# 确定扣分数值
if status == "absent":
points_change = -settings.DEDUCTION_ATTENDANCE_ABSENT
elif status == "late":
points_change = -settings.DEDUCTION_ATTENDANCE_LATE
else:
points_change = -settings.DEDUCTION_ATTENDANCE_LEAVE
# 创建扣分记录
student = await StudentModel.get_by_id(student_id)
if student:
await ConductModel.create_record(
student_id=student_id,
points_change=points_change,
reason=f"考勤异常: {status}",
recorder_id=recorder_id,
related_type="attendance",
related_id=attendance_id
)
# 标记已应用扣分
await AttendanceModel.mark_deduction_applied(attendance_id)
logger.info(f"用户[{recorder_id}] 添加考勤记录[{attendance_id}] -> {status}")
return {"success": True, "message": "考勤记录添加成功"}
@staticmethod
async def get_records(
user_id: int,
date: Optional[str] = None,
student_id: Optional[int] = None
) -> Dict[str, Any]:
"""获取考勤记录"""
role = await PermissionChecker.get_user_role(user_id)
if role in ["班主任", "考勤委员"]:
class_id = await PermissionChecker.get_user_class_id(user_id)
records = await AttendanceModel.get_class_records(
class_id=class_id,
date=date,
student_id=student_id
)
elif student_id:
# 查看指定学生
can_manage = await PermissionChecker.check_can_manage_student(user_id, student_id)
if not can_manage:
return {"error": "无权查看该学生记录"}
records = await AttendanceModel.get_student_records(student_id)
else:
records = []
return {"records": records}

View File

@@ -0,0 +1,169 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Dict, Any, Optional
from datetime import datetime
from models.user import UserModel
from models.student import StudentModel
from models.admin_role import AdminRoleModel
from utils.security import security
from utils.jwt_handler import jwt_handler
from utils.redis_client import RedisClient
from utils.database import execute_update
from utils.logger import get_logger
logger = get_logger(__name__)
class AuthService:
"""认证服务"""
@staticmethod
async def login(username: str, password: str, ip: str) -> Dict[str, Any]:
"""
用户登录
"""
# 检查登录失败次数
attempts = await RedisClient.get(f"login_attempts:{username}")
if attempts and int(attempts) >= 5:
return {"success": False, "message": "登录失败次数过多请15分钟后重试"}
# 获取用户信息
user = await UserModel.get_by_username(username)
if not user:
await RedisClient.set_login_attempts(username)
return {"success": False, "message": "用户名或密码错误"}
# 验证密码
if not security.verify_password(password, user["password_hash"]):
await RedisClient.set_login_attempts(username)
return {"success": False, "message": "用户名或密码错误"}
# 检查账号状态
if user["status"] != 1:
return {"success": False, "message": "账号已被禁用"}
# 清除登录失败记录
await RedisClient.clear_login_attempts(username)
# 更新最后登录信息
await UserModel.update_last_login(user["user_id"], ip)
# 获取用户角色(如果是管理员)
role = None
if user["user_type"] == "admin":
admin_role = await AdminRoleModel.get_by_user_id(user["user_id"])
role = admin_role["role_type"] if admin_role else None
# 生成Token
token = jwt_handler.create_token(
user_id=user["user_id"],
username=user["username"],
user_type=user["user_type"],
student_id=user["student_id"],
role=role
)
# 存储Token到Redis
await RedisClient.set_user_token(user["user_id"], token)
# 确定跳转路径
redirect = AuthService._get_redirect_path(user["user_type"], role)
return {
"success": True,
"token": token,
"user_id": user["user_id"],
"username": user["username"],
"real_name": user["real_name"],
"user_type": user["user_type"],
"need_change_password": user["need_change_password"] == 1,
"redirect": redirect
}
@staticmethod
async def logout(user_id: int) -> Dict[str, Any]:
"""用户登出"""
await RedisClient.delete_user_token(user_id)
return {"success": True, "message": "登出成功"}
@staticmethod
async def change_password(user_id: int, old_password: str, new_password: str) -> Dict[str, Any]:
"""修改密码"""
# 获取用户信息
user = await UserModel.get_by_user_id(user_id)
if not user:
return {"success": False, "message": "用户不存在"}
# 验证原密码
if not security.verify_password(old_password, user["password_hash"]):
return {"success": False, "message": "原密码错误"}
# 验证新密码强度
is_valid, msg = security.validate_password_strength(new_password)
if not is_valid:
return {"success": False, "message": msg}
# 更新密码
result = await UserModel.update_password(user_id, new_password)
if result:
# 清除所有Token
await RedisClient.delete_user_token(user_id)
return {"success": True, "message": "密码修改成功"}
else:
return {"success": False, "message": "密码修改失败"}
@staticmethod
async def get_user_info(user_id: int) -> Optional[Dict[str, Any]]:
"""获取用户信息"""
user = await UserModel.get_by_user_id(user_id)
if not user:
return None
result = {
"user_id": user["user_id"],
"username": user["username"],
"real_name": user["real_name"],
"user_type": user["user_type"],
"need_change_password": user["need_change_password"] == 1
}
# 获取学生信息
if user["student_id"]:
student = await StudentModel.get_by_id(user["student_id"])
if student:
result["student_no"] = student["student_no"]
result["student_name"] = student["name"]
result["class_id"] = student["class_id"]
result["class_name"] = student["class_name"]
result["total_points"] = student["total_points"]
# 获取管理员角色
if user["user_type"] == "admin":
admin_role = await AdminRoleModel.get_by_user_id(user_id)
if admin_role:
result["role"] = admin_role["role_type"]
result["class_id"] = admin_role["class_id"]
return result
@staticmethod
def _get_redirect_path(user_type: str, role: str = None) -> str:
"""获取跳转路径"""
if user_type == "student":
return "/student/dashboard.php"
elif user_type == "parent":
return "/parent/dashboard.php"
else:
return "/admin/dashboard.php"

View File

@@ -0,0 +1,182 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Dict, Any, List, Optional
from datetime import datetime
from models.student import StudentModel
from models.conduct import ConductModel
from models.user import UserModel
from middleware.permission import PermissionChecker
from config import settings
from utils.logger import get_logger
logger = get_logger(__name__)
class ConductService:
"""操行分服务"""
@staticmethod
async def add_points(
student_ids: List[int],
points_change: int,
reason: str,
recorder_id: int,
recorder_name: str
) -> Dict[str, Any]:
"""
批量加减分
"""
# 验证分值
if points_change == 0:
return {"success": False, "message": "分值不能为0"}
# 获取操作人角色
role = await PermissionChecker.get_user_role(recorder_id)
# 权限验证
if role == "班主任":
# 班主任无限制
pass
elif role == "班长":
# 班长限制 ±5分
if points_change > settings.MONITOR_MAX_ADD or points_change < settings.MONITOR_MAX_SUBTRACT:
return {"success": False, "message": f"班长单次只能加减{settings.MONITOR_MAX_ADD}分以内"}
elif role == "劳动委员":
# 劳动委员固定 ±1分
if points_change not in [settings.LABOR_POINTS_ADD, settings.LABOR_POINTS_SUBTRACT]:
return {"success": False, "message": "劳动委员只能进行±1分操作"}
elif role in ["科代表", "考勤委员"]:
# 科代表和考勤委员只能扣分
if points_change > 0:
return {"success": False, "message": "该角色只能进行扣分操作"}
else:
return {"success": False, "message": "无权进行此操作"}
# 批量处理
success_count = 0
fail_count = 0
details = []
for student_id in student_ids:
try:
# 检查学生是否存在
student = await StudentModel.get_by_id(student_id)
if not student:
details.append({"student_id": student_id, "error": "学生不存在"})
fail_count += 1
continue
# 创建记录
record_id = await ConductModel.create_record(
student_id=student_id,
points_change=points_change,
reason=reason,
recorder_id=recorder_id,
recorder_name=recorder_name
)
details.append({"student_id": student_id, "success": True, "record_id": record_id})
success_count += 1
logger.info(f"用户[{recorder_id}] 对学生[{student_id}] 进行 {points_change} 分操作")
except Exception as e:
details.append({"student_id": student_id, "error": str(e)})
fail_count += 1
return {
"success": True,
"success_count": success_count,
"fail_count": fail_count,
"details": details
}
@staticmethod
async def revoke_record(record_id: int, revoker_id: int) -> Dict[str, Any]:
"""撤销扣分记录"""
# 检查权限
can_revoke = await PermissionChecker.check_can_revoke(revoker_id, record_id)
if not can_revoke:
return {"success": False, "message": "无权撤销此记录"}
# 撤销记录
result = await ConductModel.revoke_record(record_id, revoker_id)
if result:
logger.info(f"用户[{revoker_id}] 撤销了记录[{record_id}]")
return {"success": True, "message": "撤销成功"}
else:
return {"success": False, "message": "撤销失败"}
@staticmethod
async def get_history(
user_id: int,
student_id: Optional[int] = None,
page: int = 1,
page_size: int = 20,
start_date: Optional[str] = None,
end_date: Optional[str] = None
) -> Dict[str, Any]:
"""获取历史记录"""
role = await PermissionChecker.get_user_role(user_id)
offset = (page - 1) * page_size
# 班主任/班长可查看全班
if role in ["班主任", "班长"]:
user_class = await PermissionChecker.get_user_class_id(user_id)
records = await ConductModel.get_all_records(
class_id=user_class,
limit=page_size,
offset=offset,
start_date=start_date,
end_date=end_date
)
# 获取总数
from utils.database import execute_one
count_sql = """
SELECT COUNT(*) as total FROM conduct_records cr
JOIN students s ON cr.student_id = s.student_id
WHERE s.class_id = %s AND cr.is_revoked = 0
"""
total_result = await execute_one(count_sql, (user_class,))
total = total_result["total"] if total_result else 0
elif student_id:
# 查看指定学生(需权限验证)
can_manage = await PermissionChecker.check_can_manage_student(user_id, student_id)
if not can_manage:
return {"error": "无权查看该学生记录"}
records = await ConductModel.get_student_records(
student_id=student_id,
limit=page_size,
offset=offset
)
total = len(await ConductModel.get_student_records(student_id, limit=10000))
else:
# 查看自己提交的记录
records = await ConductModel.get_records_by_recorder(
recorder_id=user_id,
limit=page_size,
offset=offset
)
total = len(await ConductModel.get_records_by_recorder(user_id, limit=10000))
return {
"records": records,
"page": page,
"page_size": page_size,
"total": total,
"total_pages": (total + page_size - 1) // page_size
}

View File

@@ -0,0 +1,131 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Dict, Any, List, Optional
from datetime import datetime
from models.homework import HomeworkModel
from models.student import StudentModel
from models.conduct import ConductModel
from middleware.permission import PermissionChecker
from config import settings
from utils.logger import get_logger
logger = get_logger(__name__)
class HomeworkService:
"""作业服务"""
@staticmethod
async def get_assignments(user_id: int) -> Dict[str, Any]:
"""获取作业列表"""
role = await PermissionChecker.get_user_role(user_id)
if role == "班主任":
class_id = await PermissionChecker.get_user_class_id(user_id)
assignments = await HomeworkModel.get_assignments_by_class(class_id)
elif role == "科代表":
class_id = await PermissionChecker.get_user_class_id(user_id)
subject_ids = await PermissionChecker.get_user_subject_ids(user_id)
assignments = await HomeworkModel.get_assignments_by_subjects(class_id, subject_ids)
else:
assignments = []
return {"assignments": assignments}
@staticmethod
async def create_assignment(
subject_id: int,
title: str,
description: Optional[str],
deadline: str,
created_by: int
) -> Dict[str, Any]:
"""创建作业"""
class_id = await PermissionChecker.get_user_class_id(created_by)
assignment_id = await HomeworkModel.create_assignment(
class_id=class_id,
subject_id=subject_id,
title=title,
description=description,
deadline=deadline,
created_by=created_by
)
if assignment_id:
logger.info(f"用户[{created_by}] 创建作业[{assignment_id}]: {title}")
return {"success": True, "assignment_id": assignment_id}
else:
return {"success": False, "message": "创建作业失败"}
@staticmethod
async def update_submission_status(
submission_id: int,
status: str,
comments: Optional[str],
apply_deduction: bool,
operator_id: int
) -> Dict[str, Any]:
"""更新作业提交状态"""
# 获取提交记录信息
submission = await HomeworkModel.get_submission(submission_id)
if not submission:
return {"success": False, "message": "提交记录不存在"}
# 检查权限
role = await PermissionChecker.get_user_role(operator_id)
if role == "科代表":
# 检查是否管理该科目
subject_ids = await PermissionChecker.get_user_subject_ids(operator_id)
if submission["subject_id"] not in subject_ids:
return {"success": False, "message": "无权操作此作业"}
elif role != "班主任":
return {"success": False, "message": "无权进行此操作"}
# 更新状态
result = await HomeworkModel.update_submission(
submission_id=submission_id,
status=status,
comments=comments,
updated_by=operator_id
)
if not result:
return {"success": False, "message": "更新失败"}
# 应用扣分
if apply_deduction and status in ["not_submitted", "late"]:
# 确定扣分数值
if status == "not_submitted":
points_change = -settings.DEDUCTION_HOMEWORK_NOT_SUBMIT
else:
points_change = -settings.DEDUCTION_HOMEWORK_LATE
# 创建扣分记录
student = await StudentModel.get_by_id(submission["student_id"])
if student:
await ConductModel.create_record(
student_id=submission["student_id"],
points_change=points_change,
reason=f"作业未提交/迟交: {submission['title']}",
recorder_id=operator_id,
related_type="homework",
related_id=submission["assignment_id"]
)
# 标记已应用扣分
await HomeworkModel.mark_deduction_applied(submission_id)
logger.info(f"用户[{operator_id}] 更新作业提交状态[{submission_id}] -> {status}")
return {"success": True, "message": "状态更新成功"}

View File

@@ -0,0 +1,82 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Dict, Any, Optional
from models.user import UserModel
from models.student import StudentModel
from models.conduct import ConductModel
from models.homework import HomeworkModel
from models.attendance import AttendanceModel
from utils.logger import get_logger
logger = get_logger(__name__)
class ParentService:
"""家长服务"""
@staticmethod
async def get_child_conduct(parent_id: int) -> Dict[str, Any]:
"""获取子女操行分(仅总分,家长端不显示详细记录)"""
# 获取家长关联的学生
user = await UserModel.get_by_user_id(parent_id)
if not user or not user["student_id"]:
return {"error": "未关联学生"}
student = await StudentModel.get_by_id(user["student_id"])
if not student:
return {"error": "学生不存在"}
return {
"student_id": student["student_id"],
"student_name": student["name"],
"student_no": student["student_no"],
"total_points": student["total_points"]
}
@staticmethod
async def get_child_homework(parent_id: int) -> Dict[str, Any]:
"""获取子女作业情况"""
user = await UserModel.get_by_user_id(parent_id)
if not user or not user["student_id"]:
return {"error": "未关联学生"}
student = await StudentModel.get_by_id(user["student_id"])
if not student:
return {"error": "学生不存在"}
homework = await HomeworkModel.get_student_homework(user["student_id"])
return {
"student_id": student["student_id"],
"student_name": student["name"],
"homework": homework
}
@staticmethod
async def get_child_attendance(parent_id: int) -> Dict[str, Any]:
"""获取子女考勤记录"""
user = await UserModel.get_by_user_id(parent_id)
if not user or not user["student_id"]:
return {"error": "未关联学生"}
student = await StudentModel.get_by_id(user["student_id"])
if not student:
return {"error": "学生不存在"}
records = await AttendanceModel.get_student_records(user["student_id"])
return {
"student_id": student["student_id"],
"student_name": student["name"],
"records": records
}

View File

@@ -0,0 +1,146 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Dict, Any, List, Optional
from datetime import datetime, timedelta
from models.student import StudentModel
from models.conduct import ConductModel
from models.homework import HomeworkModel
from models.attendance import AttendanceModel
from middleware.permission import PermissionChecker
from utils.logger import get_logger
logger = get_logger(__name__)
class StudentService:
"""学生服务"""
@staticmethod
async def get_conduct_history(
student_id: int,
limit: int = 50,
offset: int = 0
) -> Dict[str, Any]:
"""获取学生操行分历史(学生端显示,扣分项操作人显示为班主任)"""
student = await StudentModel.get_by_id(student_id)
if not student:
return {"error": "学生不存在"}
records = await ConductModel.get_student_records(
student_id=student_id,
limit=limit,
offset=offset
)
# 处理记录:扣分项的操作人统一显示为"班主任"
for record in records:
if record["points_change"] < 0: # 扣分项
record["recorder_name"] = "班主任"
# 加分项保持原操作人不变
return {
"student_id": student_id,
"student_name": student["name"],
"total_points": student["total_points"],
"records": records
}
@staticmethod
async def get_homework_status(student_id: int) -> Dict[str, Any]:
"""获取学生作业情况"""
student = await StudentModel.get_by_id(student_id)
if not student:
return {"error": "学生不存在"}
homework = await HomeworkModel.get_student_homework(student_id)
# 统计
total = len(homework)
submitted = sum(1 for h in homework if h["status"] == "submitted")
not_submitted = sum(1 for h in homework if h["status"] == "not_submitted")
late = sum(1 for h in homework if h["status"] == "late")
return {
"student_id": student_id,
"student_name": student["name"],
"statistics": {
"total": total,
"submitted": submitted,
"not_submitted": not_submitted,
"late": late
},
"homework": homework
}
@staticmethod
async def get_attendance_records(
student_id: int,
month: Optional[str] = None
) -> Dict[str, Any]:
"""获取学生考勤记录"""
student = await StudentModel.get_by_id(student_id)
if not student:
return {"error": "学生不存在"}
records = await AttendanceModel.get_student_records(
student_id=student_id,
month=month
)
# 统计
present = sum(1 for r in records if r["status"] == "present")
absent = sum(1 for r in records if r["status"] == "absent")
late = sum(1 for r in records if r["status"] == "late")
leave = sum(1 for r in records if r["status"] == "leave")
return {
"student_id": student_id,
"student_name": student["name"],
"statistics": {
"present": present,
"absent": absent,
"late": late,
"leave": leave,
"total": len(records)
},
"records": records
}
@staticmethod
async def get_ranking(
user_id: int,
class_id: Optional[int] = None,
limit: int = 50
) -> Dict[str, Any]:
"""获取排行榜"""
# 如果未指定班级,获取用户所在班级
if not class_id:
user = await StudentModel.get_by_id(user_id) if user_id else None
if user:
class_id = user["class_id"]
else:
admin_class = await PermissionChecker.get_user_class_id(user_id)
if admin_class:
class_id = admin_class
ranking = await StudentModel.get_ranking(class_id=class_id, limit=limit)
return {
"class_id": class_id,
"ranking": ranking
}
@staticmethod
async def get_student_info(student_id: int) -> Optional[Dict[str, Any]]:
"""获取学生个人信息"""
return await StudentModel.get_by_id(student_id)

View File

@@ -0,0 +1,77 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================
from typing import Dict, Any, List, Optional
from models.subject import SubjectModel
from utils.logger import get_logger
logger = get_logger(__name__)
class SubjectService:
"""科目服务"""
@staticmethod
async def get_subjects(is_active: Optional[bool] = None) -> Dict[str, Any]:
"""获取科目列表"""
subjects = await SubjectModel.get_all(is_active=is_active)
return {
"subjects": subjects,
"total": len(subjects)
}
@staticmethod
async def create_subject(
subject_name: str,
subject_code: Optional[str],
sort_order: int = 0
) -> Dict[str, Any]:
"""创建科目"""
# 检查是否已存在
existing = await SubjectModel.get_by_name(subject_name)
if existing:
return {"success": False, "message": "科目名称已存在"}
subject_id = await SubjectModel.create(
subject_name=subject_name,
subject_code=subject_code,
sort_order=sort_order
)
if subject_id:
logger.info(f"创建科目: {subject_name}")
return {"success": True, "subject_id": subject_id}
else:
return {"success": False, "message": "创建科目失败"}
@staticmethod
async def update_subject(subject_id: int, **kwargs) -> Dict[str, Any]:
"""更新科目"""
result = await SubjectModel.update(subject_id, **kwargs)
if result:
logger.info(f"更新科目: {subject_id}")
return {"success": True}
else:
return {"success": False, "message": "更新科目失败"}
@staticmethod
async def delete_subject(subject_id: int) -> Dict[str, Any]:
"""删除科目(软删除)"""
result = await SubjectModel.delete(subject_id)
if result:
logger.info(f"禁用科目: {subject_id}")
return {"success": True}
else:
return {"success": False, "message": "禁用科目失败"}

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()