MySQL学生成绩管理系统:外键约束、触发器与Flask权限实战

简介:本资源是一份面向高校数据库课程设计实践的MySQL学生成绩管理系统完整课设文档,适用于计算机、软件工程及教育技术类专业本科生开展课程设计与系统开发实训。文档基于Python+Flask+MySQL技术栈实现前后端分离架构,覆盖管理员、教师、学生三类角色权限管理,支持成绩录入、查询、修改、删除及学生在线选课等核心功能,兼顾实用性与教学规范性。资源为单个3.95MB的Word文档(.doc),内容结构完整,含系统总体设计、需求分析、数据库概念/逻辑/物理设计、界面与代码实现、测试用例及总结反思共五章,目录层级清晰,含详细ER图说明、SQL建表语句、接口调用示例与Postman测试记录,便于读者理解系统设计逻辑并复现开发过程。目前已有5074人学习下载,是数据库原理、Web开发与软件工程综合实践的典型参考范例。

1. 这不是又一个“登录+增删改查”的课设Demo,而是一套能真实跑通、可验证权限逻辑、带外键级联和触发器的MySQL教学闭环系统

上周帮某高校实验室调试一个学生交上来的“成绩管理系统”,结果发现:80%的课设项目卡在登录页——密码没加密、表没建全、外键没加约束、连student_course的联合主键都漏了ON DELETE CASCADE。而这个「MySQL学生成绩管理系统」,从第一页的建库SQL到最后一行Flask路由,全程按真实教务场景推演:管理员添加专业后,新专业下所有学生自动获得该专业全部课程的选课记录(靠的是那个trigger_insert_1触发器);教师修改成绩时,前端传student_id+course_id,后端直接UPDATE student_course SET grade=? WHERE student_id=? AND course_id=?,不拼接SQL、不绕过ORM、不裸写SELECT * FROM ...;学生改密码前强制校验旧密码哈希值——它不是PPT里的UML图,是能让你在PyCharm里断点调试、在MySQL Workbench里看执行计划、在Postman里一条条测通的完整工程。适合刚学完《数据库原理》想落地练手的本科生,也适合需要快速搭一个轻量教务原型给导师看的研究生。它不追求Vue3+TS炫技,但每张表的NOT NULL、每个外键的REFERENCES、每个密码字段的VARCHAR(128)都写着“这课我真听进去了”。

2. 环境准备与数据库初始化:从零建库、补全外键、激活触发器,三步踩实底层地基

2.1 开发环境确认与MySQL服务启动

项目明确要求MySQL 8.0,这不是兼容性妥协,而是关键功能依赖——比如CREATE TRIGGER在8.0中默认支持DEFINER和更严格的SQL模式,而5.7下某些触发器语法会报错。先确认服务状态:

# Windows下检查MySQL是否运行(非WAMP/XAMPP集成环境) sc query mysql # 若未启动,手动启动(以管理员身份运行CMD) net start mysql

提示:如果提示服务名无效,说明MySQL未注册为Windows服务。此时需进入MySQL安装目录的bin下,执行mysqld --install再启动。别跳过这步——很多同学卡在“连不上localhost:3306”,本质是服务根本没起来。

2.2 执行建库SQL并手动补全外键约束

报告中给出的建表语句(P11-12)存在两处必须人工修正的硬伤:

  • student表建表时未声明major_id外键,仅靠注释//alter table student add foreign key...提示;
  • course表同理,teacher_id外键缺失;
  • major_course表虽声明了联合主键,但外键引用未在建表时写死,靠后续ALTER添加。

正确操作顺序(在MySQL命令行或Workbench中逐条执行):

