简介:这是一份PHP学校教务管理系统源码,面向需要建设校园信息化平台的技术人员、在校学生或正在学习PHP项目开发的程序员,系统后台覆盖学生管理、成绩管理、教师管理、文章管理及站点管理等多个模块,支持多管理员权限控制、自动网站布局和多导航模式;前台提供HTML5自适应界面,在线注册支持短信与邮箱验证,并可完成在线报名与在线考试。系统采用模块化开发与完善的注释,支持多语言、多数据库、读写分离、高并发及内置缓存机制,同时具备SQL注入防护能力,整体工程结构清晰。资源共1701个文件,以675个PHP业务代码文件为核心,搭配HTML模板、JavaScript脚本、CSS样式以及GIF/PNG图片等前端资源,压缩包约12.83MB。已有633人学习浏览,适合直接部署试用、二次开发或作为教务系统架构参考,便于学习权限控制、数据流设计和高并发优化等关键实现。
1. 学校选课那一小时,最考验 PHP学校教务管理系统.zip 的并发边界
学校发选课通知的那一小时,往往是教务系统最脆弱的时刻。仓库里躺着的“PHP学校教务管理系统.zip”解压之后,多数时候是一个 ThinkPHP 3.2 或原生 PHP 的单体应用,连着一个 MySQL 库,前端页面还透着老 iframe 的影子;真正麻烦的不是代码旧,而是没人说得清哪些表是核心、哪些接口能在并发下顶住。要二开这套系统的人,不需要把全部源码读完,抓住学生、教师、课程、成绩这几条主线,就能把选课、排课、录成绩这些最常见的流程跑起来。理解这套系统,先从目录和运行环境开始。
2. 从目录识别技术栈:PHP学校教务管理系统的框架与环境
2.1 为什么这类系统总是 PHP 单体应用
学校机房最常见的服务器组合是 Windows Server + Apache + MySQL,一个 phpstudy 集成包就能装完。PHP 单体应用在这个环境里没有独立应用服务器这个概念,改动文件后立即生效,出问题时重启一下 Apache 或者 php-fpm 就能恢复。与 Java 系要装 Tomcat、和 Node 系要管进程守护相比,PHP 的运维成本对机房老师最友好。
从开发端看,教务系统的业务面其实不大:课程、选课、成绩、学籍、教师课表。几十张表、三种角色,单体能表达清楚。用微服务去拆这类系统,反而把简单问题复杂化。老系统选择 PHP,不是技术落后,是环境约束下的合理取舍。
2.2 解压后先看入口文件,判断用的是框架还是原生写法
不要先找文档,先执行三件事:看入口文件、看是否有 vendor 目录、看 SQL 文件里的建表语句。这套操作比读任何 README 都可靠。
head -80 index.php find . -maxdepth 2 -type d | sort | head -30 ls -la vendor/ 2>/dev/null || echo "no vendor"第一行看入口里有没有加载框架引导文件,第二行看目录结构,第三行判断是否依赖 Composer 包。一个 zip 包是否为框架项目,由这些特征决定:
| 目录特征 | 判断结果 | PHP 版本建议 | 典型风险 |
|---|---|---|---|
| Application/Home/Controller | ThinkPHP 3.2 | PHP 5.6 或 7.4 | 老版本存在注入与反序列化问题 |
| app/ 目录加 vendor/bootstrap | Laravel | PHP 7.4 以上 | 需要先跑 composer install |
| application/controllers | CodeIgniter 3 | PHP 5.6 或 7.4 | base_url 配置不当导致静态资源失效 |
| 没有 vendor,index.php 里 new mysqli | 原生 PHP | 随函数兼容而定 | 字符串拼接 SQL 风险高 |
这套判断决定后面所有步骤:如果 zip 包里的代码是 ThinkPHP 3.2,直接放到 PHP 8 环境大概率跑不起来,因为老框架里很多函数在 PHP 8 已被移除。不少人在这一步卡住,其实不是代码坏了,是版本错配。
2.3 用 Docker 快速搭出一套可运行环境
常见做法是在 Windows 上用 phpstudy 起环境,但复现老系统更容易的方式是 Docker。以下配置把一个 zip 包解压后的目录映射到 Nginx 和 PHP-FPM 容器,数据库用 MySQL 5.7 对齐老系统最常用的版本组合。
version: '3' services: nginx: image: nginx:1.24-alpine ports: - "8080:80" volumes: - ./www:/var/www/html - ./nginx.conf:/etc/nginx/conf.d/default.conf depends_on: - php php: image: php:7.4-fpm volumes: - ./www:/var/www/html depends_on: - mysql mysql: image: mysql:5.7 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: eduadmin MYSQL_USER: edu MYSQL_PASSWORD: edu123 ports: - "3306:3306" volumes: - ./mysql-data:/var/lib/mysql启动后访问http://127.0.0.1:8080,如果页面空白,优先看 PHP 错误日志:docker compose logs -f php。参数上注意三点:第一,老系统编码多是 UTF-8 或 gbk,数据库字符集要在建库时确认;第二,MySQL 5.7 与 8.0 对GROUP BY的处理差异会影响成绩统计类查询,老代码别直接上 8.0;第三,如果 zip 包里的数据库账号写死localhost,容器环境里要改成mysql主机名,否则连不上库。
3. 先建几张核心表:教务管理系统的数据模型与权限边界
3.1 学生、教师、课程、开课、选课的成绩链路
教务系统的核心不是“管理”,是“关系”。学生和课程之间隔着一个开课实体,教师和课程也通过开课实体关联。所谓开课,就是某位老师在某个学期、某个教室、某些节次上了一门课。建立模型时先问:一条选课记录到底关联谁?
CREATE TABLE student ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, student_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, class_id INT UNSIGNED NOT NULL, enroll_year SMALLINT NOT NULL, status TINYINT NOT NULL DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE teacher ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, teacher_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, title VARCHAR(20) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE course ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, course_code VARCHAR(20) NOT NULL UNIQUE, course_name VARCHAR(100) NOT NULL, credit DECIMAL(3,1) NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE course_offer ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, course_id INT UNSIGNED NOT NULL, teacher_id INT UNSIGNED NOT NULL, term VARCHAR(20) NOT NULL, capacity INT UNSIGNED NOT NULL DEFAULT 60, selected_count INT UNSIGNED NOT NULL DEFAULT 0, classroom VARCHAR(50) DEFAULT NULL, week_day TINYINT DEFAULT NULL, start_section TINYINT DEFAULT NULL, end_section TINYINT DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;student_no加唯一索引,保证学号不能重复;enroll_year用于按入学年份做分层查询;course_offer的selected_count是一个冗余字段,用它避免每次选课都COUNT(*)统计。冗余在教务系统里是合理的,因为选课期间的读频次远高于写频次。考试后再建成绩表,外键关联选课记录而不是直接关联学生和课程,这样一次选课对应一条成绩,结构最清晰。
3.2 选课记录要加联合唯一索引,靠代码防不了并发
一个学生同一门开课只能选一次,这个约束在应用层做不可靠。并发请求下,两个请求同时查到“未选过”,就会插入两条重复记录。数据库层要兜底:
CREATE TABLE course_selection ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, student_id INT UNSIGNED NOT NULL, offer_id INT UNSIGNED NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_offer (student_id, offer_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;UNIQUE KEY uk_student_offer (student_id, offer_id)的意思是,同一学生针对同一开课记录只能有一条选课数据。实际插入时如果遇到Duplicate entry错误,直接提示“已经选过这门课”,不需要先查再插。这个设计让业务代码少一次查询,也让数据库成为最后一道防线。
3.3 权限模型:登录后只区分三种角色,数据权限靠归属条件
教务系统的权限分两层:第一层是能不能进这个页面,第二层是进去了能看到哪些数据。很多老 zip 包只做了第一层,结果教师账号能查到别的班成绩单,这类越权往往比越权访问后台更隐蔽。
namespace App\Http\Middleware; use Closure; class CheckRole { public function handle($request, Closure $next, $role) { $user = $request->user(); $roleMap = ['admin' => 1, 'teacher' => 2, 'student' => 3]; if (!$user || $user->role !== $roleMap[$role]) { return redirect('/login')->with('error', '没有权限访问该页面'); } return $next($request); } }页面权限用中间件就够了,关键是数据权限。教师查询成绩列表时,SQL 里要强制带teacher_id条件,而不是查出全部记录再在前端过滤。学生端同理,查询成绩必须带student_id条件。这套写法在原生 PHP 里同样成立:登录后把uid放在 Session,每次查询把它拼进 WHERE 条件。
我建议所有查询接口在调试阶段打印最终 SQL,人工核对teacher_id或student_id有没有出现在 WHERE 里。权限验证的黄金准则是:后端无法信任前端传来的任何身份标识,身份只能来自会话。
4. 选课、排课、录成绩:PHP教务管理系统核心模块怎么写
4.1 并发选课:事务加行锁,比锁表靠谱
选课高峰期的核心矛盾是:容量剩余判断和选课插入不是原子操作。如果用“先查剩余人数,再插入,再更新人数”的写法,两个请求会同时读到selected_count = 59,同时插入,最后超卖。老系统常见的解决方案是给整张表加锁,代价是选课期间所有写操作串行化。
更精准的做法是只锁住目标开课记录这一行:
try { $pdo->beginTransaction(); $sql = "SELECT id, capacity, selected_count FROM course_offer WHERE id = :offer_id FOR UPDATE"; $stmt = $pdo->prepare($sql); $stmt->execute(['offer_id' => $offerId]); $offer = $stmt->fetch(PDO::FETCH_ASSOC); if (!$offer || $offer['selected_count'] >= $offer['capacity']) { $pdo->rollBack(); throw new RuntimeException('该课程已满或不存在'); } $insert = "INSERT INTO course_selection (student_id, offer_id) VALUES (:student_id, :offer_id)"; $stmt = $pdo->prepare($insert); $stmt->execute(['student_id' => $studentId, 'offer_id' => $offerId]); $update = "UPDATE course_offer SET selected_count = selected_count + 1 WHERE id = :offer_id"; $stmt = $pdo->prepare($update); $stmt->execute(['offer_id' => $offerId]); $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } error_log($e->getMessage()); throw new RuntimeException('选课失败,请重试'); }事务内先执行SELECT ... FOR UPDATE,这一行数据会被加上行级排他锁,其他事务再查同一行时会等待当前事务提交或回滚。这样“判断容量”和“更新容量”之间不会插入其他事务的修改。注意两点:事务里不要做远程请求或sleep(),否则锁的持有时间被拉长;不要用LOCK TABLES锁整表,选课期间所有开课记录都会被阻塞。
如果选课规模只有几百人,这个方案完全够用。几千人同时抢课时,事务里的排队时间会变长,这时才需要把“写请求”改造成队列,这点在第五章展开。
4.2 排课冲突:时间重叠判断用区间相交,不用相等
排课模块最常见的 bug 是把冲突判断写成week_day = 周一 AND start_section = 3 AND end_section = 4。问题是课程 A 占第 3 到 4 节,课程 B 占第 4 到 5 节,从第 4 节开始就重叠了,用相等条件根本查不出来。
正确的重叠条件是:旧课开始时间小于新课结束时间,并且旧课结束时间大于新课开始时间。
SELECT COUNT(*) FROM course_offer WHERE classroom = :classroom AND week_day = :week_day AND term = :term AND start_section < :end_section AND end_section > :start_section AND id != :except_id这个查询把“教室冲突”查出来了。把classroom换成teacher_id,就是教师时间冲突;换成class_id,就是班级时间冲突。三种冲突共用同一套区间判断逻辑。
如果老系统没有这个函数,常见做法是先查出目标教室当天的全部开课,再在 PHP 里循环比对区间。数据量不大时没问题,但教学周几十门课全部加载到内存,不如一条 SQL 快。注意start_section < :end_section AND end_section > :start_section这个条件要记住:它会正确处理相邻课程前后相接的情况,因为 A 结束节次与 B 开始节次相同时,两个条件有一个不成立,不会被误判为冲突。
4.3 成绩录入:防越权与发布状态
成绩模块的核心不是增删改查,是“教师只能改自己教的课”。更新成绩的 SQL 如果只带selection_id,攻击者可以构造请求改别人的成绩。正确的做法是让 SQL 自己保证数据归属:
$sql = "UPDATE score s JOIN course_selection cs ON cs.id = s.selection_id JOIN course_offer co ON co.id = cs.offer_id SET s.score = :score WHERE s.selection_id = :selection_id AND co.teacher_id = :teacher_id"; $stmt = $pdo->prepare($sql); $stmt->execute([ 'score' => $newScore, 'selection_id' => $selectionId, 'teacher_id' => $sessionTeacherId, ]);teacher_id不来自请求参数,而来自当前登录会话。这样即使有人伪造selection_id,JOIN 链上co.teacher_id不匹配,更新影响行数为 0。成绩发布流程也要考虑:草稿状态教师可反复修改,只有发布后学生端才能看到。
CREATE TABLE score ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, selection_id INT UNSIGNED NOT NULL, score DECIMAL(5,2) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0=草稿 1=已发布', updated_by INT UNSIGNED DEFAULT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_selection (selection_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;发布操作的 SQL 要带上WHERE status = 0,如果返回的影响行数为 0,说明成绩已被发布过,避免重复发布覆盖数据。实际开发里,我还会加一张成绩变更日志表,记录修改前后的值、操作人、操作时间。教务场景里,成绩的事后审计比实时拦截更重要。
5. 上传安全与选课削峰:PHP教务管理系统最容易出事的两处
5.1 上传图片和附件,最危险的不是后缀绕过
教务系统需要上传头像、教学大纲、成绩单附件。这些年最常见的攻击路径是:上传一个文件名带.php的文件,或把正常图片的内容替换成 PHP 脚本再用解析漏洞执行。老代码里很多人只检查$_FILES['file']['type'],这个字段来自客户端请求头,可以随便伪造。
if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) { throw new RuntimeException('上传失败'); } $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $_FILES['file']['tmp_name']); finfo_close($finfo); $allowList = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']; if (!in_array($mime, $allowList, true)) { throw new RuntimeException('只允许上传图片或 PDF'); } $extMap = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'application/pdf' => 'pdf']; $newName = bin2hex(random_bytes(16)) . '.' . $extMap[$mime]; move_uploaded_file($_FILES['file']['tmp_name'], UPLOAD_DIR . '/' . $newName);finfo_file读取的是文件真实内容而非请求头,判断更可靠。random_bytes(16)生成随机文件名,避免用户控制文件名引入路径穿越问题。做完这两件事,再加最后一层保险:上传目录禁止 PHP 执行。
location ^~ /uploads/ { location ~* \.(php|php5|phtml)$ { deny all; } }这段 Nginx 配置对/uploads/目录内的 PHP 后缀请求直接拒绝。即使攻击者成功上传了恶意脚本,也无法在这个目录里以 PHP 身份执行。如果是 Apache,需要在 uploads 目录放.htaccess,内容为php_flag engine off。判断一个系统是否这么做,最快的方式是往上传目录丢一个test.php,然后直接访问它的 URL。
5.2 选课高峰期削峰:Reids 消费组在什么时候才需要
几千人同时抢课时,数据库行锁会让事务排队,页面变慢但不至于崩溃。到上万人规模,数据库连接数会先被打满。常见做法是引入队列削峰:用户点击选课后,请求先写入 Redis,立即返回“排队中”;后台消费者进程慢慢把队列里的选课请求写入数据库。
$payload = json_encode([ 'student_id' => $studentId, 'offer_id' => $offerId, ]); $redis->lpush('course_selection_queue', $payload); $redis->sadd('course_selection_members:' . $offerId, $studentId);消费者用阻塞式读取:
$job = $redis->brpop('course_selection_queue', 3); if ($job) { $data = json_decode($job[1], true); // 重复执行第四章的事务插入逻辑 }brpop的第二个参数是超时时间,队列为空时阻塞等待,避免空轮询占用 CPU。去重集合course_selection_members用来保证同一学生只排队一次,消费端在处理前先sismember判断。
这个方案只在吞吐量超过数据库承受能力时使用。教务系统选课通常集中在半小时内,如果日常峰值只有每秒几十请求,事务加索引已经足够。引入 Redis 意味着要处理消费失败重试、重复消息、消费者宕机恢复三件事,复杂度是实打实的。先压测,确认数据库扛不住,再上队列。
5.3 慢查询排查:先从 key_len 和 possible_keys 看起
教务系统数据量不大时,慢查询通常不是数据多,而是索引没命中。排查入口是 EXPLAIN:
EXPLAIN SELECT * FROM course_selection WHERE student_id = 10001 AND offer_id = 200;重点看type字段,如果是ALL,说明全表扫描;看看possible_keys有没有出现uk_student_offer;key字段表示实际使用的索引。我在建表时给course_selection加了联合唯一索引,这个查询应该直接命中。
排课冲突查询里,classroom和week_day经常是条件列,要给它们建索引,但不要每个字段单独建。查询中同时用term、week_day、classroom做条件时,联合索引更有效:
ALTER TABLE course_offer ADD INDEX idx_term_room (term, week_day, classroom);需要注意的是,联合索引的列顺序要匹配查询条件的顺序。week_day是范围条件,MySQL 在范围判断之后的部分索引列无法继续高效使用,所以把等值条件放前面、范围条件放后面。这个细节在面试里常被问到,在实际排错里更重要。
6. 从 zip 到真正上线:数据迁移、配置核对与并发验收
6.1 旧系统数据迁移,用 CLI 脚本分批导出
老 zip 包里的数据库可能存的是 gbk 编码、班级编号体系也不一样。直接在数据库工具里复制表结构容易踩坑,我一般写 CLI 脚本做映射转换:
<?php declare(strict_types=1); $old = new PDO('mysql:host=192.168.1.20;dbname=old_edu', 'root', 'xxx'); $old->exec('SET NAMES gbk'); $new = new PDO('mysql:host=127.0.0.1;dbname=eduadmin', 'root', 'xxx'); $new->exec('SET NAMES utf8mb4'); $pageSize = 1000; $lastId = 0; do { $stmt = $old->query("SELECT * FROM students WHERE id > {$lastId} ORDER BY id ASC LIMIT {$pageSize}"); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($rows)) { break; } $insert = $new->prepare( "INSERT INTO student (student_no, name, class_id, enroll_year, status) VALUES (:student_no, :name, :class_id, :enroll_year, :status)" ); foreach ($rows as $row) { $insert->execute([ 'student_no' => $row['学号'], 'name' => $row['姓名'], 'class_id' => (int)($classMap[$row['班级']] ?? 0), 'enroll_year' => (int)substr($row['学号'], 0, 4), 'status' => 1, ]); $lastId = $row['id']; } } while (true);这段脚本用id > 上一个最大值而不是LIMIT OFFSET分页,避免翻页越深越慢的问题。SET NAMES gbk放在老库连接上,读取中文时就不会乱码。新建库那边统一 utf8mb4。迁移完成后,对一下三个数:学生总数、选课总记录数、成绩总记录数,三数都对上再切流量。
6.2 上线前必查配置清单
| 检查项 | 命令或操作 | 说明 |
|---|---|---|
| 关闭错误显示 | php.ini 里 display_errors = Off | 避免 SQL 报错信息直接暴露给用户 |
| 打开错误日志 | php.ini 里 error_log 指定路径 | 线上问题留现场 |
| 改默认管理员密码 | 在 user 表里查 admin 账号并重置 | 弱口令是教务系统被入侵的第一入口 |
| 定期备份数据库 | crontab 定时执行 mysqldump | 教务数据丢了几乎无法恢复 |
| 上传目录禁执行 | 按 5.1 配置 Nginx 或 Apache | 必须在上线前验证一次 |
6.3 用 ab 模拟选课并发验证
上线前用 ApacheBench 模拟集中选课场景,验证课程是否超卖:
ab -n 200 -c 20 -T 'application/x-www-form-urlencoded' \ -p postdata.txt \ http://127.0.0.1:8080/api/select_course-n 200表示总请求数,-c 20表示 20 个并发,postdata.txt里放offer_id=101&student_id=10001这样的表单内容。压测结束后,执行下面这条 SQL 核对最终状态:
SELECT id, capacity, selected_count FROM course_offer WHERE id = 101;正常情况下selected_count应该等于容量值,不会大于capacity。如果出现超卖,说明事务或锁的代码没有被正确执行,回到第四章检查事务提交逻辑。再查一遍course_selection表里同一student_id与offer_id的组合是否唯一,确认联合索引没被误删。
本文还有配套的精品资源,点击获取