前阵子帮几个朋友带了一轮 Python 入门,发现大家有个共同的坎:语法、列表、字典、函数都能背得出来,但一旦让写一个稍微完整的项目,就开始头大。正好那时候网上流传一道综合训练题——用 Py 写个简单的教务系统,我顺手就把完整版本写了出来。这个项目表面上是“管理系统”,实际上是一次很扎实的综合训练,基本上把 Python 里常用的面向对象、文件读写、数据校验、异常处理、循环分支这些知识点全部串了起来。做完以后最大的感受是:教务系统本身不复杂,但代码结构怎么设计、数据怎么存、流程怎么走,非常锻炼一个人的工程感觉。这篇文章把我做项目的完整过程拆开讲,适合已经学完 Python 基础的读者,也适合想找练手项目的同学照着做。
1. 先把需求想清楚:这个教务系统到底要管哪些事
1.1 从教务场景提炼核心实体
任何系统在写代码前,需求梳理永远排在第一位。教务系统的本质是“管理人和课之间的关系”,所以在动手前,我先把核心实体拆成了四块:
| 实体 | 关键属性 | 主要业务动作 | 通常由谁执行 |
|---|---|---|---|
| 学生 | 学号、姓名、性别、年级、专业 | 注册、选课、退课、查成绩 | 学生本人 |
| 教师 | 工号、姓名、职称、所属院系 | 查看授课名单、录入成绩 | 教师 |
| 课程 | 课程号、课程名、任课教师、学分、上课时间、容量 | 创建、调整、停用 | 管理员 |
| 选课记录 | 学号、课程号、成绩、学期 | 选课、退课、评分 | 以上角色都会涉及 |
拆完这张表,系统里最重要的“关系”也就清楚了:学生和课程之间是多对多关系,中间需要一张选课记录来衔接;教师与课程之间的关系虽然看起来简单,但实际写到代码里时很容易被忽略——课程表里的“任课教师”字段一定要和教师列表保持一致性,否则会出现“课程上写着王老师,教师列表里却查无此人”的尴尬局面。
1.2 划清功能范围:第一版只做这些事
很多初学者有个误区,拿到一个“教务系统”的需求,就想把账号密码、权限分级、分页、搜索、导出报表全部做上。实际上作为综合训练项目,第一版要覆盖的核心功能不是堆砌功能点,而是把业务闭环走通。
必要功能是这些:
- 学生信息:增、删、改、查
- 课程信息:增、删、改、查
- 选课:学生可选课、可退课,同一门课不能重复选
- 成绩:教师按课程录入成绩,学生可查看自己成绩
- 统计:课程平均分、最高分、最低分
不必要功能,或者说第二版再考虑的功能:
- 真正的登录和密码验证(这里用三种角色的菜单模拟就行)
- 并发控制(单机训练项目完全用不上)
- 数据库(用 JSON 文件持久化就够,后面有需要再替换成 SQLite)
我当时就是按这个标准给自己划界。把无用装饰拆掉以后,整个项目的工作量控制在三天以内,而知识点覆盖一点没少,这才是综合训练的意义。
2. 存储方案决定代码边界:为什么选 JSON 而不是数据库
2.1 JSON 文件做持久化的三个理由
先回答一个最常见的问题:为什么不用 MySQL 或 SQLite?
因为这个项目是 Python 综合训练,重点不是数据库设计。上数据库会引入环境依赖、建表语句、事务思维、ORM 选型等一系列新概念,初学者很容易掉进“安装数据库”的坑,反而忽略了 Python 本身的能力。
JSON 文件在这个场景里是最合适的:
第一,Python 内建的 json 模块开箱即用,不需要任何安装步骤;
第二,JSON 的 dict/list 结构天然对应 Python 的数据类型,读写之后不需要做二次映射,新手理解起来零门槛;
第三,JSON 是纯文本,出问题可以直接打开看,删掉一行、改掉一个字段就能手动修复,调试体验比二进制格式好太多。
我用的数据文件结构是下面这个样子:
{ "students": [ { "student_id": "20240001", "name": "张三", "gender": "男", "grade": "2024", "major": "计算机科学与技术" } ], "teachers": [ { "teacher_id": "T001", "name": "李老师", "department": "计算机学院", "title": "副教授" } ], "courses": [ { "course_id": "CS101", "course_name": "Python编程", "teacher_id": "T001", "credit": 3, "time_slot": "周三第3-4节", "max_students": 40 } ], "enrollments": [ { "enrollment_id": 1, "student_id": "20240001", "course_id": "CS101", "semester": "2025春季", "score": null } ] }字段设计上也有讲究。比如 enrollments 里我专门放了一个 enrollment_id。虽然它对训练项目来说有点“冗余”的嫌疑,但从扩展性看还是值得的——后面做退课、改成绩、按学期筛选时,都需要一条稳定且唯一的记录标识。
2.2 数据读写封装:写一个 DataStore 类集中处理
如果把读写 JSON 的逻辑分散在各个函数里,项目写着写着就会乱:有人用 json.dump,有人手动拼字典,有人直接改全局变量却忘了和文件同步,最后数据全散了。我的做法是把所有数据操作统一到一个 DataStore 类里,这样以后哪怕换存储方案,只需要改这个类,上层业务代码一行不用动。
import json import os class DataStore: def __init__(self, file_path="data.json"): self.file_path = file_path self.backup_path = file_path.replace(".json", "_backup.json") self.data = self.load_all() def load_all(self): if not os.path.exists(self.file_path): return {"students": [], "teachers": [], "courses": [], "enrollments": []} try: with open(self.file_path, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError): backup_data = self.load_backup() print("数据文件读取失败,已自动切换到备份数据") return backup_data def load_backup(self): if os.path.exists(self.backup_path): with open(self.backup_path, "r", encoding="utf-8") as f: return json.load(f) return {"students": [], "teachers": [], "courses": [], "enrollments": []} def save_all(self): # 先把原文件复制成备份,再写新文件 if os.path.exists(self.file_path): try: os.replace(self.file_path, self.backup_path) except OSError: pass with open(self.file_path, "w", encoding="utf-8") as f: json.dump(self.data, f, ensure_ascii=False, indent=2)两个细节值得说一下。
第一,json.dump一定要加上ensure_ascii=False和indent=2,否则中文全都会变成一串\uXXXX转义,打开文件根本没法看,调试的时候全靠猜。
第二,save_all的备份策略是用os.replace先把旧文件改成备份,再写新文件。这种做法避免了每保存一次就覆盖一次原文件,万一写到一半程序崩溃,还能从备份里恢复。很多初学者不会在意这一步,但真实系统里数据损坏这种事一旦遇到,有没有备份,心态完全是两回事。
3. 面向对象建模:按职责拆类,而不是把一切塞进函数
3.1 围绕角色和使用场景定义模型类
这个项目虽然是“综合训练”,但也没必要上特别复杂的架构。我用到的类数量很少,刚接触面向对象的人也能看懂。项目文件结构大概长这样:
teaching_management/ ├── main.py # 入口,负责菜单和交互 ├── models.py # Person / Student / Teacher / Course ├── service.py # BusinessService:核心业务逻辑 ├── data_store.py # DataStore:文件读写和备份 ├── data.json # 程序运行后自动生成 └── test_service.py # 单元测试模型层的定义方式很朴素,就是基础的继承关系:
class Person: def __init__(self, person_id, name, gender): self.person_id = person_id self.name = name self.gender = gender class Student(Person): def __init__(self, student_id, name, gender, grade, major): super().__init__(student_id, name, gender) self.grade = grade self.major = major class Teacher(Person): def __init__(self, teacher_id, name, gender, department, title): super().__init__(teacher_id, name, gender) self.department = department self.title = title有同学会问:Student 里有选课方法吗?我的答案是不放。这里其实触碰到了设计本质:Student 是“学生”这个实体的模型,它不应该知道完整的课程库、文件存储这些全局信息。如果让 Student 直接操作全局数据,测试和替换都非常麻烦。更干净的做法是让 BusinessService 接收 DataStore,在 Service 层完成校验和持久化。
3.2 Service 层和模型类分开放,后面测试省大事
把业务逻辑放在 Service 层,有一个非常实际的好处:隔离 IO。如果你的选课逻辑直接操作文件对象,将来写单元测试就得临时创建真实文件,麻烦且不稳定。而一旦逻辑都在 Service 里,测试的时候只需要构造一个内存里的 DataStore 再传进去,所有场景都可以干干净净地验证。
选课的核心方法大概长这样:
class BusinessService: def __init__(self, store: DataStore): self.store = store def enroll(self, student_id, course_id, semester="2025春季"): student = self.find_student(student_id) course = self.find_course(course_id) if not student: raise ValueError("学生不存在") if not course: raise ValueError("课程不存在") if self.is_already_enrolled(student_id, course_id): raise ValueError("已经选过这门课") if self.get_enroll_count(course_id) >= course["max_students"]: raise ValueError("课程容量已满") for e in self.get_student_enrollments(student_id): existing_course = self.find_course(e["course_id"]) if (existing_course and existing_course["time_slot"] == course["time_slot"]): raise ValueError( f"时间冲突:已选了《{existing_course['course_name']}》" ) # 通过所有校验后再加入数据 ...这样做的好处一眼就能看出来:所有校验像一道一道闸门,任何一个条件不满足,函数直接抛出异常,后面的数据根本不会进入持久化。新手最常见的错误是先往列表里写记录,再在后面做校验,发现不对又删,最后 debug 半天都查不出来问题出在哪。
4. 三条关键业务链路:选课冲突检测、成绩录入和统计计算
4.1 选课冲突的三个边界,一开始就要堵住
选课是教务系统里最容易出错的地方,我整理了一张边界情况表:
| 场景 | 处理方式 | 为什么这样处理 |
|---|---|---|
| 重复选课 | 先查 enrollments 中是否已有同课程记录 | 防止数据重复,避免后续查询和报表时出现一行变两行 |
| 课程容量已满 | 比较当前选课人数和 max_students | 超员问题在现实中非常常见,先挡住数据层的错误 |
| 上课时间冲突 | 遍历学生已选课程,比对 time_slot 字段 | 时间冲突最隐蔽,不校验就几乎一定会漏课 |
时间冲突这块值得展开讲。我用的是简单的前后字符串相等判断,比如“周三第3-4节”和“周三第3-4节”会冲突。如果课程表变成“周一1-2节”和“周一2-3节”,这种字符串比较自然就不够用了,得先把时段解析成(day, start_period, end_period)再去比较。但作为训练项目,第一版用字符串相等判断完全够用。我在代码上方留了注释,说明后续要支持更复杂的时间比较时,可以往哪个方向扩展。
4.2 成绩录入的“反复校验”和统计输出
成绩录入最容易踩的坑是“输入一个非法成绩就把前面的录入全部冲掉”。我的做法是给成绩录入单独写一个循环,只在当前这轮内反复输入,如果中途出错就重新输这条,不影响前面已经录好的数据。
课程成绩的录入代码差不多是这个逻辑:
def input_scores(self, course_id): students = self.get_course_students(course_id) if not students: print("这门课还没有学生选课") return for student in students: while True: try: raw = input( f"请输入 {student['name']}(学号 " f"{student['student_id']})的成绩:" ) score = float(raw.strip()) if not (0 <= score <= 100): print("成绩必须在 0 - 100 之间") continue self.set_score(student["student_id"], course_id, score) break except ValueError: print("输入格式不正确,请输入数字")这里有一个细节:成绩用float而不是int。因为成绩可能出现 89.5 这种小数,如果用 int 会直接丢掉小数位,之后再改回来就麻烦了。另外成绩范围我限制在 0-100,目的不是刻板,而是避免教师录成绩时手滑敲错一个数字(比如误把 80 敲成 800)却没有察觉。
成绩统计的逻辑也不难,但有一个关键过滤条件:
def course_statistics(self, course_id): enrollments = [e for e in self.store.data["enrollments"] if e["course_id"] == course_id and e["score"] is not None] if not enrollments: return None scores = [e["score"] for e in enrollments] return { "平均分": round(sum(scores) / len(scores), 2), "最高分": max(scores), "最低分": min(scores), "参考人数": len(scores) }统计之前一定要过滤掉score为 null 的选课记录。这个 null 其实就是学生选了课但老师还没给成绩,如果不过滤,平均分会被一堆无效数据拉低,明显是错误结果。
5. 命令行交互层的用户体验:菜单怎么设计,输入怎么防呆
5.1 用角色切换菜单替代复杂权限体系
很多人一拿到管理系统就想做“登录框”,但一个训练项目真没必要。我用的是最简单的“角色选择 + 菜单分支”方案:
启动程序以后,先问用户“你是谁”,提供三个选项:管理员、教师、学生。选完角色后进入对应主菜单。管理员可以管理学生和课程;教师可以查看自己课程的名单和录入成绩;学生可以选课、退课、查成绩。没有密码体系,所有操作都依赖使用者自觉选择身份。
有人觉得这样太简陋。但换成训练视角看,这恰恰是合理的:第一,省掉密码流程,把精力聚焦在业务逻辑上;第二,每个角色的操作边界很清楚,其实已经模拟了真实系统的权限划分思路,后续真要加强,只需把“选择角色”换成“账号 + 密码验证”。
入口代码大概长这样:
def main(): store = DataStore("data.json") service = BusinessService(store) while True: print("=== 教务系统主菜单 ===") print("1. 进入学生模式") print("2. 进入教师模式") print("3. 进入管理员模式") print("0. 退出系统") choice = input_choice("请选择:", {"0", "1", "2", "3"}) if choice == "0": break if choice == "1": student_menu(service) elif choice == "2": teacher_menu(service) else: admin_menu(service)菜单用数字代替文字命令的好处是分支逻辑非常直接,不会出现“输入 addStudent 还是 add_student”这种歧义。第一次用的人不用看说明书也能操作。
5.2 输入健壮性:怎么让用户随便乱敲也不崩
命令行程序的体验上限,往往由输入处理决定。用户可能输入空字符串、字母、特殊符号、超长数字、负数,任何一个不小心都会让程序直接崩溃,带出一大段堆栈信息,观感很差。
我最常用的一个工具函数是input_choice:
def input_choice(prompt, valid_options): while True: value = input(prompt).strip() if value in valid_options: return value print(f"输入无效,可选值:{'/'.join(valid_options)}")对数字类输入,我会再封装input_positive_int和input_score,思路都一样:死循环里反复输入,遇到 ValueError 就提示重输,直到拿到合法值才 return。
输入处理这块,有两个经验值得单独说。
第一,input().strip()已经成了我的下意识动作。用户手滑在数字前后敲空格的情况太常见了,不strip(),int(" 3 ")直接就会报错。
第二,尽量用while True循环配合break,不要用递归调用来重试。很多新手在输入不合法时会再次调用input,但递归层数一多就容易爆栈,而且局部变量会被重复创建,非常浪费。循环写法一眼能看懂,也是社区里最主流的做法。
6. 从“能跑”到“抗造”:数据备份、单元测试和扩展方向
6.1 防数据损坏的备份恢复机制
项目开发后期,我反复做了几次破坏性测试:手动把 data.json 改坏、删除 data.json、在数据文件里写入空内容,然后看程序是否还能启动。第一次确实崩了,于是我在load_all里加入了解析失败后自动加载备份的逻辑,然后打印一行警告提示,用户不会一脸懵地看到一串 traceback。
这套“启动即备份、写入先换名”的策略,在真实项目里也是常用做法。许多文件型存储的软件都有类似的机制。对训练项目来说,能在设计阶段就考虑到数据安全,已经是个明显的加分项。
6.2 最少但关键的单元测试:把核心逻辑锁死
这个项目我没写特别多的测试用例,但核心逻辑——选课、退课、成绩录入、统计——都配了一个简单测试。Python 自带 unittest,不需要装额外依赖。
测试的几个典型场景:
| 测试场景 | 预期行为 |
|---|---|
| 学生选一门还没选过的课 | 成功加入选课记录 |
| 学生重复选同一门课 | 抛 ValueError,提示已选过 |
| 课程容量为 2,第三个人来选 | 抛 ValueError,提示容量已满 |
| 两门课时间相同,学生再选第二门 | 抛 ValueError,提示时间冲突 |
| 课程统计里某条记录成绩为空 | 统计时不把这条记录算进去 |
测试里用的是假数据,构造方式就是新建一个 DataStore 指向临时文件,或者直接注入一个内存字典。因为 Service 和 DataStore 分离,测试代码才能写得这么干净。
我的一个真实经验是:写测试比写业务代码更考验“脑补场景”的能力。选课冲突的几种情况,如果不测一遍,很难保证后面需求迭代不引入 bug。我甚至遇到过这种事——某天给教师模块加功能,不小心把选课容量判断从<改成了<=,结果第 40 个学生还能选上第 41 个名额。没有测试,这类问题要等运行时才会暴露。
6.3 项目可以怎么延伸出去
如果让我说下一步最自然的扩展,是替换存储层。把 DataStore 里 load_all/save_all 的实现从 JSON 换成 SQLite 或者 MySQL,上层业务代码基本不用动,因为所有方法接口都一样。做一次这个动作,你会真正理解“解耦”这两个字的意思。
其次是加真正的身份认证。学生登录时输入学号,教师输入工号,管理员输入账号密码,然后把“选择角色”这一步拿掉。这个改动也能帮你搞清楚“认证”和“授权”到底差在哪。
再往后,可以用 Flask 或 FastAPI 把命令行版改造成 Web 版,前端配合简单的 HTML 模板,把录入、查询页面做出来。做完以后,你会明显感觉自己从“写脚本”的状态,正式进入了“写应用”的状态。
做这个项目期间,我最大的体会是:一个看起来“简单”的教务系统,真正到处都藏着细节。核心的业务闭环不复杂,但能不能把边界情况想清楚、数据结构定义得稳、分层设计得合理,直接决定了这个项目是“能跑”还是“能扛”。照着这套思路走,三天之内跑出一个完整版本应该没什么压力。如果你在实现过程里卡住了,优先回去检查两件事:数据到底有没有成功写进文件;选课冲突判断是不是提前return或者break了。