PHP学校选课系统开发:课程查询与选课功能实现

生活资讯2小时前发布 jdkjadmin
0 00
PHP学校选课系统开发:课程查询与选课功能实现

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 校验请求来源,数据库约束兜底数据一致性,事务和行锁处理并发名额。只要这些边界清楚,课程查询和选课接口就不必依赖前端状态,也更容易扩展到退课、候补、时间冲突和先修课等后续规则。

© 版权声明

相关文章