
简介这份资源是面向高校数据库课程设计场景的MySQL学生成绩管理系统完整课程设计报告适合正在完成数据库课设、需要参考系统设计与实现思路的计算机相关专业学生。报告以Python Flask为后端框架、MySQL为数据库、HTMLCSSJS为前端技术采用前后端分离方式开发围绕管理员、教师、学生三类角色展开权限划分与功能设计。内容涵盖系统总体设计、需求分析、概要设计、系统实现与测试总结五大章节具体包括功能结构图、数据库概念结构与逻辑结构设计、物理设计、界面设计、主要功能程序代码以及测试用例与总结可帮助读者理清从需求梳理到数据库建表、接口测试的完整课设流程。资源包共1个doc文档大小约3.95MB目录层次清晰便于按章节检索与对照撰写。目前已有5074人学习下载适合需要参考课设报告结构、数据库设计方法与Flask实现细节的读者使用。1. 一份能跑通的数据库课设长什么样期末周前一周教务要出全年级成绩统计人工翻 Excel 翻到眼花还容易把某个人的分数录串行。这份 MySQL 学生成绩管理系统就是冲着这个场景来的Python Flask 做后端MySQL 8.0 存数据HTML/CSS/JS 做前端前后端分离单机就能跑起来。它把管理员、教师、学生三类角色的权限拆开成绩录入、修改、查询、分数段统计、选课关系维护都落在数据库层面。适合正在做数据库课设、想找一个结构完整又不至于过度设计的参考项目的人也适合想练手 Flask 接口和 MySQL 建表约束的开发者。下面按“先看懂表、再跑起来、最后避坑”的顺序拆。2. 七张表定生死从 E-R 到建表语句的落地数据库课设翻车十有八九不是代码写错而是表结构一开始就没想清楚。这份资源把概念结构、逻辑结构、物理设计三层都写进了文档落到 MySQL 里是七个表student、teacher、course、major、admin、student_course、major_course。先把这七张表的关系理顺后面写 Flask 路由才不会来回改字段。2.1 主外键与联合主键的取舍学生表以student_id char(10)为主键专业编号major_id是外键指向major课程表以course_id char(5)为主键teacher_id外键指向教师选课成绩表student_course用(student_id, course_id)联合主键成绩grade允许为空——这个“允许为空”是有意为之代表“已选课但还没录成绩”教师端录入成绩时就是往这条记录上 UPDATE。专业选课表major_course同样是(major_id, course_id)联合主键它承担的是“某专业默认开设哪些课”的模板作用。这里有个设计上的关键点值得说清楚为什么选课关系要拆成major_course和student_course两张表因为业务上学生是按专业批量选课的管理员维护专业选课模板学生实际选课记录再单独落一张表。这样教师统计分数段时只查student_course不会被专业模板干扰。建表顺序也有讲究必须先建被引用的major、teacher、course再建带外键的student、major_course、student_course否则外键约束直接报 1215。2.2 完整建表脚本与字段参数说明下面这段是按依赖顺序整理过的建表脚本可以直接在 MySQL 8.0 客户端里逐段执行。原文档里建表和加外键是分开写的我合并成“先建表、后补约束”的写法方便你中途出错时定位到具体哪一步。-- 建库并切换 CREATE DATABASE grade_manage_system DEFAULT CHARSET utf8mb4; USE grade_manage_system; -- 1. 专业表被学生表、专业选课表引用最先建 CREATE TABLE major ( major_id CHAR(3) PRIMARY KEY, major_name VARCHAR(20) NOT NULL UNIQUE ); -- 2. 教师表被课程表引用 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. 课程表 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, CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) ON DELETE CASCADE ); -- 4. 学生表 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, CONSTRAINT fk_student_major FOREIGN KEY (major_id) REFERENCES major(major_id) ON DELETE CASCADE ); -- 5. 专业选课模板表 CREATE TABLE major_course ( major_id CHAR(3), course_id CHAR(5), PRIMARY KEY (major_id, course_id), CONSTRAINT fk_mc_major FOREIGN KEY (major_id) REFERENCES major(major_id) ON DELETE CASCADE, CONSTRAINT fk_mc_course FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE CASCADE ); -- 6. 学生选课成绩表 CREATE TABLE student_course ( student_id CHAR(10), course_id CHAR(5), grade TINYINT UNSIGNED, PRIMARY KEY (student_id, course_id), CONSTRAINT fk_sc_student FOREIGN KEY (student_id) REFERENCES student(student_id) ON DELETE CASCADE, CONSTRAINT fk_sc_course FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE CASCADE ); -- 7. 管理员表 CREATE TABLE admin ( admin_name CHAR(15) PRIMARY KEY, password VARCHAR(128) NOT NULL );几个参数值得单独拎出来说。grade TINYINT UNSIGNED的取值范围是 0~255业务上成绩只到 100用TINYINT比INT省空间但要注意它不会自动帮你卡 100 的上限录入端得自己校验。credit TINYINT UNSIGNED同理学分 0~10 够用。password VARCHAR(128)是给哈希后的密码留的长度原文档写的是“加密后的密码”实际实现里常见做法是存werkzeug.security的generate_password_hash结果长度会超过 64所以别图省事改成CHAR(32)。ON DELETE CASCADE挂在学生→专业、课程→教师上意味着删一个专业会连带删掉该专业所有学生这在课设演示里方便但真实教务场景是危险操作后面避坑章节会专门讲。2.3 用触发器把“专业选课”同步到“学生选课”原文档里有一个容易被忽略但很能体现数据库课设深度的点在major_course上挂一个 AFTER INSERT 触发器当管理员给某专业添加一门课时自动把该专业下所有学生批量插入student_course成绩置 NULL。这样教师登录后就能直接看到待录成绩的学生名单不用再手动一条条加。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 ;逻辑说明NEW.course_id是刚插入专业选课模板的那门课SELECT ... FROM student WHERE major_id NEW.major_id捞出该专业全部学生逐行插入选课表。参数上要注意DELIMITER //必须加否则 MySQL 客户端遇到触发器体内的分号就提前截断了这是新手最常报的语法错误之一。另外这个触发器只处理 INSERT如果管理员后来删了专业选课记录student_course里的对应行不会自动清理需要再补一个 AFTER DELETE 触发器或者接受这个不一致——课设阶段通常后者就够但答辩时被问到要能说清楚。3. Flask 后端接口三类角色怎么落到路由和会话上表建好只是地基真正让系统“活”起来的是 Flask 那层。这份资源采用前后端分离前端页面通过 fetch 调后端接口后端返回 JSON。三类角色的权限差异不是靠前端藏按钮实现的而是每个接口进来先校验 session 里的角色这一点在课设里经常被做漏。3.1 登录鉴权与 session 角色标记登录接口接收role、username、password三个字段按角色去不同表查。学生和教师的初始密码等于自己的编号管理员固定账号。校验通过后把user_id和role写进 Flask 的 session后续接口用装饰器统一拦截。from flask import Flask, request, session, jsonify from werkzeug.security import check_password_hash import pymysql app Flask(__name__) app.secret_key change-this-in-real-project # 课设可固定生产必须换 def get_conn(): return pymysql.connect( host127.0.0.1, port3306, userroot, passwordyour_password, databasegrade_manage_system, charsetutf8mb4, cursorclasspymysql.cursors.DictCursor ) app.route(/api/login, methods[POST]) def login(): data request.get_json() role, uid, pwd data.get(role), data.get(username), data.get(password) conn get_conn() try: with conn.cursor() as cur: if role student: cur.execute(SELECT student_id, password FROM student WHERE student_id%s, (uid,)) elif role teacher: cur.execute(SELECT teacher_id, password FROM teacher WHERE teacher_id%s, (uid,)) else: cur.execute(SELECT admin_name, password FROM admin WHERE admin_name%s, (uid,)) row cur.fetchone() if not row: return jsonify({code: 401, msg: 账号不存在}) stored list(row.values())[1] # 兼容明文初始密码与哈希密码两种存法 ok (pwd stored) or check_password_hash(stored, pwd) if not ok: return jsonify({code: 401, msg: 密码错误}) session[user_id] uid session[role] role return jsonify({code: 200, role: role}) finally: conn.close()逻辑上session[role]是后面所有权限判断的依据。参数说明app.secret_key必须设置否则 session 写不进去cursorclassDictCursor让查询结果变成字典取值不用记下标。这里我特意写了“明文或哈希都认”的兼容分支因为课设里初始密码往往是明文直接存的学生改密码后才转哈希如果不兼容改过密码的账号反而登不进去这个坑很隐蔽。3.2 教师录成绩与分数段统计的 SQL 写法教师端最核心的两个接口录成绩和看统计。录成绩是对student_course做 UPDATE统计是按分数段 GROUP BY。统计这块很多人写成在 Python 里循环计数其实一条 SQL 就够。app.route(/api/teacher/grade, methods[POST]) def update_grade(): if session.get(role) ! teacher: return jsonify({code: 403, msg: 无权限}), 403 d request.get_json() conn get_conn() try: with conn.cursor() as cur: cur.execute( UPDATE student_course SET grade%s WHERE student_id%s AND course_id%s, (d[grade], d[student_id], d[course_id]) ) conn.commit() return jsonify({code: 200, affected: cur.rowcount}) finally: conn.close() app.route(/api/teacher/stat/course_id) def grade_stat(course_id): if session.get(role) ! teacher: return jsonify({code: 403}), 403 sql SELECT CASE WHEN grade IS NULL THEN 未录入 WHEN grade 60 THEN 不及格 WHEN grade 70 THEN 60-69 WHEN grade 80 THEN 70-79 WHEN grade 90 THEN 80-89 ELSE 90-100 END AS bucket, COUNT(*) AS cnt FROM student_course WHERE course_id %s GROUP BY bucket ORDER BY FIELD(bucket,未录入,不及格,60-69,70-79,80-89,90-100) conn get_conn() try: with conn.cursor() as cur: cur.execute(sql, (course_id,)) return jsonify({code: 200, data: cur.fetchall()}) finally: conn.close()CASE WHEN把连续分数映射成离散桶FIELD()保证输出顺序不是按字母排的乱序而是从“未录入”到“90-100”的业务顺序。cur.rowcount返回受影响行数如果学生根本没选这门课UPDATE 影响 0 行前端就能提示“该学生不在选课名单中”比直接报成功要诚实。参数上注意grade传进来前要在前端或这里做 0~100 的校验TINYINT UNSIGNED只挡负数挡不住 150。3.3 管理员增删改查的通用套路管理员那五类信息学生、教师、课程、专业、专业选课的增删改查结构高度相似没必要每个都手写一遍。常见做法是抽一个通用函数按表名和字段白名单拼 SQL但白名单必须硬编码绝不能把前端传来的表名直接拼进语句。ALLOWED { student: [student_id, student_name, sex, birth_year, province, enter_year, major_id, password], teacher: [teacher_id, teacher_name, sex, birth_year, password], course: [course_id, course_name, year, semester, teacher_id, credit], major: [major_id, major_name], } app.route(/api/admin/table, methods[POST]) def admin_insert(table): if session.get(role) ! admin: return jsonify({code: 403}), 403 if table not in ALLOWED: return jsonify({code: 400, msg: 非法表名}), 400 d request.get_json() cols [c for c in ALLOWED[table] if c in d] placeholders ,.join([%s] * len(cols)) sql fINSERT INTO {table} ({,.join(cols)}) VALUES ({placeholders}) conn get_conn() try: with conn.cursor() as cur: cur.execute(sql, [d[c] for c in cols]) conn.commit() return jsonify({code: 200}) except pymysql.err.IntegrityError as e: return jsonify({code: 409, msg: f约束冲突: {e.args[1]}}) finally: conn.close()ALLOWED字典是安全边界表名只能从这几个键里取字段也只能是白名单里的列这样即使前端被改也注入不进来。IntegrityError单独捕获主键重复、外键不存在都会走到这里把 MySQL 的原始错误信息回给前端调试时比一句“操作失败”有用得多。注意password字段在插入学生/教师时课设阶段常直接存编号明文正式一点的做法是generate_password_hash(uid)这个选择要在文档里写清楚答辩老师大概率会问。4. 单机跑起来环境、初始化与接口自测代码和表结构都有了接下来是把它在自己机器上跑通。这份资源明确说了“未做服务器部署可在单机上测试运行”所以不用折腾 Nginx、Gunicorn 那一套Flask 自带的开发服务器足够。4.1 环境版本与依赖清单原文档给的开发环境是 Windows 10 PyCharm Community 2022.1.1 MySQL 8.0。Python 版本没写死但 Flask 2.x 和 PyMySQL 1.x 组合在 3.8~3.11 上都稳。依赖不多一个 requirements 就够pip install flask pymysql werkzeugwerkzeug其实随 Flask 一起装了单独列出来是因为密码哈希要用它的generate_password_hash/check_password_hash。MySQL 8.0 默认认证插件是caching_sha2_passwordPyMySQL 1.0 以上支持如果用的是更老的 PyMySQL连库时会报Authentication plugin caching_sha2_password cannot be loaded升级 PyMySQL 即可不用去改 MySQL 的认证方式——改认证方式属于给自己埋雷。4.2 初始化数据与启动顺序启动顺序错了会连环报错按这个来先确认 MySQL 服务在跑再执行建表脚本然后插入种子数据至少一个管理员、一个专业、一个教师、一门课、一个学生最后启动 Flask。-- 种子数据密码先用明文编号方便首次登录 INSERT INTO admin VALUES (admin, asdfg13579); INSERT INTO major VALUES (001, 计算机科学与技术); INSERT INTO teacher VALUES (T0001, 某教师, 男, 1985, T0001); INSERT INTO course VALUES (C0001, 数据库原理, 2024, 秋, T0001, 4); INSERT INTO student VALUES (2024000001, A同学, 女, 2006, 某省, 2024, 001, 2024000001); INSERT INTO major_course VALUES (001, C0001); -- 触发触发器自动给学生选课最后那条INSERT INTO major_course会激活 2.3 节的触发器student_course里应该自动出现(2024000001, C0001, NULL)。验证方法SELECT * FROM student_course;看有没有这行。如果没有先查触发器是否创建成功SHOW TRIGGERS;再查学生的major_id是否和插入的专业选课major_id一致——这两个值对不上触发器里的 WHERE 就捞不到人这是最常见的“触发器没生效”原因。启动 Flask 就是flask run或python app.py默认 5000 端口。前端页面用浏览器直接打开 HTML 文件时fetch 请求会因为跨域被拦课设阶段最简单的解法是让 Flask 顺便把静态页面也 serve 了app Flask(__name__, static_folderstatic, static_url_path)或者装flask-cors加一行CORS(app)。用 Postman 测接口则不受跨域影响这也是原文档推荐 Postman 的原因之一。4.3 用 Postman 验证三类角色的接口边界接口自测不要只测“正常路径”重点测权限边界。建一个 Postman collection按下面四组请求走一遍请求方法预期结果/api/loginrolestudent, 正确学号密码POST200返回 rolestudent/api/loginrolestudent, 错误密码POST401密码错误未登录直接请求/api/teacher/stat/C0001GET403学生 session 请求/api/admin/studentPOST403第三、四组是关键很多人只测了登录成功没测“没登录能不能直接调管理接口”结果前端藏了按钮但接口裸奔。Postman 里 session 是靠 cookie 维持的登录请求成功后会自动存 cookie后续请求带上即可如果发现每次请求都 403检查 Postman 设置里 cookie 是否被禁用。5. 避坑清单这五个地方我踩过或见人踩过课设代码能跑和能答辩是两回事下面这几条都是实际调试里高频出现的按“现象→原因→解决”记。现象一建表时报ERROR 1215 Cannot add foreign key constraint。原因被引用的表还没建或者引用列和主键列的类型/字符集不一致比如major_id一边是CHAR(3)一边写成VARCHAR(3)MySQL 认为不是同一类型拒绝建外键。解决严格按 2.2 节的顺序建表所有关联列的类型、长度、字符集保持完全一致建表语句里显式写DEFAULT CHARSET utf8mb4统一字符集。现象二触发器创建成功但插入专业选课后学生选课表没数据。原因触发器体里的SELECT ... WHERE major_id NEW.major_id没匹配到学生通常是种子数据里学生的major_id和专业选课的major_id对不上或者学生表当时是空的。解决先SELECT COUNT(*) FROM student WHERE major_id001;确认有学生再插专业选课触发器不会报错只会静默插入 0 行所以必须手动验证结果。现象三学生改完密码后用新密码登录提示“密码错误”。原因改密码接口存的是哈希值登录接口却还在用pwd stored明文比较。解决登录时两种都比如 3.1 节代码里的(pwd stored) or check_password_hash(stored, pwd)或者统一改密码时就存哈希登录只走哈希比较但要先把存量明文密码迁移成哈希。现象四教师录成绩提示成功但学生端查不到。原因UPDATE 的 WHERE 条件里course_id或student_id有一个对不上rowcount为 0 但接口仍返回 200。解决接口里判断cur.rowcount 0时返回“该学生未选此课”前端据此提示同时检查student_course里是否真有这条联合主键记录没有就先 INSERT 再 UPDATE。现象五删掉一个专业后该专业学生和他们的成绩全没了。原因外键上的ON DELETE CASCADE一路级联专业→学生→选课成绩全被带走。解决课设演示时这是“功能”但要在文档里写明这是级联删除如果不想这么激进把fk_student_major改成ON DELETE RESTRICT删专业前必须先处理学生虽然麻烦但安全。答辩被问到数据完整性时能说清 CASCADE 和 RESTRICT 的区别就是加分项。提示以上五条里第一条和第五条是建表阶段就能规避的后三条属于接口逻辑建议在 Postman 里各写一个对应用例改代码后回归一遍。6. 从课设到能讲清楚用 EXPLAIN 和边界数据验证你的设计课设做完能跑只是及格线答辩时老师问“你这个查询走索引了吗”“成绩录 150 会怎样”答不上来就露怯。这一节给两个具体技巧花不了多少时间但能让你的系统从“能跑”变成“说得清”。第一个技巧是用EXPLAIN看关键查询的执行计划。教师统计分数段那条 SQL 是高频查询在student_course上按course_id过滤而联合主键是(student_id, course_id)course_id在索引的第二位单独按它查用不上索引的最左前缀。跑一下EXPLAIN SELECT * FROM student_course WHERE course_id C0001;如果type是ALL、key是NULL说明全表扫描。数据量小的时候无所谓但你可以顺手加一个INDEX idx_course (course_id)再跑一次看type变成ref。这个对比放进答辩 PPT比贴十张界面截图有说服力。注意别乱加索引student_course写入频繁索引多了拖慢录入一门课几百条记录的场景其实不建也行关键是你能说出“为什么现在不建”。第二个技巧是拿边界数据打自己的脸。成绩字段是TINYINT UNSIGNED试着直接UPDATE student_course SET grade 150 WHERE ...MySQL 在非严格模式下会截断成 255 并给警告严格模式下直接报Out of range value。两种模式行为不同取决于sql_mode里有没有STRICT_TRANS_TABLES。我的习惯是建库时就SET sql_modeSTRICT_TRANS_TABLES,NO_ZERO_DATE;让越界数据在数据库层就被拒绝而不是靠前端一个max100兜着——前端校验是体验数据库约束才是底线。同理学号CHAR(10)你插 11 位会被截断或报错这种测试各做一次心里就有数了。还有一个容易被忽略的验证点并发。两个人同时给同一个学生同一门课录成绩后提交的覆盖先提交的这是业务上可接受的“最后写入生效”但如果你在文档里写了“防止重复录入”就得加乐观锁比如UPDATE ... WHERE grade IS NULL影响 0 行说明已被录过。课设不要求做并发但你能指出这个边界说明你想过真实场景。从那以后我每次交课设前都会强制走一遍“建表脚本从空库重跑 Postman 权限用例 一条 EXPLAIN 一条越界插入”四步下来基本不会在演示现场翻车。希望帮到你。本文还有配套的精品资源点击获取