-- 1. 创建数据库(注意字符集,避免中文乱码) CREATE DATABASE grade_manage_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE grade_manage_system; -- 2. 先建被引用表:major(专业表)和teacher(教师表) CREATE TABLE major ( major_id CHAR(3) PRIMARY KEY, major_name VARCHAR(20) NOT NULL UNIQUE ); CREATE TABLE teacher ( teacher_id CHAR(5) PRIMARY KEY, teacher_name VARCHAR(20) NOT NULL, sex CHAR(2), birth_year CHAR(4), password VARCHAR(128) NOT NULL ); -- 3. 建student表,此处必须显式声明外键(否则后续ALTER可能失败) CREATE TABLE student ( student_id CHAR(10) PRIMARY KEY, student_name VARCHAR(20) NOT NULL, sex CHAR(2), birth_year CHAR(4), province VARCHAR(20), enter_year CHAR(4) NOT NULL, major_id CHAR(3) NOT NULL, password VARCHAR(128) NOT NULL, FOREIGN KEY (major_id) REFERENCES major(major_id) ON DELETE CASCADE ); -- 4. 建course表,同样显式声明teacher_id外键 CREATE TABLE course ( course_id CHAR(5) PRIMARY KEY, course_name VARCHAR(20) NOT NULL, year CHAR(4) NOT NULL, semester CHAR(2) NOT NULL, teacher_id CHAR(5) NOT NULL, credit TINYINT UNSIGNED NOT NULL, FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) ON DELETE CASCADE ); -- 5. 建major_course(专业选课表),联合主键+双外键 CREATE TABLE major_course ( major_id CHAR(3), course_id CHAR(5), PRIMARY KEY (major_id, course_id), FOREIGN KEY (major_id) REFERENCES major(major_id) ON DELETE CASCADE, FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE CASCADE ); -- 6. 建student_course(学生选课成绩表),联合主键+外键 CREATE TABLE student_course ( student_id CHAR(10), course_id CHAR(5), grade TINYINT UNSIGNED, PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES student(student_id) ON DELETE CASCADE, FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE CASCADE ); -- 7. 建admin表(管理员表) CREATE TABLE admin ( admin_name CHAR(15) PRIMARY KEY, password VARCHAR(128) NOT NULL );

为什么必须显式声明?
MySQL 8.0 默认开启STRICT_TRANS_TABLES模式,若建表时不声明外键,后续ALTER TABLE ADD FOREIGN KEY可能因数据不一致(如student表里有major_id=‘999’但major表无此记录)直接报错ERROR 1452。先清空测试数据再建表,比后期排查外键冲突快10倍。

2.3 创建并验证核心触发器:让“专业开课”自动同步到“学生选课”

报告P18末尾的触发器代码是本系统的灵魂设计,但原文SQL有严重语法缺陷:缺少DELIMITER //声明,且未指定DEFINER。修正后如下(在MySQL命令行中执行):

DELIMITER // CREATE TRIGGER trigger_insert_1 AFTER INSERT ON major_course FOR EACH ROW BEGIN INSERT INTO student_course (student_id, course_id, grade) SELECT s.student_id, NEW.course_id, NULL FROM student s WHERE s.major_id = NEW.major_id; END // DELIMITER ;

验证触发器是否生效:

-- 插入一个新专业 INSERT INTO major VALUES ('001', '计算机科学与技术'); -- 插入一名该专业学生 INSERT INTO student VALUES ('20230001', '张三', '男', '2005', '江苏', '2023', '001', 'e10adc3949ba59abbe56e057f20f883e'); -- 插入一门课程 INSERT INTO course VALUES ('CS101', '数据库原理', '2024', '秋', 'T001', 3); -- 关键:向major_course插入专业-课程关联(触发器应自动为该专业所有学生生成选课记录) INSERT INTO major_course VALUES ('001', 'CS101'); -- 查询student_course,应看到张三的选课记录(grade为NULL) SELECT * FROM student_course WHERE course_id = 'CS101'; -- 预期输出:20230001 | CS101 | NULL

注意:触发器只对INSERT INTO major_course生效,不影响已有数据。若要补全历史数据,需手动执行INSERT INTO student_course ... SELECT。这是课设常见盲区——很多人以为触发器是“万能同步”,其实它只响应未来操作。

3. Flask后端核心模块拆解:登录鉴权、角色路由、密码哈希,三道防线如何落地

3.1 用户登录流程:从明文密码到bcrypt哈希的完整链路

