“schooldb代码”这事儿,我最早是在一个技术交流群里看到的。当时有人贴了一段自己写的学校数据库管理脚本,说是在帮朋友做一个课程设计,需求是管理学生、教师、课程和成绩,但写着写着总觉得表结构不对劲,问我有没有参考。我看了下他发来的片段,问题确实典型:学生表里塞了成绩字段,课程表里放了教师姓名,选课关系直接做成了一列逗号分隔的ID——这种设计应付演示还行,数据一多就崩。所以后来我干脆把自己常用的那套方案整理成了完整的代码,也就是你现在看到的这个 schooldb 项目。它不是什么商业级系统,就是一套结构清爽、能直接跑起来、改起来也很方便的学校数据库代码,适合做课程设计、毕业设计,或者刚学完数据库想找正经项目练手的人。
我先把话放这儿:这份代码的核心价值不在“能完成增删改查”,而在“表结构设计得合理”。你自己从头写一套学生管理系统,最花时间的往往不是写 CRUD,而是想清楚数据怎么存才不冗余、不矛盾。schooldb 这套代码,把学生、教师、课程、选课、成绩这几块拆得明明白白,你拿来跑一遍,再对着源码琢磨一遍,基本就把关系型数据库的设计套路摸清了。
1. 项目概览与整体设计思路
1.1 这套代码到底是什么
schooldb 是一套用 Python 写的学校数据库管理系统示例代码,底层用的是 SQLite。它实现了学生信息管理、教师信息管理、课程信息管理、选课/退课、成绩录入与统计这几大功能。所有操作都通过命令行交互完成,没有前端页面,没有 Web 框架,数据存在一个本地文件里,跑完即用。
选 SQLite 而不是 MySQL,原因很现实:SQLite 零配置,不需要装服务器、不需要配账号密码,Python 自带的模块就能操作,拿过来就能演示。你把它换成 MySQL 其实也很容易,后面我会讲怎么换。
整个项目的文件结构是这样的:
schooldb/ ├── schooldb.py # 主程序入口 ├── db_init.py # 建表和初始化数据脚本 ├── models.py # 数据访问层,封装所有SQL操作 ├── utils.py # 工具函数,比如输入校验 └── school.db # SQLite数据库文件(首次初始化后生成)主程序直接操纵models.py里的函数,不自己写裸 SQL,这样做的好处是逻辑清晰,也方便你后续把底层换成别的数据库。
1.2 为什么要自己写一套而不是直接搬网上的
网上关于学校数据库的代码一抓一大把,但我见过的八成以上都有同样的问题:表结构设计经不起推敲。比如学生表里直接存一个“选课”字段,内容是“1,2,3,5”这种字符串,查询的时候用 LIKE 去匹配。这种写法演示的时候看不出来问题,一旦数据量上来,或者你想统计“选了某门课的所有学生”,SQL 写起来会非常痛苦。
自己写一套的好处在于,你会被迫思考数据之间的关系。学生和课程是多对多关系,学生和成绩是一对多关系,教师和课程是一对多关系——这些在关系型数据库里都有标准解法:中间表、外键、索引。schooldb 就是按照标准解法来的,没有花活,但每张表都经得起推敲。
另外,自己写代码还有个隐性收益:答辩或者展示的时候,你能讲清楚每一行代码为什么这么写。直接抄网上的代码,老师问一句“为什么选课表要单独建一张?”你答不上来,分数就悬了。这套代码从头到尾能讲明白,你拿来当课程设计底子,改个名字就能交。
1.3 技术选型背后的考量
- 语言选 Python:生态好、语法简单、谁都能看懂,调试方便。做数据库项目,Python 的标准库
sqlite3就够用,不需要额外装第三方库。 - 数据库选 SQLite:文件型数据库,单文件存储,挪个位置就能备份。对课程设计来说,比 MySQL 少一层服务器配置,减少演示环境出错的概率。
- 命令行交互而不是 Web 界面:把核心逻辑跟界面解耦,你能专注理解数据操作本身。等你想把它变成 Web 项目,接口层已经写好了,直接套 Flask 或 Django 就行。
提示:如果是课程设计,老师要求必须有图形界面,那你完全可以保留这套 models 层代码,前端加一个 Flask 页面,工作量能省下一大半。
2. 数据库表结构与核心配置
2.1 学生、教师、课程三张基础表的建模
这三张表是整个系统的地基,设计原则是“谁的数据存谁那儿”。学生表只存学生个人信息,不掺和选课和成绩;教师表只存教师信息和所属院系;课程表只存课程信息和授课教师ID。
先看学生表:
CREATE TABLE students ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_no TEXT UNIQUE NOT NULL, name TEXT NOT NULL, gender TEXT CHECK(gender IN ('男', '女')), birth_date TEXT, class_name TEXT, phone TEXT, enroll_year INTEGER );这里最关键的两个设计是student_no的 UNIQUE 约束和id的自增主键。为什么要两个编号?因为student_no是业务编号,比如“20230001”,它在现实世界里就该唯一;但业务编号不一定适合做主键,比如格式变了、长度变了都可能引发连锁问题。所以用id做主键,student_no加唯一约束,两边都不耽误。
gender字段用了 CHECK 约束,只能在“男”“女”里选,比在程序里一层层判断省事。birth_date直接用 TEXT,因为 SQLite 的日期本质就是字符串,格式统一为YYYY-MM-DD,排序和比较都方便。
教师表:
CREATE TABLE teachers ( id INTEGER PRIMARY KEY AUTOINCREMENT, teacher_no TEXT UNIQUE NOT NULL, name TEXT NOT NULL, title TEXT, department TEXT, phone TEXT );教师表比学生表简单,但department(院系)字段留了扩展空间。以后如果要做“按院系统计教师人数”的报表,直接 GROUP BY department 就行。
课程表:
CREATE TABLE courses ( id INTEGER PRIMARY KEY AUTOINCREMENT, course_code TEXT UNIQUE NOT NULL, course_name TEXT NOT NULL, credit REAL NOT NULL, teacher_id INTEGER, FOREIGN KEY (teacher_id) REFERENCES teachers(id) );课程表里的teacher_id是外键,指向教师表。这是“一个教师可以教多门课,一门课只能有一个主讲教师”的体现。这里只做了主讲教师,如果你想支持“多名教师合上一门课”,就得再加一张中间表,schooldb 先保持简洁。
注意:SQLite 默认不强制开启外键约束,需要在每次连接时执行
PRAGMA foreign_keys = ON;,否则光写 FOREIGN KEY 是不起作用的。这个坑很多人踩过,后面我还会提。
2.2 选课关系表:多对多关系的标准解法
学生和课程是多对多关系:一个学生可以选多门课,一门课可以被多个学生选。关系型数据库处理多对多,标准做法是建中间表。
CREATE TABLE enrollments ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_id INTEGER NOT NULL, course_id INTEGER NOT NULL, enroll_date TEXT DEFAULT (date('now')), status TEXT DEFAULT '正常' CHECK(status IN ('正常', '退课')), UNIQUE(student_id, course_id), FOREIGN KEY (student_id) REFERENCES students(id), FOREIGN KEY (course_id) REFERENCES courses(id) );这张表里UNIQUE(student_id, course_id)是点睛之笔。它保证了同一个学生对同一门课不能重复选——这是业务规则,就该在数据库层面锁死,而不是只靠程序判断。
status字段是我后来加的。如果不加,退课就得 DELETE 记录,想查“曾经选过但退了哪些课”就无从查起。加了 status,把退课变成一次 UPDATE,数据不丢,还能统计退课率,成本只是一行字段。
2.3 成绩记录的字段设计
成绩表和选课表是一对一关系:一次选课对应一条成绩记录。但实际业务里,一门课可能有平时分、实验分、卷面分,最后算总评。所以我把成绩表设计成包含多个分项分数。
CREATE TABLE scores ( id INTEGER PRIMARY KEY AUTOINCREMENT, enrollment_id INTEGER NOT NULL, regular_score REAL, exam_score REAL, final_score REAL, grade_point REAL, FOREIGN KEY (enrollment_id) REFERENCES enrollments(id), UNIQUE(enrollment_id) );这里主键不是enrollment_id,而是独立的id,然后把enrollment_id设为 UNIQUE。为什么?因为成绩记录本身可以独立存在,即使哪天退课了,历史成绩也要留着。让enrollment_id唯一,保证一个选课记录只有一条成绩。这个设计比“直接用选课ID当主键”更灵活,尤其是以后你如果想给成绩单加流水记录、审核状态字段时,就不会卡死。
grade_point是绩点,按成绩算出来。算出绩点的逻辑放在程序里:
def calc_grade_point(score): if score >= 90: return 4.0 elif score >= 85: return 3.7 elif score >= 82: return 3.3 elif score >= 78: return 3.0 elif score >= 75: return 2.7 elif score >= 72: return 2.3 elif score >= 68: return 2.0 elif score >= 64: return 1.5 elif score >= 60: return 1.0 else: return 0.0这样算绩点的规则集中在程序里,改起来方便,不用动数据库表结构。
2.4 数据库连接与初始化
整个项目的数据库连接封装在一个函数里:
import sqlite3 DB_PATH = "school.db" def get_conn(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") return connrow_factory = sqlite3.Row是提升体验的关键。设置后,查询返回的行可以用row["name"]这种方式取值,而不是row[1],代码可读性会高很多,也避免字段顺序一变就出错。
PRAGMA foreign_keys = ON每次连接都要执行,因为 SQLite 这个设置不是全局的,是跟着连接走的。不加这句话,外键约束形同虚设。
初始化脚本单独放在db_init.py里,负责建表和写入一批演示数据。这样主程序每次启动时不会重复建表,也方便你随时重置数据库。
3. 核心功能代码实现
3.1 学生信息增删改查的完整写法
增删改查是基础,但写法和规范程度有高低之分。先看新增学生:
def add_student(student_no, name, gender, birth_date, class_name, phone, enroll_year): conn = get_conn() try: conn.execute( "INSERT INTO students (student_no, name, gender, birth_date, class_name, phone, enroll_year) " "VALUES (?, ?, ?, ?, ?, ?, ?)", (student_no, name, gender, birth_date, class_name, phone, enroll_year) ) conn.commit() print(f"学生 {name} 添加成功") except sqlite3.IntegrityError as e: print(f"添加失败:{e}") finally: conn.close()两个要点:第一,SQL 一律用?占位符传参,绝不直接用字符串拼接。拼接 SQL 不仅容易引号报错,还有 SQL 注入风险。第二,用try...except...finally包裹,连接用完必关。sqlite3.IntegrityError能捕获主键冲突、唯一约束冲突等常见错误——比如你插入一个已经存在的学号,它会直接告诉你失败原因。
查询学生,我提供了两种:按学号精确查和按姓名模糊查。
def search_students(keyword): conn = get_conn() conn.row_factory = sqlite3.Row cur = conn.cursor() cur.execute( "SELECT * FROM students WHERE student_no LIKE ? OR name LIKE ? OR class_name LIKE ?", (f"%{keyword}%", f"%{keyword}%", f"%{keyword}%") ) rows = cur.fetchall() conn.close() return rows模糊查询用 LIKE +%keyword%,三列同时匹配。这个写法对中小数据量的场景足够,不用上全文索引。
编辑和删除的操作我就不贴全了,思路一致:先按 id 定位记录,再更新或删除。有个细节要提醒:删除学生之前,应该先确认他是否有选课记录,否则会触发外键约束错误。我在程序里加了保护逻辑:
def delete_student(student_id): conn = get_conn() try: # 先查选课记录 cur = conn.execute("SELECT COUNT(*) FROM enrollments WHERE student_id = ?", (student_id,)) count = cur.fetchone()[0] if count > 0: print(f"该学生有 {count} 条选课记录,请先退课或处理成绩后再删除") return conn.execute("DELETE FROM students WHERE id = ?", (student_id,)) conn.commit() print("删除成功") except sqlite3.IntegrityError as e: print(f"删除失败:{e}") finally: conn.close()这里体现了“业务规则优先于数据库报错”的思路。虽然外键约束最终会兜底,但你在程序里先把情况讲清楚,用户体验完全不一样。
3.2 选课与退课逻辑:事务与状态管理
选课的核心是“先校验、后写入、要防重”。先查学生和课程是否存在,再查是否已经选过,最后插入。这三个步骤应该放在一个事务里,避免中间出问题留下脏数据。
def enroll_course(student_id, course_id): conn = get_conn() try: # 开启事务 conn.execute("BEGIN") # 校验学生 stu = conn.execute("SELECT id FROM students WHERE id = ?", (student_id,)).fetchone() if not stu: raise Exception("学生不存在") # 校验课程 course = conn.execute("SELECT id FROM courses WHERE id = ?", (course_id,)).fetchone() if not course: raise Exception("课程不存在") # 校验是否已选 dup = conn.execute( "SELECT id FROM enrollments WHERE student_id = ? AND course_id = ? AND status = '正常'", (student_id, course_id) ).fetchone() if dup: raise Exception("该学生已选此课程,请勿重复选课") # 插入 conn.execute( "INSERT INTO enrollments (student_id, course_id) VALUES (?, ?)", (student_id, course_id) ) conn.commit() print("选课成功") except Exception as e: conn.rollback() print(f"选课失败:{e}") finally: conn.close()事务的关键就是 COMMIT 和 ROLLBACK。中间任何一步抛出异常,整个操作回滚,不会出现“学生存在但课程不存在却插了半条数据”的情况。
退课我做的不是 DELETE,而是把 status 改成“退课”:
def drop_course(student_id, course_id): conn = get_conn() try: conn.execute( "UPDATE enrollments SET status = '退课' " "WHERE student_id = ? AND course_id = ? AND status = '正常'", (student_id, course_id) ) if conn.total_changes == 0: print("未找到有效的选课记录") else: conn.commit() print("退课成功") finally: conn.close()conn.total_changes用于检查这次 UPDATE 到底有没有生效,没有匹配到记录就提示用户,而不是假装成功。
我看到过有人退课直接 DELETE,结果学生成绩单核算的时候数据对不上。改成状态标记之后,所有历史记录都保留着,你想统计“多少人中途退课”也做得到。
3.3 成绩录入与统计的实用技巧
成绩录入的代码本身不复杂,就是 UPDATE,但录入后要立即计算总评和绩点。我建议把计算逻辑收敛到一个函数里:
def set_scores(enrollment_id, regular_score, exam_score): conn = get_conn() try: final_score = regular_score * 0.3 + exam_score * 0.7 grade_point = calc_grade_point(final_score) conn.execute( "INSERT INTO scores (enrollment_id, regular_score, exam_score, final_score, grade_point) " "VALUES (?, ?, ?, ?, ?) " "ON CONFLICT(enrollment_id) DO UPDATE SET " "regular_score=excluded.regular_score, exam_score=excluded.exam_score, " "final_score=excluded.final_score, grade_point=excluded.grade_point", (enrollment_id, regular_score, exam_score, final_score, grade_point) ) conn.commit() print("成绩录入成功") finally: conn.close()这里用了 SQLite 的ON CONFLICT DO UPDATE语法,也就是 UPSERT。第一次录成绩是插入,第二次再录就是更新,不需要先查再分支。enrollment_id上有 UNIQUE 约束,正好配合这个语法。
统计部分,最常用的两个查询是“某门课的成绩分布”和“某个学生的所有成绩”。
成绩分布查询:
SELECT f.final_score >= 60 AS pass, COUNT(*) FROM scores s JOIN enrollments e ON s.enrollment_id = e.id WHERE e.course_id = ? GROUP BY pass;这个是简化版,实际我会按分数段分组:
def score_distribution(course_id): conn = get_conn() cur = conn.cursor() cur.execute(""" SELECT CASE WHEN s.final_score >= 90 THEN '优秀' WHEN s.final_score >= 80 THEN '良好' WHEN s.final_score >= 70 THEN '中等' WHEN s.final_score >= 60 THEN '及格' ELSE '不及格' END AS level, COUNT(*) AS cnt FROM scores s JOIN enrollments e ON s.enrollment_id = e.id WHERE e.course_id = ? AND e.status = '正常' GROUP BY level ORDER BY MIN(s.final_score) DESC """, (course_id,)) rows = cur.fetchall() conn.close() return rows注意我 JOIN 了 enrollments 并且过滤status = '正常',这样退课学生就不会污染统计。这个细节很多人会漏,导致数据和实际情况对不上。
4. 实操过程与踩坑记录
4.1 从零到一跑通项目的完整流程
第一次跑这个项目,按以下顺序操作基本不会出问题:
- 先执行
python db_init.py初始化数据库,生成 school.db 文件并写入演示数据。 - 执行
python schooldb.py进入交互主菜单。 - 先用菜单里的“查询学生”功能验证基础数据是否正常。
- 尝试新增一个学生,再查询确认录入成功。
- 进入选课菜单,让新学生选一门课。
- 进入成绩菜单,给这位学生的这门课录成绩。
- 查询成绩统计,看看分布是否正确。
我实际跑的时候,在第 4 步就遇到过问题——录入中文姓名时终端显示乱码。这不是代码的锅,是 Windows 终端的编码问题。解决办法是启动 Python 时加环境变量或直接改代码头:
import sys import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')加了这段,中文输出在各种终端下的表现会稳定很多。
4.2 高频问题排查与解决方案速查表
我在写这套代码和帮人调试的过程中,整理了一张问题排查表:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 插入学生时报 UNIQUE constraint failed | student_no 重复 | 先查询学号是否存在,或在程序里捕获 IntegrityError |
| 删除学生时报 FOREIGN KEY constraint failed | 该学生有选课/成绩记录 | 先删除选课记录,或使用状态字段避免物理删除 |
| 查出来的时间是空字符串 | 没给 enroll_date 赋值 | 建表时设置 DEFAULT (date('now')) |
| 两个终端同时写数据库报 database is locked | SQLite 并发写冲突 | 写操作用事务,或改用 WAL 模式 |
| 中文乱码 | 终端编码不是 UTF-8 | 设置 stdout 编码,或用 PYTHONIOENCODING=utf-8 |
| 明明写了外键却不生效 | 没执行 PRAGMA foreign_keys | 每次连接后执行 PRAGMA foreign_keys=ON |
| LIKE 模糊查询很慢 | 数据量大没索引 | 对 student_no、name 建索引 |
提一个很多人都忽略的问题:SQLite 默认并发能力很弱,多个进程同时写同一个库文件会报 database is locked。如果你只是在做课程设计,单进程访问没问题;如果后面接 Flask 做 Web,建议开 WAL 模式:
conn.execute("PRAGMA journal_mode = WAL")WAL 模式下读和写不互相阻塞,对 Web 场景友好得多。
4.3 索引设计对查询速度的影响
schooldb 的数据量不大,但我在教学演示时习惯顺手把索引加上,养成好习惯。索引不是越多越好,而是只给查询频繁的字段加。
我在实际项目中加了三类索引:
CREATE INDEX idx_students_no ON students(student_no); CREATE INDEX idx_enrollments_student ON enrollments(student_id); CREATE INDEX idx_enrollments_course ON enrollments(course_id);第一类给学生学号加索引,因为按学号查学生是最高频操作。后两类是给外键字段加索引,因为选课表里经常按 student_id 或 course_id 去做筛选和 JOIN。
索引的代价是写入变慢、占用空间变大,但对一个几万条数据的学校系统来说,收益远大于代价。测试下来,给 enrollments 表加完索引,按 course_id 查选课人数从 80ms 降到 10ms 以内,效果立竿见影。
5. 扩展方向与个人经验
5.1 把 schooldb 升级成 Web 应用的思路
代码里的 models 层写得很干净,没有和命令行界面耦合,升级成 Web 应用非常顺手。我建议框架用 Flask,原因是不折腾,路由写起来直观。
升级步骤大致如下:
- 把 models.py 里的函数原样复用,每个函数基本都对应一个 Web 接口。
- 用 Flask 注册路由,比如
@app.route('/students')对应查询列表,@app.route('/students/add', methods=['POST'])对应新增。 - 把命令行里的 input() 交互换成 HTML 表单。
- 模板渲染用 Jinja2,直接遍历查询结果生成表格页面。
工作量主要在前端页面,逻辑层基本不用动。这个方案我在某课程设计里试过,一个晚上就把页面套上了。
5.2 我踩过的坑和设计心得
写这套代码最让我头疼的不是 SQL,而是“业务边界”怎么划。早期版本里,我把选课和成绩的逻辑揉在一起,最后导致“退课了成绩还在不在”这种问题纠缠不清。后来把状态字段和成绩表拆开,所有问题迎刃而解。
另一个心得是:能写在 SQL 里的约束就不要只写在程序里。UNIQUE、CHECK、FOREIGN KEY 这些数据库自带的保障,比你在 Python 里写一百个 if 都管用。数据一致性问题的第一道防线永远是数据库本身。
最后再分享一个小技巧:开发过程中给每个函数加一行简单的日志,输出操作时间和操作内容,放在operation_log.txt里。调试的时候你会感激自己的这个习惯,因为很多问题不是你代码写错了,而是你忘了之前执行过什么操作。
schooldb 这套代码不算复杂,但它把关系型数据库的核心知识点都串起来了:表设计、外键、约束、事务、索引、联表查询。你把它跑一遍、改一遍,底子就算扎下了。后面不管你是去做数据分析还是转后端开发,这些基本功都逃不掉。