简介:这是一套面向学校招生办、培训机构及中小型考试组织方的PHP在线招生考试报名系统源码,解决传统纸质报名效率低、信息易错漏、缴费与准考证发放滞后等痛点。系统含完整前后台:前台支持考生自助查询准考证、在线打印报名表;后台涵盖报名管理、用户权限控制、考点/考试时间/准考证模板配置、数据统计及分类管理,并集成支付宝即时到账接口(需自行配置),支持一键导出Excel报名表与在线支付闭环。压缩包共1086个文件,以257个核心PHP业务逻辑文件为主,辅以82个JS交互脚本、72个HTML前台页面、36个CSS样式文件及133个PNG图标资源,结构清晰、模块解耦,便于二次开发与部署。资源大小5.54MB,目录中可见WdatePicker.js.bak、jQuery验证库、CHM帮助文档及多版config配置文件,体现工程化设计思路。已有175人学习下载,适合PHP中级开发者快速搭建轻量级考试服务平台或作为Web项目实训案例。
1. 项目概述与功能拆解
1.1 招生考试报名系统到底解决什么问题
先说个现象。每年到了招生季,学校、培训机构甚至一些社会化考试组织方都会被同一件事折磨:报名表收了一堆,有手写的有Excel的有在线表单的,格式五花八门;准考证要么一张张手工填,要么一个个打电话通知来领取;到了考试当天,总有人忘带证件或者把准考证搞丢;考完试想统计一下报名数据,又得对着纸质表格重新录一遍Excel。这些琐碎工作看起来不难,但量一上来,光是核对信息就能逼疯一个教务老师。
这个基于PHP的在线招生考试报名系统,其实就是把这套流程全部搬到线上:考生在线填写报名信息并提交,系统自动生成报名记录;管理员在后台审核考生资格、管理考试场次;考生通过姓名和身份证号在线查询准考证,直接调出包含照片、考场、座位号、考试时间等信息的准考证页面并打印;同时还能随时在线打印报名表存档或用于现场核验。整套系统使用PHP代码实现,部署在常规的PHP + MySQL环境里,代码开源可控,很适合学校、培训机构、驾校、职业资格认证机构这类场景直接拿去用或者二次开发。
这套系统的核心闭环是这样:报名填表 → 后台审核 → 分配考场座位 → 生成准考证 → 考生查询打印 → 现场核验报名表。整个链路都在浏览器里完成,考生不用装任何客户端,管理人员也只需要会打开网页。
1.2 为什么用原生PHP而不是现成的开源系统或框架
其实市面上的开源报名系统并不少,但很多都存在两个让人头疼的问题。
第一是重。很多系统的依赖特别多,装个环境就要折腾半天,Composer拉一堆依赖包,数据库迁移脚本一套一套的,对于只想赶紧把报名这件事跑起来的机构来说,学习成本和部署成本都不低。第二是定制麻烦。招生考试的流程差异非常大——有的按专业报名,有的按场次报名,有的还分初试复试,每个机构的字段需求都不一样。用现成的系统,改起来往往比自己写一个还费劲。
所以我在做这套系统的时候,刻意选了原生PHP来实现。原生的优势非常明显:单文件一个index.php就能入口启动,不需要Composer、不需要框架学习成本、数据库用PDO连MySQL,国内绝大多数虚拟主机都能直接跑起来,放到宝塔面板里十分钟就能上线。对读者来说,你拿到这份源码之后,可以非常轻松地看清每一行代码在做什么,想加字段就加字段,想改逻辑就改逻辑,不会被框架的封装和路由规则卡住。
当然,如果是大型项目或者业务特别复杂的场景,用ThinkPHP、Laravel这类框架会更合适。但作为一个以“在线报名、准考证查询、在线打印报名表”为核心功能的系统,原生PHP已经是最平衡的选择了。它足够轻,也足够透。
2. 数据库设计与系统架构
2.1 核心数据表设计思路
在设计数据库之前,我先把整个系统的数据流梳理了一遍。系统中存在的核心角色有三个:考生、管理员、考试场次。围绕着这三个角色,数据关系是:一个考生报名某个考试场次,报名后产生一条报名记录,管理员审核这条记录并分配座位号,然后根据这条记录生成准考证。
表的设计不需要太复杂,四张核心表就够用:
exam_sessions:考试场次表,存放每场考试的名称、时间、地点、考场容量等基本信息。students:考生基础信息表,存放姓名、身份证号、手机号、照片路径等。signups:报名记录表,存放考生与考试场次的关联,以及报名状态、准考证号、座位号等信息。admins:管理员账号表,用于后台登录。
这里我特意把考生基础信息和报名记录分开,而不是把所有字段一股脑塞进一张表。原因是同一个考生可能同时报多个项目,比如既报笔试又报面试,这时候如果字段全部放在报名表里,数据就会大量冗余。拆成两张表之后,考生的基本信息只存一份,报名记录只是引用考生的ID,扩展性要好很多。
2.2 表结构详细设计与字段说明
直接把我实际用的建表SQL贴出来,你可以照着建。
CREATE DATABASE IF NOT EXISTS exam_system DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE exam_system; -- 考试场次表 CREATE TABLE `exam_sessions` ( `id` int(11) NOT NULL AUTO_INCREMENT, `exam_name` varchar(100) NOT NULL COMMENT '考试名称,如2025年春季招生考试', `exam_date` date NOT NULL COMMENT '考试日期', `exam_time` varchar(50) NOT NULL COMMENT '考试时间段,如09:00-11:30', `exam_location` varchar(200) NOT NULL COMMENT '考试地点', `capacity` int(11) NOT NULL DEFAULT 0 COMMENT '考场容量', `signup_deadline` datetime NOT NULL COMMENT '报名截止时间', `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '场次状态 1开放 0关闭', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试场次表'; -- 考生基础信息表 CREATE TABLE `students` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '考生姓名', `id_card` varchar(18) NOT NULL COMMENT '身份证号,唯一索引', `phone` varchar(20) NOT NULL COMMENT '手机号', `photo_path` varchar(255) DEFAULT NULL COMMENT '证件照路径', `address` varchar(255) DEFAULT NULL COMMENT '联系地址', `remark` text COMMENT '备注,用于填一些特殊说明', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_id_card` (`id_card`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考生基础信息表'; -- 报名记录表 CREATE TABLE `signups` ( `id` int(11) NOT NULL AUTO_INCREMENT, `student_id` int(11) NOT NULL COMMENT '关联考生ID', `session_id` int(11) NOT NULL COMMENT '关联考试场次ID', `ticket_no` varchar(32) NOT NULL COMMENT '准考证号,唯一', `seat_no` varchar(20) DEFAULT NULL COMMENT '座位号', `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '审核状态 0待审核 1通过 2不通过', `payment_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '支付状态,预留字段,0未支付 1已支付', `admission_notes` varchar(500) DEFAULT NULL COMMENT '考场注意事项,比如需携带2B铅笔等', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_ticket_no` (`ticket_no`), KEY `idx_session_id` (`session_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报名记录表'; -- 管理员账号表 CREATE TABLE `admins` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password_hash` varchar(255) NOT NULL COMMENT '密码使用password_hash函数加密', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员账号表';字段设计上有几个细节你需要特别注意。
身份证号在建表的时候我用了char(18)而不是varchar(18),因为身份证号长度固定,用定长字段在MySQL底层查询的时候效率更高,而且不会有尾部空格的问题。同时加了唯一索引,防止同一个考生重复注册——注意不同人身份证号不可能重复,这就是天然的候选主键,只是索引长度比自增主键长,所以还是保留自增主键,给身份证号加唯一约束就够了。
准考证号ticket_no的生成规则我在下面会细讲,这里先提醒一句:务必加唯一索引。因为准考证号要作为考生查询的凭证之一,如果有重复,后面查询就会出问题。
2.3 目录结构与路由设计
因为是原生PHP,不需要复杂的路由配置,我采用的目录结构如下:
exam-system/ ├── index.php # 入口文件,也是考生报名首页 ├── config.php # 数据库配置 ├── common.php # 公共函数,如生成准考证号、判断登录等 ├── ticket_query.php # 准考证查询页 ├── print_signup.php # 在线打印报名表页 ├── admin/ │ ├── login.php # 管理员登录 │ ├── dashboard.php # 后台首页 │ ├── sessions.php # 场次管理 │ ├── signups.php # 报名审核列表 │ └── export.php # 导出Excel ├── uploads/ # 考生照片上传目录 └── assets/ ├── css/ └── js/入口文件index.php同时承担报名表单展示和提交处理的职责,所以里面有个简单的判断:如果是GET请求就显示表单,如果是POST请求就处理数据。在实际项目中很多页面都是这个模式,不一定要用MVC框架那套,特别是业务逻辑不复杂的情况下。
3. 核心功能实现与代码解析
3.1 考生报名功能的完整实现
报名是整个系统的入口,也是数据质量的关键。这一步如果没做好,后面审核和打印全都会出问题。我的报名页面在index.php里实现,表单包含姓名、身份证号、手机号、照片上传、报考场次选择等字段。
先说前端提交部分。表单用原生HTML加一点简单的JavaScript做校验,不依赖任何前端框架,页面加载速度很快:
// index.php 报名表单核心部分 <form id="signupForm" action="index.php" method="post" enctype="multipart/form-data"> <div class="form-group"> <label>姓名</label> <input type="text" name="name" required maxlength="50"> </div> <div class="form-group"> <label>身份证号</label> <input type="text" name="id_card" id="idCard" required maxlength="18" placeholder="18位身份证号"> </div> <div class="form-group"> <label>手机号</label> <input type="text" name="phone" required maxlength="20" pattern="1[3-9][0-9]{9}"> </div> <div class="form-group"> <label>证件照</label> <input type="file" name="photo" accept="image/*" required> <p class="tip">支持jpg/png格式,大小不超过2MB</p> </div> <div class="form-group"> <label>报考场次</label> <select name="session_id" required> <?php // 从数据库读取当前开放的场次 $stmt = $pdo->query("SELECT id, exam_name, exam_date, exam_time, capacity FROM exam_sessions WHERE status = 1 AND signup_deadline > NOW()"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { echo '<option value="' . $row['id'] . '">' . htmlspecialchars($row['exam_name']) . ' - ' . $row['exam_date'] . ' ' . $row['exam_time'] . '</option>'; } ?> </select> </div> <button type="submit">提交报名</button> </form>这里有一个关键点:场次的下拉框是服务端渲染出来的,查询的时候同时过滤了status = 1和报名截止时间,也就是说已经关闭的场次根本不会显示在页面上。这比前端JS拉数据再渲染要简单可靠得多,也是PHP这类服务端脚本语言处理表单场景的传统优势。
提交处理的部分,我直接写在index.php的顶部:
if ($_SERVER['REQUEST_METHOD'] === 'POST') { try { $name = trim($_POST['name']); $id_card = trim($_POST['id_card']); $phone = trim($_POST['phone']); $session_id = intval($_POST['session_id']); // 校验身份证号:18位,最后一位可能是X if (!preg_match('/^\d{17}[\dX]$/i', $id_card)) { throw new Exception('身份证号格式不正确'); } // 先检查是否已存在 $stmt = $pdo->prepare("SELECT id FROM students WHERE id_card = ?"); $stmt->execute([$id_card]); $student = $stmt->fetch(); if ($student) { $student_id = $student['id']; } else { // 新增考生 $stmt = $pdo->prepare("INSERT INTO students (name, id_card, phone) VALUES (?, ?, ?)"); $stmt->execute([$name, $id_card, $phone]); $student_id = $pdo->lastInsertId(); } // 处理照片上传 $photo_path = handlePhotoUpload($_FILES['photo'], $student_id); // 生成准考证号并写入报名记录 $ticket_no = generateTicketNo($session_id); $stmt = $pdo->prepare("INSERT INTO signups (student_id, session_id, ticket_no, seat_no, status) VALUES (?, ?, ?, ?, 0)"); $stmt->execute([$student_id, $session_id, $ticket_no, '']); // 发送成功的提示 echo '<div class="alert alert-success">报名成功!您的准考证号是:' . htmlspecialchars($ticket_no) . ',请截图保存。审核通过后可在线查询打印准考证。</div>'; } catch (Exception $e) { echo '<div class="alert alert-danger">' . htmlspecialchars($e->getMessage()) . '</div>'; } }3.2 准考证号生成规则与防重策略
准考证号是整套系统的核心标识,规则定了就不能变,否则以后扩展和查询都会出问题。我的生成规则参考了实际考试中常见的准考证号格式:年份 + 场次ID + 三位流水号。
我先说使用场景:一个机构一年可能组织几场考试,每场考试几百人报名,准考证号如果有规律,管理员处理起来会很方便,考生也觉得正规。
function generateTicketNo($sessionId) { $year = date('Y'); $prefix = $year . str_pad($sessionId, 3, '0', STR_PAD_LEFT); $stmt = $pdo->prepare("SELECT COUNT(*) FROM signups WHERE ticket_no LIKE ?"); $stmt->execute([$prefix . '%']); $count = $stmt->fetchColumn(); $ticketNo = $prefix . str_pad($count + 1, 3, '0', STR_PAD_LEFT); // 万一并发下重复,就循环加1直到唯一 while (checkTicketNoExists($ticketNo)) { $count++; $ticketNo = $prefix . str_pad($count + 1, 3, '0', STR_PAD_LEFT); } return $ticketNo; }这个方法的逻辑不复杂:前缀是年份加场次ID,后缀是当天报名人数的流水号。比如2025001001,含义是2025年第001号场次的第001号考生。
但这里有个隐患——如果两个人同时提交报名,COUNT查出来的数字是相同的,就会生成同样的准考证号。虽然建表时加了唯一索引兜底,真撞了就会报SQL异常,但用户体验不好。更稳妥的方式是用数据库的AUTO_INCREMENT拿到ID后再拼接,或者更严谨一点,直接把signups表的自增ID作为准考证号的一部分。我后来在实际部署中发现并发量其实不大,用这个方案加唯一索引保障已经完全够用,所以保守起见保持了上面的写法。如果你预期并发量很高,建议把准考证号改成$year . str_pad($studentId, 6, '0', STR_PAD_LEFT)这种基于考生ID的方式,天然唯一。
3.3 准考证查询功能实现
准考证查询是考生最常用的功能。我的实现方案非常简单:考生输入姓名和身份证号,系统在signups表关联查询,返回准考证信息和状态。
这一步的关键在于:查询条件到底用哪个字段。有的系统要求输入准考证号才能查,但对考生来说,准考证号本身就是要查出来的东西,这就不太合理。所以这里我用“姓名 + 身份证号”作为查询条件,身份证号本身具有唯一性,再加姓名是双重校验,防止别人拿到身份证号就能查。
// ticket_query.php 核心代码 if ($_SERVER['REQUEST_METHOD'] === 'POST') { $name = trim($_POST['name']); $id_card = trim($_POST['id_card']); $stmt = $pdo->prepare(" SELECT s.name, s.id_card, s.photo_path, e.exam_name, e.exam_date, e.exam_time, e.exam_location, u.ticket_no, u.seat_no, u.status, u.admission_notes FROM signups u INNER JOIN students s ON u.student_id = s.id INNER JOIN exam_sessions e ON u.session_id = e.id WHERE s.name = ? AND s.id_card = ? AND u.status = 1 "); $stmt->execute([$name, $id_card]); $result = $stmt->fetch(PDO::FETCH_ASSOC); if ($result) { // 显示准考证 renderTicket($result); } else { echo '<div class="alert alert-warning">未查询到已审核通过的准考证信息,请核对姓名和身份证号是否填写正确。</div>'; } }这里有个容易踩的坑:status字段如果默认值是0,考生提交报名后立刻去查,是查不到准考证的。因为管理员还没审核。所以我在查询SQL里把AND u.status = 1写死,状态为1才显示准考证信息。这样设计有业务上的考量——如果报名还没审核通过就把准考证号显示出来,考生可能会直接拿号去考试,而实际上考场安排可能还没定,容易出乱子。
3.4 在线打印报名表实现
在线打印报名表这个功能,很多同学容易做复杂。实际上它就是两个核心点:一个是把报名信息从数据库里取出来,按照固定表格格式展示在浏览器里;另一个是调用浏览器的打印能力,把这段内容输出成纸质文件。后者不需要后端参与,完全靠CSS控制。
打印页面print_signup.php接受一个ticket_no参数,验证后展示报名表:
// print_signup.php $ticket_no = $_GET['ticket_no']; if (empty($ticket_no)) { die('缺少准考证号参数'); } $stmt = $pdo->prepare(" SELECT s.name, s.id_card, s.phone, s.photo_path, s.address, e.exam_name, e.exam_date, e.exam_time, e.exam_location, u.ticket_no, u.seat_no, u.admission_notes, u.created_at FROM signups u INNER JOIN students s ON u.student_id = s.id INNER JOIN exam_sessions e ON u.session_id = e.id WHERE u.ticket_no = ? "); $stmt->execute([$ticket_no]); $data = $stmt->fetch(PDO::FETCH_ASSOC);然后HTML部分就是一张标准的信息表格,样式上尽量做成A4纸打印友好的。这里关键的是CSS:
@media print { body * { visibility: hidden; } .print-area, .print-area * { visibility: visible; } .print-area { position: absolute; left: 0; top: 0; width: 100%; } .no-print { display: none !important; } }这套CSS写法的思路很简单:打印的时候,隐藏页面上所有元素,只显示.print-area区域里的内容。这样页面上就算有导航栏、按钮、提示语,打印出来也只有报名表主体内容。@media print是浏览器专用于打印的媒体类型规则,你不需要额外引入任何库,浏览器原生支持。
3.5 照片上传与文件管理细节
照片上传是报名系统里容易出问题的环节。我见过很多系统在照片上传上栽跟头——要么是路径写错了图片显示的裂图,要么是文件没做类型校验被传了病毒,要么是文件名是中文导致浏览器打不开。
我的实现里,核心点有两个:严格校验文件类型和大小,把文件名改成随机字符串保存。
function handlePhotoUpload($file, $studentId) { $allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $file['tmp_name']); if (!isset($allowed[$mime])) { throw new Exception('只支持JPG和PNG格式的图片'); } if ($file['size'] > 2 * 1024 * 1024) { throw new Exception('图片大小不能超过2MB'); } $ext = $allowed[$mime]; $filename = 'stu_' . $studentId . '_' . time() . '.' . $ext; $targetDir = __DIR__ . '/uploads/'; if (!is_dir($targetDir)) { mkdir($targetDir, 0755, true); } $targetPath = $targetDir . $filename; if (!move_uploaded_file($file['tmp_name'], $targetPath)) { throw new Exception('照片保存失败,请检查目录写入权限'); } return 'uploads/' . $filename; }光看文件名,stu_12_1739264234.jpg,这就有两重信息在里面:12是考生ID,1739264234是时间戳。好处是不用查数据库就可以知道这个文件属于谁,排查问题很方便。
还有个细节:照片路径存的是相对路径uploads/xxx.jpg而不是/uploads/xxx.jpg。这样系统部署在子目录也能正常显示,不用改配置。
4. 后台审核与管理功能
4.1 管理员登录与会话管理
后台管理功能我单独放在admin/目录下,第一件事就是登录认证。用PHP原生的session来控制登录状态,密码用password_hash函数加密存储,彻底告别明文密码的问题。
// admin/login.php session_start(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username']); $password = $_POST['password']; $stmt = $pdo->prepare("SELECT id, username, password_hash FROM admins WHERE username = ?"); $stmt->execute([$username]); $admin = $stmt->fetch(); if ($admin && password_verify($password, $admin['password_hash'])) { $_SESSION['admin_id'] = $admin['id']; $_SESSION['admin_name'] = $admin['username']; header('Location: dashboard.php'); exit; } else { $error = '用户名或密码错误'; } }每个后台页面顶部都要做登录判断,我封装了一个函数:
require_login(); function require_login() { session_start(); if (empty($_SESSION['admin_id'])) { header('Location: login.php'); exit; } }一个容易被忽略的点:生产环境里session的有效期和安全性很重要。建议在php.ini里把session.cookie_httponly设为1,这样可以防止JavaScript脚本读取session cookie,减少XSS攻击拿到会话的风险。另外后台页面在HTTPS环境下部署的话,session.cookie_secure也建议开启。
4.2 报名审核与考场分配
管理员登录后台后,主要的工作就是审核报名记录、给通过审核的考生分配座位号。这个功能集中体现在admin/signups.php页面上。
我实际做的列表页长这样:一张表格,每一行是一条报名记录,显示考生的姓名、身份证号、报考场次、照片预览、状态,右侧是操作按钮——通过、不通过、分配座位号。
审核通过的时候同时分配座位号。座位号的分配策略不复杂:每个场次有固定的容量,我按审核通过的顺序从1号开始排。程序里需要实时查询某个场次已分配的最大座位号,然后加1。
// 分配座位号 $sessionId = $_POST['session_id']; $stmt = $pdo->prepare("SELECT MAX(seat_no) FROM signups WHERE session_id = ? AND seat_no != '' AND status = 1"); $stmt->execute([$sessionId]); $maxSeat = $stmt->fetchColumn(); $newSeat = intval($maxSeat) + 1;如果某场考试要设置人数上限,你还需要在查询里判断COUNT(*)是否小于capacity,避免超额。我在代码里加了这层校验:
$capacityStmt = $pdo->prepare(" SELECT e.capacity, COUNT(u.id) as signed_up FROM exam_sessions e LEFT JOIN signups u ON e.id = u.session_id AND u.status = 1 WHERE e.id = ? GROUP BY e.id "); $capacityStmt->execute([$sessionId]); $row = $capacityStmt->fetch(); if ($row['signed_up'] >= $row['capacity']) { die('该场次名额已满'); }4.3 报名数据导出Excel
导出功能虽然不直接影响考生体验,但绝对是管理员的刚需。机构报完名往往需要把数据交给财务、教务、监考老师等不同角色,没有导出功能,就只能后台截图或者手工抄表。
我没用PHPExcel或者PhpSpreadsheet这类重库,而是用PHP生成CSV文件来解决。CSV可以直接用Excel打开,兼容性完全没问题,而且代码量少得多:
// admin/export.php require_once '../common.php'; require_login(); $filename = '报名数据_' . date('Ymd_His') . '.csv'; header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); $output = fopen('php://output', 'w'); // 写入表头 fputcsv($output, ['姓名', '身份证号', '手机号', '报考场次', '考试日期', '准考证号', '座位号', '审核状态']); $stmt = $pdo->query(" SELECT s.name, s.id_card, s.phone, e.exam_name, e.exam_date, u.ticket_no, u.seat_no, u.status FROM signups u INNER JOIN students s ON u.student_id = s.id INNER JOIN exam_sessions e ON u.session_id = e.id ORDER BY e.exam_date, u.id "); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { // 把状态码转换为文字 $statusText = ['待审核', '已通过', '不通过'][$row['status']]; fputcsv($output, [$row['name'], $row['id_card'], $row['phone'], $row['exam_name'], $row['exam_date'], $row['ticket_no'], $row['seat_no'], $statusText]); } fclose($output);这里有坑:CSV导出中文名会乱码。原因是CSV标准其实是ASCII文本,Excel解析的时候默认用系统本地编码,而Linux服务器的默认locale一般是UTF-8。解决办法是输出前加BOM头。上面代码里我加了output之前,实际上需要这样处理:
echo "\xEF\xBB\xBF"; // UTF-8 BOM,让Excel正确识别编码这个不起眼的操作能让你省去不少“导出Excel中文乱码”的搜索时间。
5. 部署配置与常见问题排查
5.1 环境要求与一键部署步骤
这套系统的运行环境要求很低,传统PHP装机三件套就能跑:
| 组件 | 版本要求 | 说明 |
|---|---|---|
| PHP | 7.0+ | 推荐7.4或8.0,PDO及pdo_mysql扩展必须开启 |
| MySQL | 5.6+ | 推荐MySQL 8.0或MariaDB 10.x |
| Web服务器 | Apache/Nginx | Apache需要开启mod_rewrite,Nginx需要配置PHP-FPM |
部署流程也很简单:
- 用宝塔面板或者手动创建站点,PHP版本选择7.0以上。
- 把源码上传到站点根目录。
- 创建数据库,把上面建表SQL语句直接导入。
- 修改
config.php里的数据库连接配置。 - 手动插入管理员账号:
INSERT INTO admins (username, password_hash) VALUES ('admin', '$2y$10$...'),这里的hash值用password_hash('你想要的密码', PASSWORD_DEFAULT)生成,或者写一个install.php初始化脚本来自动创建。
如果是本地调试,用phpStudy这类集成环境更快,解压源码放进去就能跑。整个部署流程大概也就是十分钟的事。
5.2 照片上传失败的排查思路
照片上传失败是我在实际使用中遇到最多的一个问题,而且后台报错信息往往不直观。大部分情况排查方向就这几条。
第一,看uploads目录是否存在、是否有写入权限。Apache/Nginx运行用户(一般是www或www-data)必须对该目录有写权限。很多虚拟主机不用命令行,就通过FTP把uploads目录权限设为755,不行就775,再不行就777,后两者主要用于测试环境。
第二,PHP上传配置限制。默认的upload_max_filesize通常是2MB,如果照片稍微大一点就会被PHP拒绝,却在页面上直接报了500错误。建议在php.ini里改成:
upload_max_filesize = 5M post_max_size = 6M这两个配置配套用,只改前者不改后者,文件超过post_max_size一样报错。
第三,move_uploaded_file失败。这个情况最常见的原因不是目录权限,而是tmp_name目录不可写,或者文件名里的路径拼接出错了。一个实用的排查技巧是:在move_uploaded_file之前先打印$file['error'],对应错误码能直接告诉你问题出在哪。
5.3 打印样式错乱的处理
在线打印时最常见的毛病是:浏览器预览的时候页面很好看,一点打印就多出一张空白页或者表格被切得七零八落。这基本都和CSS打印样式没有写好有关。
我建议的排查顺序是:
第一,查看页面里是否有position: fixed的导航栏或按钮,打印时这些元素会固定在每一页纸上。用了我前面说的visibility: hidden方案可以解决。
第二,注意表格是否在页面中间被截断。给表格的每一行加page-break-inside: avoid,可以防止浏览器在行中间断页。
@media print { .print-area table tr { page-break-inside: avoid; } }第三,A4纸张的宽度通常是210mm,如果页面宽度设成了100%的网页宽度,打印出来就会被压缩到左边。设置:
@media print { .print-area { width: 190mm; margin: 0 auto; } }5.4 常见问题速查表
| 问题现象 | 排查方向 | 解决方案 |
|---|---|---|
| 页面显示数据库连接错误 | 数据库名、密码配置错误 | 检查config.php,确认数据库账号有权限访问对应库 |
| 报名提交后刷新出现重复提交 | 页面没有做防重复处理 | 提交成功后可跳转到成功页/使用SESSION标记本次提交 |
| 准考证查询查不到数据 | 状态不是1(待审核) | 先确认后台审核是否已通过,再检查姓名身份证是否匹配 |
| 导出CSV中文乱码 | 缺少UTF-8 BOM头 | 输出前添加echo "\xEF\xBB\xBF"; |
| 照片上传500错误 | 上传限制过小或目录无权限 | 调整upload_max_filesize和uploads目录写权限 |
| 部署后样式不显示 | 静态资源路径绝对 | 检查assets目录是否被整体复制,有时删除掉了CSS/JS文件 |
| 打印预览出现重复页眉 | 浏览器自带页眉页脚 | 在打印对话框中关闭“页眉和页脚”选项,或CSS设置@page的margin |
这里还要提一个安全原则:始终用PDO的预处理语句绑定参数来操作数据库,绝对不要用字符串拼接SQL。尤其在这个系统里有大量用户输入会进数据库,如果直接$pdo->query("SELECT * FROM students WHERE id_card = '$id_card'")这样拼接,一个恶意的身份证号输入就能拖走整个表。预处理语句不复杂,就是多写一行,却是这个系统最值得保住的安全性底线。
6. 二次开发与功能扩展建议
6.1 根据实际需求定制字段
招生考试在不同场景下的需求差异很大。比如考研培训机构需要采集考生的本科院校和毕业专业,驾校需要采集报考车型,职业资格认证机构需要采集工作年限。这些个性化字段在原系统里并不存在,但二次开发时只需要按下面的步骤来:
- 在
students表里加字段,比如ALTER TABLE students ADD COLUMN school varchar(100) DEFAULT NULL COMMENT '毕业院校'; - 在报名表单中添加对应的输入框。
- 在
index.php的POST处理逻辑中把新字段插入数据库,INSERT语句里加上新字段名,VALUES里加上对应参数。 - 在后台列表页、导出函数中按需展示。
整个过程不涉及框架层级的改动,只要SQL语句和HTML标签对应上就行。
我实际改过最复杂的一次,是给一个考试机构加了“考生考点选择”功能——同一个考试在三个校区举行,考生报名时就要选择校区。添加一个exam_location_id字段,然后改一下考场分配逻辑,分配座位的时候按校区分别计数。这个改动前后不超过一小时,原生PHP的可维护性在这里体现得淋漓尽致。
6.2 增加在线支付功能的思路
有些报名需要在线缴费,很多读者都会问能不能在这个系统里接支付。答案是可以的,而且不复杂。
流程是这样:在signups表里我已经预留了payment_status字段,0是未支付,1是已支付。前端在报名完成后显示一个“去支付”按钮,后端生成支付二维码的时候带上一个订单号(就用ticket_no作为关联)。支付回调的接口里查询订单号,把对应记录的payment_status更新为1。等支付成功之后,管理员审核时就能参考支付状态了。
需要注意的点:支付回调的签名校验一定要认真实现,不要信任任何外部传入的参数。具体到微信支付,需要根据你的商户号、API密钥等生成签名,用官方SDK能省很多事。我自己的建议是先用沙箱环境跑通,再切到正式环境。
6.3 多场次管理与批量导入导出
如果机构一年组织几十场考试,光靠后台手动一个个填场次信息效率太低。这时候可以做一个非常实用的功能——批量导入场次。用CSV或者JSON格式,一次批量录入几十个场次的信息。原理就是解析上传的文件,循环执行INSERT语句,加个事务保证原子性,中间遇到错误回滚。
导出的方向也可以再加一层:支持按场次导出准考证名单,Excel带照片的那种。这个用PHPExcel或者更轻的PhpSpreadsheet库来实现,给后台加一个“导出本场次考生名单”,把照片路径转为图片写入单元格。这个功能在考场确认身份时很方便,监考老师拿到一份带照片的名单,就不用对着身份证一个个核对了。
6.4 发送短信通知的接入
线上报名还有一个痛点:考生提交报名后不知道自己有没有报上,也容易忘考试时间。我后面给这套系统加过短信通知功能,接入一个短信服务商的API,在几个关键节点触发:
- 报名成功后:给考生发一条短信提醒,告知“报名已提交,请等待审核”。
- 审核通过后:告知考生可以登录查询并打印准考证。
- 考试前一天:提醒考试时间和地点,附上注意事项。
接入的原理不复杂——就是调用第三方API发送POST请求,把内容提交过去。一般主流的服务商都有PHP SDK,封装好了签名和发送逻辑。只要在代码里预留一个钩子函数,在审核通过的逻辑中加一行调用即可。
这一步对提升机构正规性感知非常明显,考生体验完全不同。
7. 安全加固与上线注意事项
7.1 文件上传安全
照片上传是整个系统无法绕开的功能,同时也是风险最高的入口。如果你允许用户上传图片,你相当于把服务器的一个目录开放给了外部输入。我建议用以下几个手段把风险压到最低:
第一,用服务端判断文件类型,用finfo函数读取文件的真实MIME类型,而不是信任浏览器传上来的$_FILES['photo']['type']。这个字段用户可以伪造。
第二,文件名必须重写。保留用户上传的原始文件名是我见过最大的安全坑——如果用户传一个shell.php,系统即使拦截了后缀,其他场景也可能出大问题。像我前面代码那样,用student_id + 时间戳重命名新文件,后缀使用白名单里的扩展名,基本可以杜绝这类问题。
第三,上传目录执行权限要关掉。如果你用Nginx + PHP-FPM,在uploads目录的location配置里加上php_admin_value engine off,或者让Nginx对uploads目录直接返回403而不转发给PHP处理:
location ~* ^/uploads/.*\.(php|php5|phtml)$ { deny all; }这一点我在实际项目里见过太多因为配置疏漏导致的webshell上传事件了。
7.2 SQL注入与XSS防护
这两个词听起来像教科书术语,但在实际业务里就是实实在在的攻击手段。先说SQL注入——攻击者在输入框里填了' OR 1=1 --这种内容,如果代码是字符串拼接SQL,就会导致本应查询单条记录的逻辑变成查询全部记录,数据泄露的风险非常大。防御的方式是全文统一使用PDO预处理语句,绑定参数,不给用户输入直接进入SQL的机会。
XSS的防御也很简单——所有输出到页面的动态内容,一律用htmlspecialchars()转义。我在上面的打印准考证代码里已经展示过:htmlspecialchars($data['name'])。如果少了这层转义,攻击者可以在姓名里填一段JavaScript代码,管理员打开后台列表时脚本就会执行,轻则弹窗骚扰,重则窃取管理员的session。
7.3 上线检查清单
根据我部署多个项目的经验,上线前最好按这个清单过一遍:
- 数据库连接用户名不要用
root,建议创建一个权限最小化的专用账号。 .php文件头部不要出现display_errors设为On的配置,生产环境必须关闭错误显示,改为记录日志。- 后台路径不要用
admin这种默认字典里的路径,改成admin_xxxx这种随机字符串,大幅减少被自动扫描工具扫描到的概率。 - 定期备份数据库。最简单的可以用crontab定时任务执行
mysqldump:
0 2 * * * mysqldump -u exam_user -p'密码' exam_system > /backup/exam_$(date +\%Y\%m\%d).sql- 照片上传目录设置一个容量上限,防止被当图床,磁盘满了导致系统挂掉。
8. 写在最后的实操心得
我开发这套系统的初衷,是做一套看得见、摸得着、改得动的PHP实战项目。没有框架的黑盒封装,没有复杂到看不懂的抽象层,一个有一定PHP基础的人拿到源码,逐行读下去就能明白整个需求从数据库到页面渲染的完整链路。
在实际使用中,这套系统后来被部署到过好几个机构,最容易被忽略但直接影响体验的,其实是打印样式和查询路径。打印样式决定了你给考生的第一印象,查询路径决定了考生最后一天是否能顺利找到自己的准考证。很多人把精力花在登录、权限这些技术噱头上,却忘了对考生来说,整个系统就两个页面:报名页和打印准考证页。把这两件小事做到极致,远比堆功能有意义。
如果你打算基于这套源码做毕业设计或者商业项目,我的建议是先完整跑通一遍“报名 → 审核 → 查询 → 打印”的流程,理解每个环节的字段流向,再动手改代码。等到第二遍,你可以试着加上在线支付、短信通知、批量导入这些扩展功能,每次都只围绕一个核心改动,观察数据库和页面的变化,这样的成长路径会非常扎实。
最后再分享一个小技巧:我每次上线前,都会用一个完全不存在的身份证号、一个重复的身份证号、一个带引号的姓名、一张改名为.php的图片分别测试一遍。只有这四个测试用例全过了,我才放心把系统交给客户用。这个习惯帮我拦截过至少十次低级事故,今天也分享给你。
本文还有配套的精品资源,点击获取