项目摘要提到“密码加密存储”,但正文未说明算法。结合Python生态惯例及安全性要求,必须使用bcrypt而非MD5/SHA1(报告中示例密码e10adc3949ba59abbe56e057f20f883e是MD5,仅用于演示,不可用于实际)。Flask后端典型实现如下:

# app.py 片段 from flask import Flask, request, session, redirect, url_for, render_template, flash import bcrypt import mysql.connector from mysql.connector import Error app = Flask(__name__) app.secret_key = 'your_secret_key_here' # 实际项目需用secrets.token_hex() def get_db_connection(): try: connection = mysql.connector.connect( host='localhost', database='grade_manage_system', user='root', # 替换为你的MySQL用户名 password='your_password' # 替换为你的MySQL密码 ) return connection except Error as e: print(f"数据库连接错误: {e}") return None @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': username = request.form['username'] password = request.form['password'].encode('utf-8') # 转bytes供bcrypt使用 conn = get_db_connection() if not conn: flash('数据库连接失败') return render_template('login.html') cursor = conn.cursor(dictionary=True) # 根据用户名类型动态查表(管理员查admin表,教师查teacher,学生查student) user_type = None user_data = None # 先查admin表(用户名固定为'admin') cursor.execute("SELECT * FROM admin WHERE admin_name = %s", (username,)) admin = cursor.fetchone() if admin: user_type = 'admin' stored_hash = admin['password'].encode('utf-8') if bcrypt.checkpw(password, stored_hash): session['user_type'] = 'admin' session['username'] = username return redirect(url_for('admin_dashboard')) # 查teacher表(teacher_id即用户名) cursor.execute("SELECT * FROM teacher WHERE teacher_id = %s", (username,)) teacher = cursor.fetchone() if teacher: user_type = 'teacher' stored_hash = teacher['password'].encode('utf-8') if bcrypt.checkpw(password, stored_hash): session['user_type'] = 'teacher' session['username'] = username session['teacher_id'] = username return redirect(url_for('teacher_dashboard')) # 查student表(student_id即用户名) cursor.execute("SELECT * FROM student WHERE student_id = %s", (username,)) student = cursor.fetchone() if student: user_type = 'student' stored_hash = student['password'].encode('utf-8') if bcrypt.checkpw(password, stored_hash): session['user_type'] = 'student' session['username'] = username session['student_id'] = username return redirect(url_for('student_dashboard')) # 所有表都没匹配或密码错误 flash('用户名或密码错误') cursor.close() conn.close() return render_template('login.html') return render_template('login.html')

关键参数说明:

  • bcrypt.checkpw(password, stored_hash):password必须是bytes类型,stored_hash是数据库中存储的bcrypt哈希值(长度约60字符,以$2b$开头);
  • session['user_type']:存储用户角色,用于后续路由权限控制;
  • session['student_id']/session['teacher_id']:避免每次查询都解析username,提升性能;
  • flash():Flask消息闪现机制,前端用get_flashed_messages()显示错误。

血泪经验:很多同学直接password == db_password做明文比对,导致初始密码(学生编号/教师编号)在数据库里明文存储——这在课设答辩时会被导师当场叫停。bcrypt是底线,没有商量余地。

3.2 角色路由守卫:用装饰器拦截未授权访问

不同角色看到的页面完全不同,不能只靠前端隐藏菜单。Flask必须做后端路由守卫:

from functools import wraps def role_required(*allowed_roles): """角色权限装饰器""" def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): if 'user_type' not in session: flash('请先登录') return redirect(url_for('login')) if session['user_type'] not in allowed_roles: flash('权限不足') # 根据当前用户类型重定向到对应首页 if session['user_type'] == 'student': return redirect(url_for('student_dashboard')) elif session['user_type'] == 'teacher': return redirect(url_for('teacher_dashboard')) else: return redirect(url_for('admin_dashboard')) return f(*args, **kwargs) return decorated_function return decorator # 使用示例:管理员专属路由 @app.route('/admin/students') @role_required('admin') def admin_students(): # 只有admin能访问 conn = get_db_connection() cursor = conn.cursor(dictionary=True) cursor.execute("SELECT * FROM student") students = cursor.fetchall() cursor.close() conn.close() return render_template('admin/students.html', students=students) # 教师查看自己课程的学生名单(需关联course表过滤) @app.route('/teacher/course_students/<course_id>') @role_required('teacher') def teacher_course_students(course_id): conn = get_db_connection() cursor = conn.cursor(dictionary=True) # 关键:WHERE t.teacher_id = %s 确保教师只能看自己教的课 cursor.execute(""" SELECT s.*, sc.grade FROM student s JOIN student_course sc ON s.student_id = sc.student_id JOIN course c ON sc.course_id = c.course_id JOIN teacher t ON c.teacher_id = t.teacher_id WHERE c.course_id = %s AND t.teacher_id = %s """, (course_id, session['teacher_id'])) students = cursor.fetchall() cursor.close() conn.close() return render_template('teacher/course_students.html', students=students, course_id=course_id)

为什么不用if session['user_type'] != 'admin': abort(403)?
装饰器模式将权限逻辑抽离,避免在每个路由函数开头重复写if判断。更重要的是,它能统一处理越权行为(如学生直接在浏览器输入/admin/studentsURL),而不是让非法请求进入业务逻辑层再报错。

3.3 密码修改模块:旧密码校验+新密码二次确认+哈希更新

学生/教师修改密码是高频操作,也是SQL注入高危点。必须参数化查询 + 严格校验:

@app.route('/change_password', methods=['GET', 'POST']) def change_password(): if 'user_type' not in session: return redirect(url_for('login')) user_type = session['user_type'] username = session['username'] if request.method == 'POST': old_password = request.form['old_password'].encode('utf-8') new_password = request.form['new_password'].encode('utf-8') confirm_password = request.form['confirm_password'].encode('utf-8') # 1. 两次新密码必须一致 if new_password != confirm_password: flash('新密码与确认密码不一致') return render_template('change_password.html', user_type=user_type) # 2. 旧密码校验(根据user_type查对应表) conn = get_db_connection() cursor = conn.cursor(dictionary=True) stored_hash = None if user_type == 'student': cursor.execute("SELECT password FROM student WHERE student_id = %s", (username,)) elif user_type == 'teacher': cursor.execute("SELECT password FROM teacher WHERE teacher_id = %s", (username,)) elif user_type == 'admin': cursor.execute("SELECT password FROM admin WHERE admin_name = %s", (username,)) result = cursor.fetchone() if result: stored_hash = result['password'].encode('utf-8') cursor.close() conn.close() if not stored_hash or not bcrypt.checkpw(old_password, stored_hash): flash('原密码错误') return render_template('change_password.html', user_type=user_type) # 3. 生成新哈希并更新数据库 new_hash = bcrypt.hashpw(new_password, bcrypt.gensalt()) conn = get_db_connection() cursor = conn.cursor() if user_type == 'student': cursor.execute("UPDATE student SET password = %s WHERE student_id = %s", (new_hash.decode('utf-8'), username)) elif user_type == 'teacher': cursor.execute("UPDATE teacher SET password = %s WHERE teacher_id = %s", (new_hash.decode('utf-8'), username)) elif user_type == 'admin': cursor.execute("UPDATE admin SET password = %s WHERE admin_name = %s", (new_hash.decode('utf-8'), username)) conn.commit() cursor.close() conn.close() flash('密码修改成功') return redirect(url_for(f'{user_type}_dashboard')) return render_template('change_password.html', user_type=user_type)

参数说明:

  • bcrypt.gensalt(rounds=12):rounds参数控制哈希强度,默认12足够课设使用;
  • new_hash.decode('utf-8'):bcrypt返回bytes,MySQL字段是VARCHAR,必须解码;
  • conn.commit():显式提交事务,避免修改不生效;
  • flash()消息区分场景(原密码错/新密码不一致),降低用户困惑。

4. 前端交互与接口联调:用Postman验证RESTful接口,避开jQuery版本玄学

