diff --git a/README.md b/README.md index 664584a..9e4d223 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,7 @@ classmanager/ | v1.1 | 2026.4.20 | 更新家长端查看加减分记录功能 | | v1.2 | 2026.4.22 | 学期管理、env配置加减分上限、排行榜百分比筛选、撤销操作日志、调试入口开关 | | v1.3 | 2026.4.27 | 考勤时段系统(早上/中午/晚修三时段)、历史记录扣分类型筛选、管理员/科目信息编辑、全链路输入安全校验 | +| v1.4 | 2026.4.28 | 全量代码审查修复:双重密码哈希bug、学生端XSS漏洞、性能优化(COUNT替代全量加载)、Pydantic schema统一、权限检查补全、考勤委员撤销权限 | ## 许可证 diff --git a/backend/.env.example b/backend/.env.example index a4e8a47..368c74e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -81,13 +81,13 @@ DEDUCTION_HOMEWORK_NOT_SUBMIT=2 DEDUCTION_HOMEWORK_LATE=1 # 缺勤扣分 - 学生无故缺勤时扣除的操行分 -DEDUCTION_ATTENDANCE_ABSENT=5 +DEDUCTION_ATTENDANCE_ABSENT=3 # 迟到扣分 - 学生迟到时扣除的操行分 -DEDUCTION_ATTENDANCE_LATE=2 +DEDUCTION_ATTENDANCE_LATE=1 -# 请假扣分 - 学生请假时扣除的操行分(可设为0表示不扣分) -DEDUCTION_ATTENDANCE_LEAVE=1 +# 请假扣分 - 学生请假时扣除的操行分(设为0表示不扣分) +DEDUCTION_ATTENDANCE_LEAVE=0 # =========================================== # 劳动委员固定分值配置 diff --git a/backend/config.py b/backend/config.py index 05b3bd8..5c834bb 100644 --- a/backend/config.py +++ b/backend/config.py @@ -53,9 +53,9 @@ class Settings: 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")) + DEDUCTION_ATTENDANCE_ABSENT: int = int(os.getenv("DEDUCTION_ATTENDANCE_ABSENT", "3")) + DEDUCTION_ATTENDANCE_LATE: int = int(os.getenv("DEDUCTION_ATTENDANCE_LATE", "1")) + DEDUCTION_ATTENDANCE_LEAVE: int = int(os.getenv("DEDUCTION_ATTENDANCE_LEAVE", "0")) LABOR_POINTS_ADD: int = int(os.getenv("LABOR_POINTS_ADD", "1")) LABOR_POINTS_SUBTRACT: int = int(os.getenv("LABOR_POINTS_SUBTRACT", "-1")) diff --git a/backend/middleware/auth_middleware.py b/backend/middleware/auth_middleware.py index 0884c2b..accd804 100644 --- a/backend/middleware/auth_middleware.py +++ b/backend/middleware/auth_middleware.py @@ -92,7 +92,6 @@ class AuthMiddleware(BaseHTTPMiddleware): logger.warning(f"[Auth] {path} - Redis Token不匹配, user_id={user_id}, stored={'有' if stored_token else '无'}") return self._cors_response(request, 401, "令牌已失效,请重新登录") - # 将用户信息存储到request.state # 将用户信息存储到request.state request.state.user_id = payload.get("user_id") request.state.username = payload.get("username") @@ -142,20 +141,3 @@ class AuthMiddleware(BaseHTTPMiddleware): }, headers=headers ) - - -async def get_current_user(request: Request) -> Dict[str, Any]: - """获取当前登录用户信息""" - return { - "user_id": request.state.user_id, - "username": request.state.username, - "real_name": getattr(request.state, 'real_name', None) or 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 diff --git a/backend/middleware/permission.py b/backend/middleware/permission.py index 5ed5769..733a671 100644 --- a/backend/middleware/permission.py +++ b/backend/middleware/permission.py @@ -26,6 +26,7 @@ async def get_current_user(request: Request) -> Dict[str, Any]: return { "user_id": getattr(request.state, 'user_id', None), "username": getattr(request.state, 'username', None), + "real_name": getattr(request.state, 'real_name', None), "user_type": getattr(request.state, 'user_type', None), "student_id": getattr(request.state, 'student_id', None), "role": getattr(request.state, 'role', None) @@ -124,19 +125,23 @@ class PermissionChecker: 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,)) + record = await execute_one( + "SELECT record_id, recorder_id FROM conduct_records WHERE record_id = %s", + (record_id,) + ) if not record: return False role = await PermissionChecker.get_user_role(user_id) - if role in ["班主任", "班长", "志愿委员"]: + if role in ["班主任", "班长"]: return True - return record["recorder_id"] == user_id + if role == "考勤委员" and record.get("recorder_id") == user_id: + return True + return False def require_auth(func: Callable): diff --git a/backend/models/conduct.py b/backend/models/conduct.py index 9c10124..1817f9f 100644 --- a/backend/models/conduct.py +++ b/backend/models/conduct.py @@ -40,6 +40,21 @@ class ConductModel: student_id, points_change, reason, recorder_id, recorder_name, related_type, related_id )) + @staticmethod + async def count_student_records(student_id: int, include_revoked: bool = False) -> int: + """统计学生操行分记录总数""" + revoked_condition = "" if include_revoked else " AND is_revoked = 0" + sql = f"SELECT COUNT(*) as total FROM conduct_records WHERE student_id = %s{revoked_condition}" + result = await execute_one(sql, (student_id,)) + return result["total"] if result else 0 + + @staticmethod + async def count_records_by_recorder(recorder_id: int) -> int: + """统计记录人提交的操行分记录总数""" + sql = "SELECT COUNT(*) as total FROM conduct_records WHERE recorder_id = %s" + result = await execute_one(sql, (recorder_id,)) + return result["total"] if result else 0 + @staticmethod async def get_student_records( student_id: int, diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 367eb46..81fa719 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -27,7 +27,8 @@ from schemas.admin import ( AddPointsRequest, RevokeRequest, AddAdminRequest, AddStudentRequest, UpdateStudentRequest, UpdateHomeworkStatusRequest, AddAttendanceRequest, - UpdateAdminRequest, DeleteAdminRequest, ResetPasswordRequest + UpdateAdminRequest, DeleteAdminRequest, ResetPasswordRequest, + CreateAssignmentRequest ) from utils.response import success_response, error_response from utils.logger import get_logger @@ -48,6 +49,8 @@ async def get_students( ): """获取所有学生列表(单班级)""" user = await get_current_user(request) + if user["user_type"] != "admin": + return error_response(message="仅管理员可查看学生列表", code=403) result = await AdminService.get_students(page=page, page_size=page_size, search=search) return success_response(data=result) @@ -138,6 +141,13 @@ async def update_student(request: Request, student_id: int, req: UpdateStudentRe parent_phone=req.parent_phone ) if result["success"]: + await LogService.write_operation_log( + operator_id=user["user_id"], operator_name=user["real_name"], + operator_role="班主任", operation_type="update_student", + target_type="student", target_id=student_id, + details=f"编辑学生ID: {student_id}", + ip=request.client.host + ) return success_response(message=result["message"]) else: return error_response(message=result["message"]) @@ -153,6 +163,13 @@ async def delete_student(request: Request, student_id: int): result = await AdminService.delete_student(student_id=student_id) if result["success"]: + await LogService.write_operation_log( + operator_id=user["user_id"], operator_name=user["real_name"], + operator_role="班主任", operation_type="delete_student", + target_type="student", target_id=student_id, + details=f"删除学生ID: {student_id}", + ip=request.client.host + ) return success_response(message=result["message"]) else: return error_response(message=result["message"]) @@ -171,6 +188,13 @@ async def reset_student_password(request: Request, student_id: int, req: ResetPa new_password=req.new_password ) if result["success"]: + await LogService.write_operation_log( + operator_id=user["user_id"], operator_name=user["real_name"], + operator_role="班主任", operation_type="reset_student_password", + target_type="student", target_id=student_id, + details=f"重置学生密码, 学生ID: {student_id}", + ip=request.client.host + ) return success_response(message=result["message"]) else: return error_response(message=result["message"]) @@ -272,6 +296,8 @@ async def get_conduct_history( """获取操行分历史记录""" try: user = await get_current_user(request) + if user["user_type"] != "admin": + return error_response(message="仅管理员可查看历史记录", code=403) result = await ConductService.get_history( user_id=user["user_id"], student_id=student_id, @@ -316,23 +342,17 @@ async def get_submissions(request: Request, assignment_id: int): @router.post("/homework/assignment") -async def create_assignment( - request: Request, - subject_id: int, - title: str, - description: Optional[str] = None, - deadline: str = None -): +async def create_assignment(request: Request, req: CreateAssignmentRequest): """发布作业(班主任)""" 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 HomeworkService.create_assignment( - subject_id=subject_id, - title=title, - description=description, - deadline=deadline, + subject_id=req.subject_id, + title=req.title, + description=req.description, + deadline=req.deadline, created_by=user["user_id"] ) if result["success"]: @@ -416,6 +436,9 @@ async def get_attendance_records( ): """获取考勤记录""" user = await get_current_user(request) + role = await PermissionChecker.get_user_role(user["user_id"]) + if role not in ["班主任", "考勤委员"]: + return error_response(message="无权查看考勤记录", code=403) result = await AttendanceService.get_records( user_id=user["user_id"], date=date, diff --git a/backend/schemas/admin.py b/backend/schemas/admin.py index a82fd10..6a6b839 100644 --- a/backend/schemas/admin.py +++ b/backend/schemas/admin.py @@ -91,7 +91,6 @@ class AddAttendanceRequest(BaseModel): class UpdateAdminRequest(BaseModel): """更新管理员请求""" - user_id: int = Field(..., gt=0, description="用户ID") real_name: str = Field(..., min_length=1, max_length=50, description="真实姓名") role_type: str = Field(..., pattern=r'^(班长|学习委员|考勤委员|劳动委员|志愿委员)$', description="角色类型") @@ -109,4 +108,12 @@ class ResetPasswordRequest(BaseModel): class UpdateStudentRequest(BaseModel): """更新学生请求""" name: Optional[str] = Field(None, min_length=1, max_length=50, description="姓名") - parent_phone: Optional[str] = Field(None, max_length=11, pattern=r'^\d{0,11}$', description="家长手机号") \ No newline at end of file + parent_phone: Optional[str] = Field(None, max_length=11, pattern=r'^\d{0,11}$', description="家长手机号") + + +class CreateAssignmentRequest(BaseModel): + """创建作业请求""" + subject_id: int = Field(..., gt=0, description="科目ID") + title: str = Field(..., min_length=1, max_length=200, description="作业标题") + description: Optional[str] = Field(None, max_length=1000, description="作业描述") + deadline: str = Field(..., min_length=1, max_length=20, description="截止日期") \ No newline at end of file diff --git a/backend/services/admin_service.py b/backend/services/admin_service.py index 8538d59..8480950 100644 --- a/backend/services/admin_service.py +++ b/backend/services/admin_service.py @@ -296,9 +296,8 @@ class AdminService: if not user: return {"success": False, "message": "未找到对应的用户账号"} - password_hash = security.sha1_md5_password(new_password) - - result = await UserModel.update_password(user['user_id'], password_hash) + # UserModel.update_password 内部会进行哈希,无需预先哈希 + result = await UserModel.update_password(user['user_id'], new_password) if result: await execute_update( "UPDATE users SET need_change_password = 1 WHERE user_id = %s", diff --git a/backend/services/attendance_service.py b/backend/services/attendance_service.py index 07e7107..3e03385 100644 --- a/backend/services/attendance_service.py +++ b/backend/services/attendance_service.py @@ -89,14 +89,17 @@ class AttendanceService: points_change = -settings.DEDUCTION_ATTENDANCE_LATE else: points_change = -settings.DEDUCTION_ATTENDANCE_LEAVE - # 创建扣分记录 + + # 扣分为0时跳过(如请假不扣分) + if points_change == 0: + logger.info(f"用户[{recorder_id}] 添加考勤记录[{attendance_id}] -> {status} (不扣分)") + return {"success": True, "message": "考勤记录添加成功(不扣分)"} student = await StudentModel.get_by_id(student_id) if student: # 获取操作人姓名 user = await UserModel.get_by_user_id(recorder_id) recorder_name = user.get("real_name", "班主任") if user else "班主任" # 使用中文状态 - # 使用中文状态 status_text = ATTENDANCE_STATUS_MAP.get(status, status) await ConductModel.create_record( student_id=student_id, diff --git a/backend/services/conduct_service.py b/backend/services/conduct_service.py index 2cf2627..6d0c68d 100644 --- a/backend/services/conduct_service.py +++ b/backend/services/conduct_service.py @@ -115,8 +115,10 @@ class ConductService: details.append({"student_id": student_id, "error": str(e)}) fail_count += 1 + message = "操作成功" if fail_count == 0 else f"{success_count}人成功,{fail_count}人失败" return { "success": fail_count == 0, + "message": message, "success_count": success_count, "fail_count": fail_count, "details": details @@ -278,7 +280,7 @@ class ConductService: limit=page_size, offset=offset ) - total = len(await ConductModel.get_student_records(student_id, limit=10000)) + total = await ConductModel.count_student_records(student_id) else: # 查看自己提交的记录 records = await ConductModel.get_records_by_recorder( @@ -286,7 +288,7 @@ class ConductService: limit=page_size, offset=offset ) - total = len(await ConductModel.get_records_by_recorder(user_id, limit=10000)) + total = await ConductModel.count_records_by_recorder(user_id) return { "records": records, diff --git a/backend/services/parent_service.py b/backend/services/parent_service.py index 5c587b4..29ca19c 100644 --- a/backend/services/parent_service.py +++ b/backend/services/parent_service.py @@ -135,9 +135,8 @@ class ParentService: offset=offset ) - # 获取总数 - all_records = await ConductModel.get_student_records(user["student_id"], limit=10000) - total = len(all_records) + # 使用 COUNT 查询获取总数(避免获取全部记录) + total = await ConductModel.count_student_records(user["student_id"]) return { "student_id": student["student_id"], diff --git a/frontend/.env.example b/frontend/.env.example index 47fe8ee..f7a5b65 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -48,11 +48,11 @@ HOMEWORK_MAX_POINTS=3 STUDY_COMMISSIONER_MAX_POINTS=5 # 考勤-缺勤扣分 -DEDUCTION_ATTENDANCE_ABSENT=5 +DEDUCTION_ATTENDANCE_ABSENT=3 # 考勤-迟到扣分 -DEDUCTION_ATTENDANCE_LATE=2 -# 考勤-请假扣分 -DEDUCTION_ATTENDANCE_LEAVE=1 +DEDUCTION_ATTENDANCE_LATE=1 +# 考勤-请假扣分(设为0表示不扣分) +DEDUCTION_ATTENDANCE_LEAVE=0 # 学生初始操行分 STUDENT_INITIAL_POINTS=60 \ No newline at end of file diff --git a/frontend/admin/admins.php b/frontend/admin/admins.php index 27acf0f..54f78ba 100644 --- a/frontend/admin/admins.php +++ b/frontend/admin/admins.php @@ -227,7 +227,6 @@ async function submitEditAdmin() { } const res = await apiPut(`/api/admin/update/${currentEditUserId}`, { - user_id: currentEditUserId, real_name: document.getElementById('editAdminRealName').value, role_type: roleType }); diff --git a/frontend/admin/attendance.php b/frontend/admin/attendance.php index d1593a3..90ddac5 100644 --- a/frontend/admin/attendance.php +++ b/frontend/admin/attendance.php @@ -47,10 +47,10 @@ include __DIR__ . '/../includes/header.php';
- - - - + + + +
@@ -92,14 +92,35 @@ let currentStatus = 'absent'; let studentsData = []; let existingRecords = []; +// 考勤扣分配置映射(从后端配置注入) +const attendanceDeductionMap = { + absent: window.DEDUCTION_ATTENDANCE_ABSENT || 3, + late: window.DEDUCTION_ATTENDANCE_LATE || 1, + leave: window.DEDUCTION_ATTENDANCE_LEAVE || 0 +}; + +// 初始化按钮文字 +function initAttendanceButtons() { + const btnAbsent = document.getElementById('btnAbsent'); + const btnLate = document.getElementById('btnLate'); + const btnLeave = document.getElementById('btnLeave'); + if (btnAbsent) btnAbsent.textContent = '缺勤(' + attendanceDeductionMap.absent + '分)'; + if (btnLate) btnLate.textContent = '迟到(' + attendanceDeductionMap.late + '分)'; + if (btnLeave) btnLeave.textContent = '请假(' + (attendanceDeductionMap.leave > 0 ? attendanceDeductionMap.leave + '分' : '不扣分') + ')'; + // 默认选中缺勤,自动填入默认扣分 + if (attendanceDeductionMap.absent > 0) { + document.getElementById('customDeduction').value = attendanceDeductionMap.absent; + } +} + // 选择考勤状态 function selectStatus(btn) { document.querySelectorAll('.status-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); currentStatus = btn.dataset.status; - // 自动设置默认扣分值 - const defaultDeduction = btn.dataset.defaultDeduction; - if (defaultDeduction && defaultDeduction !== '0') { + // 自动设置默认扣分值(从配置读取) + const defaultDeduction = attendanceDeductionMap[currentStatus] || 0; + if (defaultDeduction > 0) { document.getElementById('customDeduction').value = defaultDeduction; } else { document.getElementById('customDeduction').value = ''; @@ -252,6 +273,7 @@ document.getElementById('attendanceSlot').addEventListener('change', function() }); // 页面初始化 +initAttendanceButtons(); loadStudents(); loadAttendanceRecords(); diff --git a/frontend/admin/conduct.php b/frontend/admin/conduct.php index 8aca8db..92a80cc 100644 --- a/frontend/admin/conduct.php +++ b/frontend/admin/conduct.php @@ -20,7 +20,7 @@ if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') { $page_title = '操行分管理'; $role = $_SESSION['role'] ?? ''; -if (!in_array($role, ['班主任', '班长', '学习委员', '劳动委员', '志愿委员'])) { +if (!in_array($role, ['班主任', '班长', '学习委员', '考勤委员', '劳动委员', '志愿委员'])) { header('Location: /admin/dashboard.php'); exit(); } diff --git a/frontend/admin/history.php b/frontend/admin/history.php index 0588b78..5f485e2 100644 --- a/frontend/admin/history.php +++ b/frontend/admin/history.php @@ -65,7 +65,7 @@ include __DIR__ . '/../includes/header.php'; 分数变动 原因 操作人 - + 操作 @@ -80,6 +80,7 @@ include __DIR__ . '/../includes/header.php'; - \ No newline at end of file diff --git a/frontend/parent/dashboard.php b/frontend/parent/dashboard.php index f00231f..b0b16ff 100644 --- a/frontend/parent/dashboard.php +++ b/frontend/parent/dashboard.php @@ -73,7 +73,6 @@ async function loadDashboard() { document.getElementById('totalPoints').textContent = res.data.total_points; } - // 加载排名信息 // 加载排名信息 const rankRes = await apiGet('/api/parent/child/ranking'); if (rankRes && rankRes.success) { diff --git a/frontend/student/dashboard.php b/frontend/student/dashboard.php index a093053..a139cf3 100644 --- a/frontend/student/dashboard.php +++ b/frontend/student/dashboard.php @@ -285,7 +285,7 @@ include __DIR__ . '/../includes/header.php'; html += `
${record.points_change > 0 ? '+' : ''}${record.points_change} - ${record.reason} + ${escapeHtml(record.reason)} ${formatDate(record.created_at)}
`; @@ -347,8 +347,8 @@ include __DIR__ . '/../includes/header.php'; ${formatDateTime(record.created_at)} ${record.points_change > 0 ? '+' : ''}${record.points_change} - ${record.reason} - ${record.recorder_name} + ${escapeHtml(record.reason)} + ${escapeHtml(record.recorder_name)} `; }); @@ -369,21 +369,9 @@ include __DIR__ . '/../includes/header.php'; } function renderConductPagination() { - const container = document.getElementById('conductPagination'); - if (!container) return; - if (conductTotalPages <= 1) { - container.innerHTML = ''; - return; - } - let html = ''; - for (let i = 1; i <= conductTotalPages; i++) { - if (i === conductPage) { - html += `${i}`; - } else { - html += `${i}`; - } - } - container.innerHTML = html; + renderSmartPagination('conductPagination', conductPage, conductTotalPages, function(page) { + loadConductHistory(page); + }); } // 加载作业 @@ -395,11 +383,11 @@ include __DIR__ . '/../includes/header.php'; res.data.homework.forEach(hw => { html += ` - ${hw.subject_name} - ${hw.title} - ${hw.deadline} + ${escapeHtml(hw.subject_name)} + ${escapeHtml(hw.title)} + ${escapeHtml(hw.deadline)} ${getStatusBadge(hw.status, 'homework')} - ${hw.comments || '-'} + ${escapeHtml(hw.comments || '-')} `; }); @@ -428,9 +416,9 @@ include __DIR__ . '/../includes/header.php'; res.data.records.forEach(record => { html += ` - ${record.date} + ${escapeHtml(record.date)} ${getStatusBadge(record.status, 'attendance')} - ${record.reason || '-'} + ${escapeHtml(record.reason || '-')} `; });