ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python学校教务系统骨架:选课并发与事务实战

Python学校教务系统骨架:选课并发与事务实战

简介:这是一套面向Python Web开发初学者及课程设计、毕业设计场景的学校教务系统实践包,围绕学生信息、课程安排、成绩录入与查询等日常教务流程的自动化处理展开,可用于练习后端业务逻辑、页面交互与数据库设计的完整串联。压缩包共9个文件,约467KB,包含7个SQL数据库脚本、1个Python主程序文件和1份docx设计论文,分别对应数据表初始化、系统运行入口以及需求分析与架构说明,方便对照源码和文档理解实现思路。目前已有594人学习。读者可结合源码、数据库脚本与论文,梳理学生、教师、课程、成绩等数据表关系及CRUD操作,了解Python Web框架、前端页面与后端交互的组织方式,并参考需求分析、模块划分和测试思路完成课程项目或答辩准备。

1. 一个 zip 包能省掉多少活:教务系统骨架的真实价值

拿到 python 学校教务系统 这个压缩包,多数人的第一反应是解压、翻目录、找app.py,然后卡在数据库连不上。它真正值钱的地方不在界面好看,而在于把学校教务里最绕的那几件事——学生与课程的多对多关系、选课名额扣减、成绩录入、课表冲突判断——用一套能跑的 python 代码先把骨架立住了。适合三类人:做课程设计需要一份有业务厚度的底稿;接了小型教务外包需要快速起量;想练手数据库事务与并发控制。后面按「跑起来 → 读懂表 → 改业务 → 避坑」推。

2. 从解压到登录页:依赖、编码与数据库初始化

这个包能不能在你这台机器上跑起来,八成不取决于代码质量,取决于三件事:Python 版本、依赖清单是否完整、数据库字符集。顺序别搞反,先把依赖锁定,再建库,最后动配置。

2.1 先看目录结构,判断它用的是哪种框架

解压后别急着装包,先用几条命令摸清骨架。不同的目录约定对应不同的启动方式,认错了框架,后面所有命令都是白费。

# 看顶层文件,判断是 Django 还是 Flask ls -la # 找依赖清单,注意有没有版本锁定 find . -maxdepth 2 -name "requirements*.txt" -o -name "Pipfile" -o -name "pyproject.toml" # 找入口文件 find . -maxdepth 2 -name "manage.py" -o -name "app.py" -o -name "run.py" -o -name "wsgi.py"

判断规则很直接:有manage.py并且目录里存在settings.py、urls.py,那是 Django 的路子,启动走python manage.py runserver;只有app.py或run.py,同时出现templates/、static/和一堆blueprints/或views/目录,那是 Flask + Blueprint 的常见组织方式。这两者的数据库初始化方式完全不同,Django 走 migrations,Flask 多数组件走db.create_all()或外部 SQL 脚本。

再看requirements.txt里有没有写死版本号。没写版本的包是最大的不确定性来源,尤其是SQLAlchemy、Flask-SQLAlchemy、Werkzeug这三个,跨大版本会有 API 断裂,比如 2.x 的Query.get()在 2.0 之后的写法变化,会让原本能跑的代码直接抛异常。稳妥做法是先把依赖装进独立虚拟环境,把当前解析出来的版本号回写一份requirements.lock,别用全局环境硬装。

# 用固定的小版本创建环境,避免 3.12 与老依赖冲突 python3.10 -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -U pip pip install -r requirements.txt # 冻结实际解析出的版本,作为可复现的凭据 pip freeze > requirements.lock

参数上说明两点:选 3.10 而不是最新版,是因为大量教务系统的依赖清单里带着mysqlclient、cryptography这类需要编译的包,老版本轮子更全;requirements.lock只用于还原环境,不要提交回原项目目录覆盖原始清单,两者的职责不一样。

2.2 建库与建表:字符集选错,后面全是乱码

数据库这一步最容易翻车的地方不是连不上,而是连上了、表也建了,中文全是问号。教务系统里学生姓名、课程名称、院系名称全是中文,字符集必须是utf8mb4,并且在建库、建表、连接串三处保持一致。