4.1 前端模板结构与静态资源组织

报告提到“借鉴网上前端模板”,实际项目中通常采用以下结构(PyCharm工程内):

grade_manage_system/ ├── app.py # Flask主程序 ├── templates/ # Jinja2模板 │ ├── base.html # 基础布局(含导航栏、CSS/JS引入) │ ├── login.html # 登录页 │ ├── admin/ │ │ ├── dashboard.html │ │ ├── students.html # 管理员学生管理页 │ │ └── ... │ ├── student/ │ │ ├── dashboard.html │ │ ├── profile.html # 学生个人信息页 │ │ └── grades.html # 学生成绩页 │ └── teacher/ │ ├── dashboard.html │ └── courses.html # 教师课程管理页 ├── static/ │ ├── css/ │ │ └── style.css # 自定义样式(覆盖模板默认) │ └── js/ │ └── main.js # 页面级JS(如成绩表格排序) └── requirements.txt # 依赖包清单

关键设计点:

  • base.html中用{% block content %}{% endblock %}定义内容区,子模板用{% extends "base.html" %}继承;
  • 导航栏根据session['user_type']动态渲染菜单项(如管理员显示“专业管理”,学生不显示);
  • 所有表单提交用POST方法,禁用GET传敏感参数。

4.2 Postman接口测试清单:5个必测核心接口

不要等前端写完再测!用Postman直接调Flask接口,效率翻倍。以下是课设答辩前必须验证的5个接口:

接口URL请求方法测试要点预期响应
http://localhost:5000/loginPOSTusername=admin,password=asdfg13579302重定向到/admin/dashboard,且Set-Cookie含session_id
http://localhost:5000/admin/studentsGET已登录admin,返回HTML页面含学生列表tableHTTP 200,响应体含<table>标签及学生数据
http://localhost:5000/student/gradesGET已登录学生(如20230001),返回该生成绩HTTP 200,含课程名、成绩、教师名等字段
http://localhost:5000/teacher/course_students/CS101GET已登录教师(T001),且CS101由其授课HTTP 200,返回该课程学生名单及成绩
http://localhost:5000/change_passwordPOSTold_password=20230001,new_password=test123,confirm_password=test123HTTP 200,返回成功提示,且数据库student表password字段更新为bcrypt哈希

Postman操作技巧:

  • 在Cookies标签页勾选Enable cookies,确保Session正常传递;
  • 对POST接口,在Body → x-www-form-urlencoded中填参数,勿用raw JSON(Flask默认不解析);
  • 用Tests标签页写简单断言,例如:
    // 验证登录后重定向到admin页 pm.test("Status code is 302", function () { pm.response.to.have.status(302); pm.expect(pm.response.headers.get("Location")).to.include("/admin/dashboard"); });

玄学警告:若Postman测试通过但浏览器登录失败,大概率是浏览器缓存了旧的Cookie或JavaScript报错阻塞了表单提交。此时应:1)清空浏览器Cookie;2)打开F12控制台看Console是否有JS错误;3)用Postman复现问题——把复杂问题隔离到最小可测单元。

4.3 常见前端问题排查:CSS失效、JS未加载、Jinja2变量渲染失败

现象:登录页样式错乱,按钮堆叠在一起

原因:static/css/style.css路径错误,或Flask未正确配置静态文件服务。
解决:

  • 检查base.html中CSS引入是否为<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">;
  • 确认static/目录与app.py同级;
  • 在浏览器访问http://localhost:5000/static/css/style.css,应直接下载CSS文件(而非404)。
现象:点击“修改密码”按钮无反应

原因:main.js中事件绑定代码执行时机早于DOM加载完成。
解决:

// 错误写法(可能执行时DOM未就绪) document.getElementById('changePwdBtn').addEventListener('click', function() { ... }); // 正确写法(等待DOM加载完成) document.addEventListener('DOMContentLoaded', function() { const btn = document.getElementById('changePwdBtn'); if (btn) { btn.addEventListener('click', function() { // 表单验证逻辑 const oldPwd = document.getElementById('old_password').value; if (!oldPwd) { alert('请输入原密码'); return; } // 提交表单 document.getElementById('changePasswordForm').submit(); }); } });
现象:学生页面显示{{ student_name }}而非真实姓名

