
PHP学校选课系统开发:课程查询与选课功能实现
学校选课系统看起来只有两件事:查课程、点选课。实际开发中,课程查询要处理筛选、分页和输出安全;选课则要同时面对身份校验、重复提交、名额并发和选课时间等边界。只在 PHP 里检查“还有没有名额”,再直接插入一条记录,并不足以保证结果正确:多个学生可能同时读到同一个剩余名额。
本文用 PHP 8.1 及以上版本、PDO 和 MySQL 8 演示一个可落地的基础实现。示例提供课程列表查询和选课提交,不包含用户注册、完整登录流程、退课、先修课规则及管理员后台。项目已有登录功能时,可以把示例中的会话用户标识接入现有认证系统。
一、适用场景与实现边界
这个实现适用于规模适中、使用关系型数据库的校内选课应用,功能包括:
- 按学期、课程名或教师名筛选课程。
- 按页展示课程,并显示课程容量与已选人数。
- 已登录学生提交选课,系统检查状态、选课时间、重复记录和剩余名额。
- 依靠数据库唯一约束和事务处理重复提交与并发抢占。
以下规则应由业务方明确,不能只靠示例代码猜测:是否允许候补、退课后是否释放名额、同一学生能否选时间冲突课程、是否需要先修课校验、选课时间按学校时区还是统一服务器时区计算。本文默认每位学生对每门课程只能有一条选课记录,且选课成功后立即占用名额;不处理候补和退课。
示例使用 MySQL 的 InnoDB 存储引擎,因为事务和行锁依赖它。若改用其他数据库,应按该数据库的事务、锁和占位符规则调整 SQL。不要把“PHP 版本较新”理解成已经具备这些数据库保证。
二、数据库表结构
先准备学生、课程和选课记录三张表。为了让示例能够独立理解,这里把学生表也列出;实际项目通常已经有自己的用户表,届时应将 student_id 外键关联到现有表。
CREATE TABLE students (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
display_name VARCHAR(100) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE courses (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
semester VARCHAR(32) NOT NULL,
course_code VARCHAR(32) NOT NULL,
title VARCHAR(200) NOT NULL,
teacher_name VARCHAR(100) NOT NULL,
capacity INT UNSIGNED NOT NULL,
status ENUM('draft', 'open', 'closed') NOT NULL DEFAULT 'draft',
enroll_start DATETIME NOT NULL,
enroll_end DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_semester_course_code (semester, course_code),
KEY idx_course_search (semester, status, title)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE enrollments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
student_id BIGINT UNSIGNED NOT NULL,
course_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_student_course (student_id, course_id),
KEY idx_course_enrollments (course_id),
CONSTRAINT fk_enrollment_student
FOREIGN KEY (student_id) REFERENCES students (id),
CONSTRAINT fk_enrollment_course
FOREIGN KEY (course_id) REFERENCES courses (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
uq_student_course 是防止重复选课的最终防线。应用层可以先判断是否已选,以便给出友好提示,但两个并发请求仍可能同时通过该判断;唯一索引可以确保数据库最终不会保存重复记录。课程的已选人数不单独存储,而由选课记录统计,避免计数值与明细不一致。若业务量大到必须维护计数器,应设计事务内的原子更新与数据校验机制,不能简单地在 PHP 中读、加一、写回。
示例没有设置级联删除。正式环境中通常不应允许课程被随意物理删除;可以将课程标记为关闭或归档,并保留历史选课记录。删除规则应与学校的数据留存要求一致。
三、数据库连接与通用约定
将连接信息放入环境变量或受保护的配置文件,不要把数据库密码提交到代码仓库。以下连接文件使用 PDO 预处理语句,并设置异常模式和明确的字符集:
<?php
declare(strict_types=1);
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
getenv('DB_HOST') ?: '127.0.0.1',
getenv('DB_PORT') ?: '3306',
getenv('DB_NAME') ?: 'school'
);
$pdo = new PDO(
$dsn,
getenv('DB_USER') ?: 'school_app',
getenv('DB_PASSWORD') ?: '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
后续 PHP 文件可通过 require __DIR__ . '/db.php'; 引入连接。生产环境还应将错误记录到服务端日志,避免向用户输出数据库连接信息、SQL 或堆栈。开启 PDO 异常模式并不意味着可以把异常原文直接显示给浏览器。
统一使用 UTC 还是学校本地时区,应在系统层面确定。本文的数据库字段是 DATETIME,它本身不携带时区信息;写入和比较时必须采用一致的时区约定。下面的选课判断依赖 MySQL 会话的 NOW(),因此应确保数据库会话时区与存储时间的约定匹配,或改为由应用生成并绑定统一时区的时间。
四、课程查询:筛选、统计与分页
课程列表需要展示每门课的已选人数。可以通过分组子查询统计选课记录,再与课程表连接。若把多个一对多表直接连接后统计,可能因为连接行数膨胀而得到错误的计数;本例只连接一个预先聚合的结果。
下面是 courses.php 的主体。它支持按学期、课程名或教师名筛选,并使用绑定参数处理所有用户输入。分页参数会先转换为整数再限制范围;LIMIT 和 OFFSET 在不同 PDO 驱动及配置下不一定能作为普通字符串参数安全可靠地处理,因此这里将经过边界校验的整数直接拼入 SQL。不要把未经校验的请求参数拼进 SQL。
<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/db.php';
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$semester = trim((string)($_GET['semester'] ?? ''));
$q = trim((string)($_GET['q'] ?? ''));
// 防止异常长的查询参数造成不必要的数据库工作。
// 这是应用层长度限制,不等同于数据库安全机制。
$q = mb_substr($q, 0, 100, 'UTF-8');
$pageRaw = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT);
$page = ($pageRaw !== false && $pageRaw !== null)
? max(1, min(100000, $pageRaw))
: 1;
$perPage = 20;
// 用同一套条件分别生成总数和列表查询。
$where = [];
$params = [];
if ($semester !== '') {
$where[] = 'c.semester = :semester';
$params[':semester'] = $semester;
}
if ($q !== '') {
// 将用户输入中的 LIKE 通配符按字面字符处理。
$escaped = strtr($q, [
'!' => '!!',
'%' => '!%',
'_' => '!_',
]);
$where[] = "(c.title LIKE :title ESCAPE '!'
OR c.teacher_name LIKE :teacher ESCAPE '!')";
$params[':title'] = '%' . $escaped . '%';
$params[':teacher'] = '%' . $escaped . '%';
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$countSql = "
SELECT COUNT(*)
FROM courses c
$whereSql
";
$countStmt = $pdo->prepare($countSql);
$countStmt->execute($params);
$total = (int)$countStmt->fetchColumn();
$totalPages = max(1, (int)ceil($total / $perPage));
$page = min($page, $totalPages);
$offset = ($page - 1) * $perPage;
$listSql = "
SELECT c.id, c.semester, c.course_code, c.title,
c.teacher_name, c.capacity, c.status,
c.enroll_start, c.enroll_end,
COALESCE(ec.enrolled_count, 0) AS enrolled_count
FROM courses c
LEFT JOIN (
SELECT course_id, COUNT(*) AS enrolled_count
FROM enrollments
GROUP BY course_id
) ec ON ec.course_id = c.id
$whereSql
ORDER BY c.semester DESC, c.course_code ASC, c.id ASC
LIMIT $perPage OFFSET $offset
";
$listStmt = $pdo->prepare($listSql);
$listStmt->execute($params);
$courses = $listStmt->fetchAll();
$csrfToken = $_SESSION['csrf_token'] ?? '';
?>
<form method="get" action="courses.php">
<label>
学期
<input name="semester" value="<?= h($semester) ?>" maxlength="32">
</label>
<label>
课程名或教师名
<input name="q" value="<?= h($q) ?>" maxlength="100">
</label>
<button type="submit">查询</button>
</form>
<table>
<thead>
<tr>
<th>课程</th>
<th>教师</th>
<th>学期</th>
<th>人数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($courses as $course): ?>
<?php
$enrolled = (int)$course['enrolled_count'];
$capacity = (int)$course['capacity'];
$canShowEnroll = $course['status'] === 'open'
&& $enrolled < $capacity;
?>
<tr>
<td>
<?= h((string)$course['course_code']) ?>
—
<?= h((string)$course['title']) ?>
</td>
<td><?= h((string)$course['teacher_name']) ?></td>
<td><?= h((string)$course['semester']) ?></td>
<td><?= $enrolled ?> / <?= $capacity ?></td>
<td>
<?php if ($canShowEnroll && $csrfToken !== ''): ?>
<form method="post" action="enroll.php">
<input type="hidden" name="course_id"
value="<?= (int)$course['id'] ?>">
<input type="hidden" name="csrf_token"
value="<?= h($csrfToken) ?>">
<button type="submit">选课</button>
</form>
<?php else: ?>
暂不可选
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p>
共 <?= $total ?> 门,
第 <?= $page ?> / <?= $totalPages ?> 页
</p>
上面的查询页需要在创建页面时生成 CSRF token;若现有应用尚未统一生成,可以在调用模板之前加上:
<?php
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
random_bytes(32) 生成 32 字节随机数据,bin2hex 将其表示为十六进制字符串。表单中的 token 用于验证请求是否来自持有同一会话的页面,不代替登录认证,也不防止跨站脚本攻击。它应通过 HTML 转义后写入页面,并在服务端使用安全比较函数校验。
查询示例没有只显示开放课程,这是为了让学生也能看到关闭或已满课程的状态。若产品要求隐藏某些课程,应在查询条件中明确加入范围条件,而不是只在页面上隐藏按钮。用户提交请求时,服务端仍必须重新检查规则,浏览器显示状态不能作为权限判断。
五、选课提交:认证、CSRF 与并发名额控制
选课是会改变数据的操作,应使用 POST,而不是通过访问一个 GET 链接完成。下例假设登录成功后将学生主键存入 $_SESSION['student_id']。如果项目使用不同会话字段或身份服务,应替换这段读取逻辑。未登录时返回 401;也可以按项目约定跳转到登录页,但不能从表单参数接收学生 ID 并把它当成真实身份。
<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/db.php';
header('Content-Type: text/plain; charset=UTF-8');
$studentId = filter_var(
$_SESSION['student_id'] ?? null,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]]
);
if ($studentId === false || $studentId === null) {
http_response_code(401);
exit('请先登录');
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit('仅支持 POST 请求');
}
$sessionToken = $_SESSION['csrf_token'] ?? '';
$postedToken = (string)($_POST['csrf_token'] ?? '');
if (
!is_string($sessionToken)
|| $sessionToken === ''
|| !hash_equals($sessionToken, $postedToken)
) {
http_response_code(403);
exit('请求校验失败,请刷新页面后重试');
}
$courseId = filter_var(
$_POST['course_id'] ?? null,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]]
);
if ($courseId === false || $courseId === null) {
http_response_code(400);
exit('课程编号无效');
}
try {
$pdo->beginTransaction();
// 锁定课程行,使同一门课的并发选课请求依次检查名额。
$courseStmt = $pdo->prepare("
SELECT id, capacity, status, enroll_start, enroll_end
FROM courses
WHERE id = :id
FOR UPDATE
");
$courseStmt->execute([':id' => $courseId]);
$course = $courseStmt->fetch();
if (!$course) {
$pdo->rollBack();
http_response_code(404);
exit('课程不存在');
}
if ($course['status'] !== 'open') {
$pdo->rollBack();
http_response_code(409);
exit('该课程当前不接受选课');
}
// 以数据库当前时间判断窗口;前提是时间字段和数据库会话时区约定一致。
$timeStmt = $pdo->prepare("
SELECT
NOW() >= :start_time AND NOW() <= :end_time AS in_window
");
$timeStmt->execute([
':start_time' => $course['enroll_start'],
':end_time' => $course['enroll_end'],
]);
if ((int)$timeStmt->fetchColumn() !== 1) {
$pdo->rollBack();
http_response_code(409);
exit('当前不在选课时间内');
}
$existingStmt = $pdo->prepare("
SELECT id
FROM enrollments
WHERE student_id = :student_id AND course_id = :course_id
");
$existingStmt->execute([
':student_id' => $studentId,
':course_id' => $courseId,
]);
if ($existingStmt->fetch()) {
$pdo->rollBack();
http_response_code(409);
exit('你已经选过这门课程');
}
$countStmt = $pdo->prepare("
SELECT COUNT(*)
FROM enrollments
WHERE course_id = :course_id
");
$countStmt->execute([':course_id' => $courseId]);
$enrolled = (int)$countStmt->fetchColumn();
if ($enrolled >= (int)$course['capacity']) {
$pdo->rollBack();
http_response_code(409);
exit('课程名额已满');
}
$insertStmt = $pdo->prepare("
INSERT INTO enrollments (student_id, course_id)
VALUES (:student_id, :course_id)
");
$insertStmt->execute([
':student_id' => $studentId,
':course_id' => $courseId,
]);
$pdo->commit();
http_response_code(201);
exit('选课成功');
} catch (PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
// 唯一索引仍是防重复的最终保证;并发下可能以数据库异常形式出现。
if ($e->getCode() === '23000') {
http_response_code(409);
exit('该选课记录已存在或违反数据约束');
}
error_log((string)$e);
http_response_code(500);
exit('系统暂时无法处理选课,请稍后重试');
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log((string)$e);
http_response_code(500);
exit('系统发生错误,请稍后重试');
}
锁课程行的关键是:同一课程的请求先取得该行锁,再统计人数并插入。另一个请求必须等待前一个事务结束,随后才检查人数,因此不会都基于同一个旧计数判断“还有一个名额”。所有写入选课表的路径都应遵循相同的锁定约定;若还有管理员批量导入、其他接口或后台任务直接插入记录,就需要确保它们也不会绕过名额规则。
这里的事务不能替代唯一索引。课程行锁保护的是同一门课程的容量判断,唯一索引保护的是同一学生与同一课程的重复记录。数据库异常代码 23000 是 SQLSTATE 类别,可能对应多种完整性约束问题;示例返回了相对通用的提示,没有把它全部认定为重复选课。生产环境可以结合驱动错误码区分具体约束,但不要把数据库原始错误信息返回给用户。
当每个课程的选课量很大时,逐次执行 COUNT(*) 可能带来负载。可考虑在事务中以原子条件更新课程剩余名额计数,再插入选课记录;失败时回滚。但这要求容量计数的初始值、退课释放、导入和修复都保持一致,设计和验证成本更高。不要为了优化而只增加一个计数字段,却不处理并发与数据修复。
六、参数限制与安全用途说明
- 参数化查询:课程名、学期、学生 ID 和课程 ID 都通过 PDO 绑定值,防止这些值被当成 SQL 语法执行。占位符只能绑定数据值,不能绑定表名、列名或排序方向;动态排序字段必须从服务端白名单中选择。
- 分页限制:页码限制在 1 到 100000,页大小固定为 20。这是资源与输入范围控制,不是数据库注入防护的替代方案。数据总量很大时,偏移分页可能变慢,可改为基于最后一条记录主键的游标分页。
- 搜索长度与 LIKE:示例将搜索词截断为最多 100 个 Unicode 字符,并转义
%、_和转义字符!,使其作为字面搜索内容。参数化解决 SQL 语法注入问题,通配符转义则改变 LIKE 的匹配语义,两者用途不同。包含前导通配符的模糊搜索通常不能有效利用普通 B-tree 索引;课程量大时应评估全文索引或专用搜索服务。 - HTML 输出转义:在 HTML 文本和属性中使用
htmlspecialchars,避免课程名、教师名等数据被浏览器解释为标记。转义应按输出上下文选择;如果数据将放入 JavaScript、CSS 或 URL,不能机械地只用 HTML 转义。 - 身份与 CSRF:学生身份从服务端登录会话读取,不能信任浏览器提交的学生编号。CSRF token 用来校验跨站表单请求,但不能代替身份认证、授权检查或 HTTPS。会话 Cookie 应结合站点部署情况设置
Secure、HttpOnly和合适的SameSite属性。 - 业务校验:页面是否显示按钮只是用户体验逻辑;课程状态、开放时间、剩余容量和是否重复,都必须在服务端提交时重新校验。客户端 JavaScript 的校验可以被绕过。
七、常见错误与排查
1. 只在 PHP 里检查余量,然后插入
两个并发请求可能都读到同一个未满状态,随后都插入,导致超过容量。应在数据库事务中锁定同一课程行并在锁内检查、插入;同时保留数据库唯一约束处理重复记录。
2. 只靠“先查有没有记录”防重复
“先查再插”存在并发窗口,两个请求都可能查到不存在。唯一索引才是数据库层面的保证,应用层判断主要用于改善提示。
3. 把所有请求参数拼进 SQL
即使参数看起来是数字,也应先验证范围。普通查询值用预处理语句绑定;只有经过严格整数化的分页数字才可像示例那样拼入 LIMIT。不要把用户传来的排序列名直接连接到 SQL。
4. 选课接口使用 GET
GET 请求可能被浏览器预取、爬虫访问、缓存或链接预览触发,不适合作为改变数据的操作。选课使用 POST,并验证会话身份和 CSRF token。需要防止用户连续点击时,可在前端禁用按钮改善体验,但服务端仍须防重复。
5. 页面显示剩余名额就认为提交时仍有名额
列表只是查询时刻的快照。用户打开页面后,其他人可能先占用名额。选课接口必须重新读取并检查,最终结果以服务端事务处理为准。
6. 忽略日期和时区约定
PHP、MySQL 和应用服务器可能采用不同默认时区。应在部署和连接配置中明确约定,并用选课开始前、开始时、截止时及截止后的边界时间测试。若规则要求截止时刻之后立即关闭,应明确使用 <= 还是 <,不要让不同接口各自采用不同定义。
7. 查询统计方式导致人数重复
将课程、学生、教师等多个一对多关系直接连接后再 COUNT(*),可能把同一选课记录重复计算。应先对选课表分组统计,或对正确的唯一标识使用 COUNT(DISTINCT ...) 并验证查询结果。
8. 把异常原文直接显示给用户
异常可能包含 SQL、表名、连接信息或服务器路径。用户端返回通用错误和合理状态码,详细错误写入受控日志,并配置日志访问权限与保留周期。
八、建议的测试清单
上线前至少覆盖以下场景:
- 不传筛选条件、筛选无结果、中文关键词以及关键词含有
%、_、!。 - 页码为空、负数、非整数、超大值,以及最后一页之后的页码。
- 未登录、错误 CSRF token、缺少课程编号、课程不存在、课程关闭和不在选课时间内。
- 同一学生重复选同一课程,以及两名学生同时竞争课程最后一个名额。
- 达到容量后再提交,确认记录数不超过容量;多个并发请求结束后核对课程选课明细。
- 课程名称或教师名称含 HTML 特殊字符时,页面只显示文本而不执行脚本。
并发测试应使用独立 HTTP 请求或独立数据库连接同时发起,而不是在同一个 PHP 请求中顺序调用两次。测试环境也应使用 InnoDB,并检查请求失败后事务是否回滚、日志是否记录必要信息。
九、部署前检查
确认 PHP 已启用 PDO MySQL 扩展,数据库表使用 InnoDB,连接字符集为 utf8mb4,应用账号只拥有实际需要的数据库权限。数据库密码应从环境配置读取,生产站点使用 HTTPS,并确保会话 Cookie 配置适合部署域名和代理结构。
本文的核心原则是把职责放在正确的位置:预处理语句处理数据库输入,HTML 转义处理浏览器输出,登录会话确定操作者,CSRF token 校验请求来源,数据库约束兜底数据一致性,事务和行锁处理并发名额。只要这些边界清楚,课程查询和选课接口就不必依赖前端状态,也更容易扩展到退课、候补、时间冲突和先修课等后续规则。