-- 建库时就把字符集和排序规则定死,别依赖服务端默认值 CREATE DATABASE edu_sys DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; -- 单独建一个业务账号,不要用 root 跑应用 CREATE USER 'edu_app'@'localhost' IDENTIFIED BY '换成你自己的强密码'; GRANT SELECT, INSERT, UPDATE, DELETE ON edu_sys.* TO 'edu_app'@'localhost'; FLUSH PRIVILEGES;

这里给的是最小权限:只给增删改查,不给DROP、ALTER。原因很现实——应用层代码里只要有拼接 SQL 的地方,权限就是最后一道闸。建表脚本如果包里自带schema.sql或init.sql,直接导入;如果是 Django 项目,跳过导入,改完DATABASES后跑迁移。

# 情况一:包内自带 SQL 脚本 mysql -u edu_app -p --default-character-set=utf8mb4 edu_sys < sql/schema.sql # 情况二:Django 项目,先生成迁移再执行 python manage.py makemigrations python manage.py migrate

导入完成后一定要验证,别信「没报错就是成功」:

SHOW VARIABLES LIKE 'character_set_database'; SHOW TABLES; SELECT table_name, table_collation FROM information_schema.tables WHERE table_schema = 'edu_sys';

期望结果是utf8mb4/utf8mb4_general_ci。如果表已经建成latin1或utf8mb3,别指望改连接串能救回来,只能改表:ALTER TABLE student CONVERT TO CHARACTER SET utf8mb4;,逐表执行,数据量大的时候记得先在备份库上试。

配置层面,连接串里的参数要显式带上字符集,不要靠默认值:

# config.py —— 连接串显式声明字符集,避免驱动走服务端默认 SQLALCHEMY_DATABASE_URI = ( "mysql+pymysql://edu_app:密码@127.0.0.1:3306/edu_sys" "?charset=utf8mb4" ) SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_ENGINE_OPTIONS = { "pool_pre_ping": True, # 连接空闲被服务端断开时自动重连 "pool_recycle": 280, # 小于 MySQL 默认 wait_timeout(28800s) 里的常见 8 小时 }

pool_pre_ping这个参数值得单独说:教务系统典型使用场景是上班时间集中访问,中午和夜里几乎没请求,连接池里的连接会被数据库按wait_timeout掐掉。没有这个参数,第二天早上第一个请求必然报Lost connection to MySQL server,而重启应用又能好,这种玄学问题排查起来最费时间。

2.3 首次启动:管理员账号与登录态验证

表建好之后,多数包会在初始化脚本里塞一个默认管理员。别直接用,先看密码是不是明文存在数据库里——这是判断这套代码安全水位最直接的信号。

# 用一段一次性脚本检查账号表,不登录后台也能看 from app import create_app, db from sqlalchemy import text app = create_app() with app.app_context(): rows = db.session.execute( text("SELECT id, username, LENGTH(password) AS plen FROM sys_user LIMIT 5") ).fetchall() for r in rows: print(r.id, r.username, r.plen)

如果plen是 6~20 的短数字,基本可以确认是明文或简单 MD5,交付前必须换成带盐哈希。改造方式不复杂,加一个注册/改密时调用的哈希函数,把老数据批量刷一遍即可。接着启动服务并验证登录链路,不要只在浏览器里点,命令行能更快定位问题出在路由、模板还是静态资源。

# Flask 项目 export FLASK_APP=app.py export FLASK_ENV=development flask run --host 0.0.0.0 --port 5000 # 另一终端验证:拿到登录页状态码与 Cookie curl -i -c cookie.txt http://127.0.0.1:5000/login curl -i -b cookie.txt -d "username=admin&password=你的密码" \ -X POST http://127.0.0.1:5000/login

返回 302 且Location指向首页,说明会话建立成功。如果返回 200 且页面内容还是登录页,通常是 CSRF token 校验失败——Flask-WTF 开启保护后,表单必须带csrf_token字段,命令行提交要先把 token 从 HTML 里抠出来。这一步能跑通,才说明这套 python 教务系统的骨架是活的,可以往下读数据模型。