原因:Flask路由未将变量传入模板,或Jinja2语法错误。
解决:

  • 检查路由函数是否用render_template('student/profile.html', student=student_data)传参;
  • 检查模板中是否写成{{ student.student_name }}(若传入的是字典)或{{ student.name }}(若传入的是对象);
  • 在模板顶部加{% if student %}DEBUG: {{ student }}{% endif %}快速定位变量是否存在。

5. 避坑指南:课设高频翻车现场与后悔药清单

5.1 MySQL连接拒绝:Host 'localhost' is not allowed to connect

现象:Flask启动时报错mysql.connector.errors.ProgrammingError: 1045 (28000): Access denied for user 'root'@'localhost'。
原因:MySQL 8.0默认认证插件改为caching_sha2_password,而旧版mysql-connector-python不兼容。
解决:

-- 在MySQL命令行执行(替换your_password为实际密码) ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password'; FLUSH PRIVILEGES;

这是课设最常卡住的点,占调试时间40%以上。务必在PyCharm运行前先在MySQL Workbench里执行此SQL。

5.2 触发器不生效:INSERT后student_course无数据

现象:手动执行INSERT INTO major_course VALUES ('001','CS101');,但SELECT * FROM student_course返回空。
原因:触发器创建时未用DELIMITER //,导致MySQL将;当作语句结束符,只执行了CREATE TRIGGER ... BEGIN部分。
解决:

  • 在MySQL命令行中,先执行DELIMITER //;
  • 粘贴完整触发器代码(含END //);
  • 最后执行DELIMITER ;恢复默认分隔符;
  • 用SHOW TRIGGERS LIKE 'trigger_insert_1';确认触发器状态为ACTIVE。

5.3 中文乱码:学生姓名显示为问号(????)

现象:数据库建表时用了CHARACTER SET utf8,但插入中文后查出来是乱码。
原因:MySQL的utf8实际是utf8mb3,不支持emoji和部分生僻汉字;且客户端连接未指定字符集。
解决:

  • 建库时用CHARACTER SET utf8mb4(如2.2节所示);
  • 在get_db_connection()函数中显式设置字符集:
    connection = mysql.connector.connect( host='localhost', database='grade_manage_system', user='root', password='your_password', charset='utf8mb4', # 关键! collation='utf8mb4_unicode_ci' )

5.4 Flask路由404:访问/admin/students报错

现象:URL正确,但Flask返回404 Not Found。
原因:路由函数名与url_for()中名称不一致,或未加@app.route装饰器。
解决:

  • 检查函数定义:@app.route('/admin/students')下方必须是def admin_students():;
  • 检查模板中url_for('admin_students')的字符串是否与函数名完全一致(大小写、下划线);
  • 在PyCharm中用Ctrl+Click点击url_for('xxx'),看能否跳转到对应函数——跳转失败即名称错误。

5.5 密码修改后无法登录:新密码哈希值存入数据库但长度超限

现象:UPDATE student SET password = ?执行成功,但下次登录失败。
原因:password字段定义为VARCHAR(128),而bcrypt哈希值长度约60字符,看似够用,但若误用md5()生成32位字符串再存入,会导致截断。
解决:

  • 用SELECT LENGTH(password) FROM student LIMIT 1;查看实际存储长度;
  • 确保代码中bcrypt.hashpw()生成的哈希值是完整60+字符;
  • 若发现长度异常,检查是否误用了hashlib.md5().hexdigest()。

6. 二次开发实战:从课设到可用原型的3个改造技巧

6.1 支持Excel批量导入成绩:用pandas解析,绕过前端文件上传限制

课设只要求手动录入,但真实教务场景需批量导入。Flask本身不擅长处理大文件,最佳实践是:前端用AJAX上传,后端用pandas解析,分批写入数据库。

import pandas as pd from werkzeug.utils import secure_filename import os @app.route('/teacher/upload_grades', methods=['POST']) @role_required('teacher') def upload_grades(): if 'file' not in request.files: flash('未选择文件') return redirect(url_for('teacher_courses')) file = request.files['file'] if file.filename == '': flash('文件名为空') return redirect(url_for('teacher_courses')) # 安全文件名(防路径遍历) filename = secure_filename(file.filename) if not filename.endswith('.xlsx'): flash('仅支持.xlsx文件') return redirect(url_for('teacher_courses')) # 保存临时文件 temp_path = os.path.join('/tmp', filename) # Linux/macOS,Windows用os.getenv('TEMP') file.save(temp_path) try: # 用pandas读取Excel(需pip install pandas openpyxl) df = pd.read_excel(temp_path, dtype={'student_id': str, 'course_id': str}) # 校验必要列 required_cols = ['student_id', 'course_id', 'grade'] if not all(col in df.columns for col in required_cols): flash('Excel必须包含student_id, course_id, grade列') return redirect(url_for('teacher_courses')) conn = get_db_connection() cursor = conn.cursor() # 分批插入(避免单次SQL过长) batch_size = 100 for i in range(0, len(df), batch_size): batch = df.iloc[i:i+batch_size] # 构造VALUES子句(注意:student_id和course_id是字符串,grade是数字) values = [] for _, row in batch.iterrows(): # 先检查学生和课程是否存在,且属于当前教师 cursor.execute(""" SELECT 1 FROM student s JOIN student_course sc ON s.student_id = sc.student_id JOIN course c ON sc.course_id = c.course_id WHERE s.student_id = %s AND c.course_id = %s AND c.teacher_id = %s """, (row['student_id'], row['course_id'], session['teacher_id'])) if cursor.fetchone(): values.append((row['student_id'], row['course_id'], int(row['grade']))) else: flash(f'跳过无效记录:{row["student_id"]}-{row["course_id"]}') if values: cursor.executemany( "INSERT INTO student_course (student_id, course_id, grade) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE grade = VALUES(grade)", values ) conn.commit() flash(f'成功导入{len(df)}条成绩记录') except Exception as e: flash(f'导入失败:{str(e)}') conn.rollback() finally: cursor.close() conn.close() os.remove(temp_path) # 清理临时文件 return redirect(url_for('teacher_courses'))

关键点:

  • secure_filename()防止../../etc/passwd类攻击;
  • ON DUPLICATE KEY UPDATE处理重复学号-课程组合(更新成绩而非报错);
  • conn.rollback()确保出错时数据不残留;
  • 临时文件存于/tmp而非项目目录,避免污染源码。

6.2 成绩统计图表化:用Chart.js在前端渲染分数分布

报告中教师可“查看统计”,但只是文字描述。加一行JS就能变成可视化图表:

<!-- 在teacher/courses.html中 --> <canvas id="gradeChart" width="400" height="200"></canvas> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script> // 从后端API获取统计数据(假设已提供 /api/teacher/grade_stats?course_id=CS101) fetch(`/api/teacher/grade_stats?course_id={{ course_id }}`) .then(response => response.json()) .then(data => { const ctx = document.getElementById('gradeChart').getContext('2d'); new Chart(ctx, { type: 'bar', data: { labels: ['0-59', '60-69', '70-79', '80-89', '90-100'], datasets: [{ label: '学生人数', data: [data.fail, data.sixty, data.seventy, data.eighty, data.ninety], backgroundColor: [ 'rgba(255, 99, 132, 0.2)', 'rgba(54, 162, 235, 0.2)', 'rgba(255, 205, 86, 0.2)', 'rgba(75, 192, 192, 0.2)', 'rgba(153, 102, 255, 0.2)' ], borderColor: [ 'rgb(255, 99, 132)', 'rgb(54, 162, 235)', 'rgb(255, 205, 86)', 'rgb(75, 192, 192)', 'rgb(153, 102, 255)' ], borderWidth: 1 }] }, options: { scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } <p> <a href="https://download.csdn.net/download/qq_62760217/86730796" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>