简介:这套基于PHP的学校教务管理系统,面向学校教务管理人员与PHP开发者,涵盖学生、教师、成绩、文章、站点等管理模块,支持多管理员权限控制、在线注册、在线报名与在线考试,前台采用自适应组件,可同时满足电脑端和手机端的使用。系统扩展性强,支持多语言、多数据库、读写分离与高并发,内置缓存机制,且对SQL注入进行了防范,代码注释完善,便于二次开发。整个压缩包共1701个文件,约12.83MB,以675个PHP源码文件为核心,配合HTML页面、JavaScript脚本、CSS样式以及GIF、PNG图片等素材,构成完整的前后台功能、界面样式与交互逻辑。目前已有633人学习该资源,适合用于教学实践、课程设计或毕业设计参考,也可作为轻量级教务系统开发的研究样本,尤其有助于理解多模块权限设计、在线考试流程及高可扩展架构的实现方式。
1. 为什么 PHP 学校教务管理系统.zip 还会被反复下载
大学机房里的旧服务器、课程设计选题列表、云盘里存放了十年的项目源代码,这套东西几乎每年换一个名字重新出现。PHP 学校教务管理系统的架构并不复杂:学生、教师、课程、选课、成绩五个实体,外加排课与公告两个辅助模块,PHP + MySQL 就能跑通,这也是同类 php 源码散落网络的主因。真正值得读的,是代码里沉淀下来的老工程师套路:怎么拆表、怎么防重复选课、怎么从 PHP 5 升到 PHP 7 之后兼容性不崩。做任何后台系统,思路都与这套代码高度同构。
下面按“表结构 → 核心功能 → 部署 → 安全加固”的顺序展开,中途给出可直接抄的 SQL 与 PHP 片段。只交课程设计看第 2、3 章,接手学校旧服务器重点看第 4、5 章。
2. 先定表结构:教务系统的数据模型与会话登录设计
2.1 student / teacher / admin 分表,为什么老源码偏爱这种设计
下载下来的 zip 里八成是同一套建表模式:student 表存学号、姓名、班级、专业,teacher 表存职工号、所属学院,admin 表存管理员账号,三张表的密码字段分别做哈希。这种设计上手快,查询“某个学生的选课记录”就是 JOIN 最少两张表,业务逻辑一眼能看懂。缺点也很直接:登录时要写三段几乎一样的查询,先查 student,查不到再查 teacher,最后一层兜底 admin;未来要想加进辅导员、教务秘书这种新角色,又得再补一张表。
| 角色 | 登录入口 | 业务数据表 | 页面入口 |
|---|---|---|---|
| 学生 | login.php 角色切到 student | student(学号、姓名、专业、班级) | 选课、查成绩 |
| 教师 | login.php 角色切到 teacher | teacher(工号、姓名、学院) | 排课、录成绩 |
| 管理员 | admin.php 独立登录 | admin(账号、权限级别) | 全部管理菜单 |
我一般不会推翻三表设计,而是在这三张表之外补一张 account 表,统一存 login_name、pwd_hash 和 role,三张业务表各加一个 account_id 字段做外键关联:
CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, login_name VARCHAR(32) UNIQUE NOT NULL, pwd_hash VARCHAR(60) NOT NULL, role ENUM('admin','teacher','student') NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ALTER TABLE student ADD COLUMN account_id INT;参数说明:pwd_hash 长度 60 是给 password_hash() 算法的输出预留的;ENGINE 必须写成 InnoDB,老建表语句里常见的 MyISAM 不支持事务和行锁,后面选课并发控制完全用不上。charset 用 utf8mb4 而不是旧的 utf8,否则插入“陈”“龚”这类生僻姓氏或 Emoji 时的“Incorrect string value”错会一直烦你,库、表、连接串三处要同时改掉。
2.2 PHP 实现登录:密码校验、会话与角色跳转
老代码最典型的写法是一行 mysql_query 拼接用户名和 MD5 密码,同一行里同时踩了 SQL 注入和弱口令加密两个坑。现在换到 PHP 7 以上,mysql_* 函数没了,改成 PDO 预编译加 password_hash / password_verify 才是省事方案:
<?php // login_action.php session_start(); $pdo = new PDO( 'mysql:host=127.0.0.1;dbname=edu;charset=utf8mb4', 'edu_user', 'password', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); $name = trim($_POST['login_name'] ?? ''); $pass = $_POST['password'] ?? ''; $stmt = $pdo->prepare("SELECT id, pwd_hash, role FROM account WHERE login_name = ? LIMIT 1"); $stmt->execute([$name]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row || !password_verify($pass, $row['pwd_hash'])) { exit('账号或密码错误'); } $_SESSION['uid'] = $row['id']; $_SESSION['role'] = $row['role']; $_SESSION['name'] = $row['login_name']; $roles = [ 'student' => 'student_panel.php', 'teacher' => 'teacher_panel.php', 'admin' => 'admin_panel.php', ]; header('Location: ' . ($roles[$row['role']] ?? 'index.php')); exit;逻辑说明:查询条件用占位符,数据库端把 login_name 当字符串参数处理,不会拼进 SQL 片段;账号不存在和密码错误统一返回同一句话,避免通过提示差异枚举合法用户名。password_verify 只认 password_hash 产出的哈希,老系统里的 MD5 密码要写一个迁移脚本:登录时先试 password_verify,失败再比旧 MD5,命中后用 new_hash = password_hash($old_md5, PASSWORD_DEFAULT) 回写,用户不用感知。
session 有两个隐藏坑。第一,session_start() 之前不能有任何 HTML 或 BOM 输出,否则报 headers already sent,重定向全部失效。第二,后台页只判断 $_SESSION['uid'] 存在远远不够,学生登录后手动访问 admin.php,很多老 zip 就直接放行了,需要在每个管理页入口加一行角色校验:
if (($_SESSION['role'] ?? '') !== 'admin') { http_response_code(403); exit('no permission'); }3. 用 PHP 实现选课、成绩录入与查询的三个可复现代码段
3.1 选课接口:事务加行锁,把重复选课和超选拦在数据库层
选课是最容易出并发问题的地方。答辩演示时一个学生随手点一下没问题,但真实环境里学生开两个浏览器标签同时提交,两个请求先后查出“当前没有选课记录”,再各插一条,于是出现重复选课。课程容量检查也常见先查后写,满员了还能继续挤进去。
| 防重手段 | 作用粒度 | 拦截点 |
|---|---|---|
| (stu_id, course_id) 唯一索引 | 数据库表级 | 兜底拦截一切重复插入 |
| SELECT ... FOR UPDATE | InnoDB 行级锁 | 串行化并发事务 |
| 业务层预查询 | 应用层 | 给用户友好错误提示 |
三种手段不互相替代,常见做法是同时上。具体实现如下:
<?php // enroll_action.php $pdo->beginTransaction(); try { // 1. 锁该学生该选课记录,没有则插入,有则提示 $stmt = $pdo->prepare( "SELECT id FROM enroll WHERE stu_id = ? AND course_id = ? FOR UPDATE" ); $stmt->execute([$stuId, $courseId]); if ($stmt->fetch()) { throw new RuntimeException('你已选过该课程'); } // 2. 锁课程容量,避免两个请求同时读到同一剩余名额 $course = $pdo->prepare( "SELECT capacity, selected FROM course WHERE id = ? FOR UPDATE" ); $course->execute([$courseId]); $c = $course->fetch(PDO::FETCH_ASSOC); if (!$c || $c['selected'] >= $c['capacity']) { throw new RuntimeException('课程不存在或已满员'); } // 3. 插入选课记录并原子更新已选人数 $pdo->prepare( "INSERT INTO enroll (stu_id, course_id, created_at) VALUES (?, ?, NOW())" )->execute([$stuId, $courseId]); $pdo->prepare( "UPDATE course SET selected = selected + 1 WHERE id = ?" )->execute([$courseId]); $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); exit($e->getMessage()); }参数说明:FOR UPDATE 是 InnoDB 的行级锁语法,事务提交或回滚时释放,两个事务同时进来时后者会阻塞等待;MyISAM 不认这个锁,所以建表语句必须明确 ENGINE=InnoDB。enroll 表上再补一个 UNIQUE KEY,即便业务层漏判,数据库也会直接拒绝重复插入。
3.2 成绩录入:批量 upsert 与 JSON 数组对象返回
教师端录成绩是逐班操作,一批几十名学生。老做法是 foreach 循环逐条 UPDATE,网络往返多,而且某个学生成绩格式写错时,前面已提交的数据和后面的状态不一致,很难回滚。更可靠的做法是前端一次把整个班成绩以 JSON 数组 POST 上来,后端走一次 INSERT ... ON DUPLICATE KEY UPDATE,所有数据在一条语句里完成。
<?php // grade_save.php $rows = json_decode(file_get_contents('php://input'), true); if (!is_array($rows) || count($rows) === 0) { http_response_code(400); exit(json_encode(['code' => 400, 'msg' => 'grade list empty'])); } $sql = "INSERT INTO score (stu_id, course_id, value, updated_at) VALUES (?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = NOW()"; $stmt = $pdo->prepare($sql); foreach ($rows as $r) { $stmt->execute([ (int)($r['stu_id'] ?? 0), (int)($r['course_id'] ?? 0), (float)($r['value'] ?? 0), ]); } header('Content-Type: application/json; charset=utf-8'); echo json_encode(['code' => 0, 'msg' => 'saved', 'count' => count($rows)]);逻辑说明:score 表主键必须是 (stu_id, course_id) 或单独建唯一索引,ON DUPLICATE KEY UPDATE 才有触发点。输入用的是 php://input 流而非 $_POST,兼容前端 fetch 和 axios 提交的 JSON 正文。每个字段做 (int)、(float) 强转是双保险:PDO 预编译已经防注入,类型约束再挡掉字符串型的脏数据。
返回体是 JSON 对象,前端拿到后直接判断 code 字段,这就是“PHP 接口数组对象”这套最常见的落地姿势。如果还要接 Excel 批量录入,把导入文件先解析成同样结构的数组,再喂给这个函数即可,不需要另写一套写入逻辑。
3.3 排课查询:多表 JOIN 加分页,带条件的列表页标准写法
排课页面要显示“哪个教师、哪个时间段、教哪个专业哪门课”,数据分散在 course、teacher、schedule、specialty 四张表。这类列表页的正确 SQL 是 JOIN 后统一排序和分页:
<?php // schedule_list.php $page = max(1, (int)($_GET['page'] ?? 1)); $pageSize = 20; $offset = ($page - 1) * $pageSize; $sql = "SELECT sch.id, c.name AS course_name, t.real_name AS teacher_name, sp.name AS specialty_name, sch.weekday, sch.period FROM schedule sch JOIN course c ON sch.course_id = c.id JOIN teacher t ON sch.teacher_id = t.id JOIN specialty sp ON sch.specialty_id = sp.id WHERE sch.semester = ? ORDER BY sch.weekday, sch.period LIMIT ? OFFSET ?"; $stmt = $pdo->prepare($sql); $stmt->bindValue(1, '2025s', PDO::PARAM_STR); $stmt->bindValue(2, $pageSize, PDO::PARAM_INT); $stmt->bindValue(3, $offset, PDO::PARAM_INT); $stmt->execute();参数说明:LIMIT 和 OFFSET 两个占位符必须 bindValue 并显式指定 PDO::PARAM_INT,否则 PDO 默认按字符串处理,老版本 MySQL 能容忍 LIMIT '20',MariaDB 10.6 之后部分模式下直接报语法错误。JOIN 的驱动表不用手工指定,MySQL 优化器会按索引和行数决定,只要确认 course.id、teacher.id、specialty.id 有主键索引即可。业务上另一件必须做的事是排课冲突约束:同一教室同一时间只能排一门课,直接用唯一索引 (classroom_id, weekday, period) 兜底,比在代码里查一遍再判断可靠得多。
4. Windows 环境部署与 PHP 版本兼容:从 zip 下载到跑通
4.1 Nginx + PHP-FPM 在 Windows 上的最小配置
这类 zip 解压后通常直接就能跑,但前提是运行环境对路。Windows 上用 phpStudy 这类集成环境点击启动即可,自己手搭 Nginx 时最容易踩的坑是 fastcgi 配置写错。下面是一份能直接用的最小 server 块:
server { listen 80; server_name edu.local; root D:/www/edu; index index.php index.html; location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }配置说明:fastcgi_pass 指向 PHP-CGI 监听端口,phpStudy 默认是 9000,新版 PHP 8 内置的 CGI 端口可能是 9001,要在 PHP 配置文件里确认一致。项目入口如果是 admin.php、login.php 这类静态别名,不需要 rewrite;只有入口统一为 index.php?act=xxx 的单入口架构才考虑 location / { try_files $uri $uri/ /index.php?$query_string; }。
4.2 PHP 5 迁移到 PHP 7/8 的兼容性改造
早期教务源码大量依赖 mysql_* 系列函数,PHP 7 删掉后页面直接白屏。常见做法是先全局搜索,把报错函数集中替换掉:
| PHP 5 老写法 | PHP 7+ 替代 | 注意事项 |
|---|---|---|
| mysql_connect / mysql_query | PDO 或 MySQLi | PDO 统一异常处理,推荐优先 |
| mysql_real_escape_string | 预编译占位符 | 不必再手动转义 |
| each() 遍历数组 | foreach ($arr as $k => $v) | PHP 8 已彻底移除 |
| create_function() | 匿名函数 fn() | PHP 8 已移除 |
| list($a, $b) = each($arr) | foreach + 解构 | 写法差异最大 |
替换时不要只换函数名,连接方式也要跟着改。PDO 连接可以统一封装在 db.php 里,其他页面直接引用;迁移后顺手把错误模式设为 ERRMODE_EXCEPTION,SQL 写错时报错信息会直接可见,排错成本比原来低很多。
4.3 php 错误处理:白屏和 500 的定位顺序
白屏时先看 PHP 错误日志,phpStudy 的日志路径一般在 php_error.log;手搭环境则看 php.ini 里的 error_log 配置。开发环境可以先开启显示:
<?php // 放在入口文件最顶部,定位完成后再关掉 error_reporting(E_ALL); ini_set('display_errors', '1');确认逻辑:第一步看浏览器直接访问 php 文件是否返回 500;第二步看 Nginx error.log,fastcgi 连接不上会有 connect() failed 记录;第三步确认入口文件编码是无 BOM 的 UTF-8,带 BOM 会导致 header 输出报错。线上环境再回来把这个配置关掉,转成日志落盘,避免把 SQL 语句和绝对路径暴露出去。
5. 这套 zip 必查的 3 个安全点:上传白名单、伪协议与越权
5.1 上传模块:只靠前端校验等于没校验
老教务源码里头像上传、课件上传是重灾区。很多代码只检查了文件扩展名,后端不再校验,于是上传入口成了 php 上传漏洞的高发区。修复方案是白名单加改名加目录隔离:
<?php $allowed = ['jpg', 'png', 'gif', 'webp']; $ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION)); if (!in_array($ext, $allowed)) { exit('bad extension'); } $newName = bin2hex(random_bytes(8)) . '.' . $ext; move_uploaded_file($_FILES['file']['tmp_name'], UPLOAD_DIR . $newName);逻辑说明:白名单之外的一切扩展名直接拒绝;用 random_bytes 生成随机文件名,用户无法预知文件地址;上传目录放在 web 根目录之外或通过 Nginx 配置禁止执行 PHP,即使文件内容被拼装过也没有执行入口。
5.2 路由 include 与 php 伪协议
有些老代码喜欢用 include("pages/" . $_GET['p'] . ".php") 做页面切换。这个写法配合 php 伪协议可以绕过扩展名限制读取敏感文件,比如 php://filter 读源码。修复方式是把动态参数改成白名单路由:
<?php $pages = [ 'list' => 'pages/schedule_list.php', 'add' => 'pages/schedule_add.php', 'edit' => 'pages/schedule_edit.php', ]; $key = $_GET['p'] ?? 'list'; if (!isset($pages[$key])) { http_response_code(404); exit('page not found'); } include $pages[$key];5.3 用三条 curl 验证部署链路
部署完成后,几行命令就能确认基础链路是否健康,不用等学生在浏览器里试出来:
curl -i http://127.0.0.1/login.php | head -n 20 curl -o /dev/null -s -w "%{http_code}" http://127.0.0.1/index.php curl -X POST http://127.0.0.1/grade_save.php -d "{}" -H "Content-Type: application/json"第一条确认响应头没有 PHP Warning 夹杂;第二条看到 200 就能确定 PHP 处理链活着;第三条返回 400 且提示 grade list empty 说明 JSON 入口和 PDO 连接都已生效。头部若有 X-Powered-By 泄露版本信息,在 php.ini 里加上 expose_php = Off 顺手关掉。
本文还有配套的精品资源,点击获取