3. 数据模型怎么读:六张核心表决定了你能改多深

教务系统的复杂度几乎全在关系上。一个学生选多门课、一门课有多个学生、一门课有多个上课时段、一次选课对应一条成绩——这四层关系没理顺,后面的选课和成绩功能全是空中楼阁。

3.1 六张表与关键字段的对应关系

不管包的原始命名是student还是t_student,结构基本跑不出这张表。字段名以你的实际项目为准,但约束位置是一致的。

表作用关键字段必须存在的约束
student学生档案sno 学号、name、class_id、statussno 唯一
teacher教师档案tno 工号、name、depttno 唯一
course课程与开课信息cno 课程号、credit 学分、capacity、selected、teacher_id、semester(cno, semester) 唯一
course_schedule上课时段course_id、weekday、start_section、end_section、week_start、week_endcourse_id 外键
enrollment选课记录student_id、course_id、semester、created_at(student_id, course_id, semester) 唯一
score成绩enrollment_id、score、exam_typescore 在 0~100,enrollment_id 唯一

这里有两个容易被忽略的点。第一,capacity和selected必须放在course表而不是单独的名额表,因为选课扣减需要一条原子 UPDATE 完成「判断 + 扣减」,拆表就得多一次查询,锁的范围立刻变大。第二,成绩挂在enrollment上而不是直接挂学生和课程,好处是退课后记录自然失效,坏处是重修场景需要多条 enrollment,命名上得用semester区分。

提示:如果你拿到的包把成绩直接存在选课表里,也能用,但补考、重修、多次考试类型的扩展会很别扭,改造成本要在接手时就评估清楚。

课程资源这类附件(课件、实验指导书)通常不在核心六表里,而是单独一张course_material表,挂course_id、文件名、存储路径、上传人。如果你的包里有这张表,说明它是往「课程资源管理」方向做过的版本,比纯选课 Demo 完整,值得优先跑通。

3.2 用 SQL 把数据一致性查一遍

别信界面上显示正常,脏数据都藏在界面看不到的角落。下面几条查询建议在改动任何业务代码之前先跑一遍,作为基线。

-- 1. 孤儿选课:引用了不存在或已删除的课程 SELECT e.id, e.student_id, e.course_id FROM enrollment e LEFT JOIN course c ON c.id = e.course_id WHERE c.id IS NULL; -- 2. 名额越界:已选人数超过容量,或与真实记录数不符 SELECT c.id, c.cno, c.capacity, c.selected, COUNT(e.id) AS real_cnt FROM course c LEFT JOIN enrollment e ON e.course_id = c.id AND e.semester = c.semester GROUP BY c.id, c.cno, c.capacity, c.selected HAVING c.selected > c.capacity OR c.selected <> COUNT(e.id); -- 3. 成绩越界与重复 SELECT enrollment_id, COUNT(*) AS cnt, MIN(score), MAX(score) FROM score GROUP BY enrollment_id HAVING COUNT(*) > 1 OR MIN(score) < 0 OR MAX(score) > 100; -- 4. 学分统计:按学生汇总,与课程学分对照 SELECT s.sno, s.name, SUM(c.credit) AS total_credit FROM enrollment e JOIN student s ON s.id = e.student_id JOIN course c ON c.id = e.course_id WHERE e.semester = '2024-2025-1' GROUP BY s.sno, s.name ORDER BY total_credit DESC;

第 2 条最值得跑。selected是冗余字段,只要有并发或者有代码走了「先查后改」的路径,它和真实记录数迟早对不上。对不上不一定是代码 bug,也可能是历史数据迁移遗留,但你必须知道当前差多少,否则后面做名额扣减时无法判断是新增问题还是旧账。

3.3 索引怎么加:让选课页从 3 秒降到 200 毫秒

万级数据时全表扫看不出问题,十万行选课记录之后就藏不住了。索引不是越多越好,写多读少的表加多了反而拖慢插入。

-- 选课记录:按学生查课表、按课程查名单,两个方向都要快 CREATE UNIQUE INDEX uk_enroll ON enrollment (student_id, course_id, semester); CREATE INDEX idx_enroll_course ON enrollment (course_id, semester); -- 课程表:按学期列课程是最高频查询 CREATE INDEX idx_course_semester ON course (semester, teacher_id); -- 成绩表:按学生出成绩单是刚需 CREATE INDEX idx_score_enroll ON score (enrollment_id);

复合索引的顺序有讲究。(student_id, course_id, semester)这个顺序能同时服务「查某学生选了哪些课」和「判断某学生是否已选某课」,因为最左前缀原则。反过来写成(course_id, student_id, semester),查课表就命中不了。唯一索引在这里不只是加速,更是并发安全的第一道防线——两个人同时提交同一门课,数据库会直接拒绝第二条,比应用层查重可靠得多。

4. 业务逻辑层:选课并发与成绩录入的两块硬骨头

界面能点不代表逻辑对。教务系统真正会出线上事故的地方只有两处:选课那一瞬间的名额竞争,和成绩批量导入时的事务边界。

4.1 名额扣减:把「先查后改」换成一条原子 UPDATE

典型的错代码长这样:先SELECT查selected < capacity,再UPDATE selected = selected + 1。两个请求同时进来,都读到还有 1 个名额,都去加,结果超额。这不是理论问题,选课开放的头十分钟必然发生。

from sqlalchemy import text from sqlalchemy.exc import IntegrityError class SeatsFull(Exception): pass def enroll(student_id: int, course_id: int, semester: str) -> None: """选课:先原子扣减名额,再写选课记录,最后提交。""" try: # 第一步:条件更新,容量判断和自增在同一条语句里完成 result = db.session.execute( text(""" UPDATE course SET selected = selected + 1 WHERE id = :cid AND semester = :sem AND selected < capacity """), {"cid": course_id, "sem": semester}, ) if result.rowcount == 0: # 没更新到行,要么名额满了,要么课程不存在 raise SeatsFull(f"course {course_id} 名额已满或不存在") # 第二步:写选课记录,唯一索引兜底重复选课 db.session.execute( text(""" INSERT INTO enrollment (student_id, course_id, semester, created_at) VALUES (:sid, :cid, :sem, NOW()) """), {"sid": student_id, "cid": course_id, "sem": semester}, ) db.session.commit() except SeatsFull: db.session.rollback() raise except IntegrityError: # 唯一索引冲突 = 重复选课,扣减必须一起回滚 db.session.rollback() raise ValueError("已经选过这门课")

逻辑说明:UPDATE ... WHERE selected < capacity在 InnoDB 下会对命中行加行锁,两个并发请求会被串行化,第二个请求看到的是已经自增后的值,条件不再成立,rowcount为 0。这就是「判断与修改合并」的价值,它把应用层无法保证的原子性交给了数据库。

参数说明:selected和capacity必须同表同类型,都是INT UNSIGNED更保险,防止负数;semester进条件是为了避免不同学期共用一条课程记录时串号。IntegrityError分支里必须rollback,否则前面那次UPDATE已经加了 1 但选课记录没写进去,名额就白扣了——这是最隐蔽的一类数据不一致。

如果业务上要支持「退课」,对称地写一条selected = selected - 1 WHERE selected > 0,同时删记录,两个操作放在同一个事务里,不要分两次提交。

4.2 成绩批量导入:校验在前,写库在后

老师交上来的成绩单是 Excel,格式五花八门:学号带空格、分数是文本、有人缺考写「缺」、有人直接空着。直接在循环里逐行commit,一旦第 87 行炸了,前 86 行已经落库,重跑就重复。正确顺序是:全部读进内存 → 全量校验 → 校验通过再开一个事务写入。

import openpyxl from decimal import Decimal, InvalidOperation def parse_grade_file(path: str) -> tuple[list[dict], list[str]]: """解析成绩单,返回 (合法行, 错误信息)。不碰数据库。""" wb = openpyxl.load_workbook(path, data_only=True) ws = wb.active valid, errors = [], [] for idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): sno = str(row[0]).strip() if row[0] else "" raw = row[1] if not sno: errors.append(f"第{idx}行:学号为空") continue # 缺考、免修等非数字状态单独标记,不混进分数 if isinstance(raw, str) and raw.strip() in ("缺", "缺考", "免修"): valid.append({"sno": sno, "score": None, "status": raw.strip()}) continue try: score = Decimal(str(raw).strip()) except (InvalidOperation, AttributeError): errors.append(f"第{idx}行:分数无法解析 -> {raw!r}") continue if not (0 <= score <= 100): errors.append(f"第{idx}行:分数越界 {score}") continue valid.append({"sno": sno, "score": score, "status": "正常"}) return valid, errors

写库部分单独一层,把所有行塞进一个事务:

def save_grades(course_id: int, semester: str, rows: list[dict]) -> int: """整批写入,任何一行失败全批回滚。返回成功条数。""" try: for r in rows: # 通过学号反查 enrollment,找不到说明学生没选这门课 res = db.session.execute( text(""" UPDATE score sc JOIN enrollment e ON e.id = sc.enrollment_id JOIN student s ON s.id = e.student_id SET sc.score = :score, sc.status = :status WHERE s.sno = :sno AND e.course_id = :cid AND e.semester = :sem """), {"score": r["score"], "status": r["status"], "sno": r["sno"], "cid": course_id, "sem": semester}, ) if res.rowcount == 0: raise ValueError(f"学号 {r['sno']} 没有该课程的选课记录") db.session.commit() return len(rows) except Exception: db.session.rollback() raise

关键在rowcount == 0这一判断:它把「学生没选这门课却出现在成绩单里」这种数据错误提前暴露出来,而不是静默跳过。整批一个事务,失败就全退,老师改完 Excel 重传即可,不会产生半截数据。十万行以内这个写法足够,真到几十万行再考虑executemany分批提交。

4.3 课表冲突:把时间片编码成整数做位运算

判断两门课是否冲突,本质是判断两个时间区间是否重叠。教务场景里时间是离散的:第几周、星期几、第几节。把三段编码进一个整数,冲突判断就变成一次按位与。

def slot_mask(week_start: int, week_end: int, weekday: int, sec_start: int, sec_end: int) -> int: """把一个上课时段压成一个整数位图:周次段 × 星期 × 节次段。""" mask = 0 for w in range(week_start, week_end + 1): for s in range(sec_start, sec_end + 1): # 每个 (周, 星期, 节) 组合占一位,位序号唯一 bit = (w - 1) * 7 * 12 + (weekday - 1) * 12 + (s - 1) mask |= (1 << bit) return mask def has_conflict(mask_a: int, mask_b: int) -> bool: return (mask_a & mask_b) != 0

这个写法的好处是把「区间重叠判断」的四种情况(完全包含、部分重叠、反向包含、首尾相接)压缩成一行与运算,不会漏判。12是每天最大节次,7是一周天数,改这两个常量就能适配不同学校的作息。位图存成BIGINT落库,排课算法里直接查库取 mask 做与运算,比每次展开区间比较快一个数量级。

需要提醒的是单双周。很多学校的课程是单周或双周上课,上面这个按周枚举的写法天然支持,只要在range里加步长或者传入周次列表即可,不需要额外字段。

5. 避坑与排查:五个真实翻车现场

5.1 登录成功但一刷新就退出

现象:输入账号密码能进首页,点任意链接又跳回登录页,日志无报错。

原因:会话密钥SECRET_KEY每次启动随机生成,或者多进程部署时各进程密钥不一致,导致签名对不上,Cookie 直接失效。

解决:把SECRET_KEY固定到环境变量里,不要写在代码里,也不要每次启动os.urandom(24)。多 worker 部署时所有进程必须读同一个值。排查手段是看响应头里的Set-Cookie是否每次请求都在变。

5.2 选课人数和实际名单对不上

现象:课程显示已选 45 人,点进去名单只有 43 条。

原因:扣减名额的UPDATE成功但后续插入失败,中间没有回滚;或者历史数据里存在已删除学生但选课记录未清理。前者是新账,后者是旧账,必须分开处理。

解决:先用 3.2 节第 2 条 SQL 定位差异课程,再跑一次全量校准脚本,把selected重算为真实记录数:UPDATE course c SET selected = (SELECT COUNT(*) FROM enrollment e WHERE e.course_id = c.id AND e.semester = c.semester);。校准之后,代码层面把扣减和插入包进同一事务,从源头堵住。

5.3 成绩导入报编码错误

现象:Excel 明明是正常的,导入时报UnicodeDecodeError或中文全变问号。

原因:老版本.xls与.xlsx处理方式不同,而且 Windows 下 Excel 导出的 CSV 常带 BOM 头,用utf-8直接读会把 BOM 当成内容。

解决:.xlsx一律用openpyxl或pandas.read_excel,不要自己解析;CSV 用encoding="utf-8-sig";上传环节同时校验扩展名和文件头,别只看后缀。数据库侧再确认一次utf8mb4,三处一致才不会出乱码。

5.4 SQLite 版跑着跑着报 database is locked

现象:单机测试没问题,几个人同时点选课,页面卡住然后报锁。

原因:SQLite 写操作是库级锁,一个写事务没提交,其他写全部阻塞。包内如果默认用 SQLite 方便演示,它就不适合多人并发的选课场景。

解决:开发阶段用 SQLite 图省事可以,但只要涉及并发写,切 MySQL 或 PostgreSQL。切换时注意自增主键语法、日期函数(NOW()vsdatetime('now'))、LIMIT分页写法这几处差异,别指望连接串一改就全兼容。

5.5 分页一加,列表页反而更慢

现象:学生名单做了分页,每页 20 条,翻到后面几页明显变卡。

原因:用了LIMIT offset, size的大偏移量分页,数据库要先扫过前 offset 行再丢弃;同时列表页每行都去关联查一次班级名,典型的 N+1 查询。

解决:小数据量下先解决 N+1,用一次JOIN把班级名带出来,别在模板里循环调 ORM;数据量真到几十万行再改游标分页(WHERE id > last_id ORDER BY id LIMIT 20)。另外确认sort的字段上有索引,否则ORDER BY会触发文件排序。

6. 交付前的一次回归:把权限和事务各压一遍

改完业务逻辑,最容易漏的是权限粒度。原始包常见做法是登录后所有菜单都能点,只是前端隐藏按钮——这等于没有权限。后端每个写接口都要独立校验角色,不能依赖模板里的if。

一个成本很低但很有效的做法:给路由加统一的装饰器,把「谁能调」写成声明式。

from functools import wraps from flask import abort, session def require_role(*roles): def deco(fn): @wraps(fn) def wrapper(*args, **kwargs): if session.get("role") not in roles: abort(403) # 后端直接拒绝,不靠前端隐藏 return fn(*args, **kwargs) return wrapper return deco @app.route("/score/import", methods=["POST"]) @require_role("teacher", "admin") def import_score(): ...

配合 pytest 做一次最小回归,重点不是覆盖率,而是把「越权」和「事务回滚」这两类问题固化成用例:用学生角色请求成绩导入接口断言 403;构造一条会失败的成绩行,断言整批回滚、数据库条数不变。

自检项通过标准
越权访问学生调教师接口返回 403
重复选课并发两次提交,仅一条记录,名额只减 1
成绩导入失败全批回滚,score 表行数不变
字符集中文姓名、课程名存取无乱码
空数据无选课学生查课表返回空页而非 500

血泪经验是:这五项里最容易被跳过的是第三项,因为「导入失败」不好造。但恰恰是它,一旦上线才发现,就得手工比对 Excel 和数据库,后悔药很贵。我现在的习惯是,每次动过事务边界的代码,先故意让中间一行抛异常,看着数据退回去,再改回来。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表