
在线预约系统是现代服务型网站常见的功能模块,它允许访客自助选择时间、填写需求并提交申请,从而替代传统电话或人工登记流程。对于许多中小型项目而言,从头开发一套完整的预约系统成本较高,而直接复用并改造一段稳定的PHP核心代码,则是高效且经济的方案。本文提供一套基础的预约处理逻辑,涵盖数据库设计、表单提交、时段校验、冲突检测及结果返回等关键环节,并预留了充分的扩展接口,方便您根据实际业务场景进行调整。
本段代码面向单资源、多时段的预约模型,例如:咨询时段预约、工位使用申请、设备借用登记、课程试听报名等。其核心能力包括:
展示当前可预约的日期与时间片;
限制每个时段的最大预约人数;
防止同一用户重复提交(基于Session或IP简单限流);
将预约记录写入数据库,并返回成功或失败的状态信息;
基础的时间冲突判断(例如:已满时段不再接受新单)。
若您的业务涉及多资源并行(如多个房间、多位服务人员)、周期性规则(如每周一闭馆)、或复杂的价格计算,本文末尾会给出改造方向,但主体代码依然可作为底层逻辑的起点。
在部署代码前,请确保服务器满足以下最低要求:
PHP版本 ≥ 7.4(推荐8.0及以上,以支持更严格的类型声明);
MySQL 5.7 或 MariaDB 10.3 以上,并开启PDO扩展;
Web服务器(Apache/Nginx)正确配置URL重写规则(非必须,但建议开启);
网站已建立全局数据库连接对象($db),且字符集统一为utf8mb4。
您需要预先创建一张预约记录表,推荐结构如下(可根据实际字段增减):
sql
CREATE TABLE `appointments` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_name` varchar(50) NOT NULL, `user_phone` varchar(20) NOT NULL, `user_email` varchar(100) DEFAULT NULL, `appoint_date` date NOT NULL, `appoint_time_slot` tinyint(2) NOT NULL COMMENT '时段编号:1上午 2下午 3晚间等', `remark` text DEFAULT NULL, `status` tinyint(1) DEFAULT 1 COMMENT '1有效 0取消', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `ip_address` varchar(45) DEFAULT NULL, `session_id` varchar(64) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `unique_booking` (`appoint_date`,`appoint_time_slot`,`user_phone`), KEY `idx_date_slot` (`appoint_date`,`appoint_time_slot`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
其中unique_booking唯一索引防止同一手机号在同一天同一时段重复预约,您也可以改为user_email或去掉该约束,改为业务层检测。
同时,建议维护一个时段配置表(或直接写在PHP常量中),便于前端下拉渲染:
sql
CREATE TABLE `time_slots` ( `slot_id` tinyint(2) NOT NULL, `slot_name` varchar(20) NOT NULL, `start_time` time NOT NULL, `end_time` time NOT NULL, `max_capacity` smallint(4) DEFAULT 5, `is_active` tinyint(1) DEFAULT 1, PRIMARY KEY (`slot_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
初始化几条示例数据:1-上午(09:00-12:00,容量5人),2-下午(14:00-17:00,容量5人),3-晚间(19:00-21:00,容量3人)。
以下为预约提交处理的入口文件 booking_submit.php,采用面向过程风格但清晰分层,方便封装为类。
php
<?php// 开启会话用于简单防刷if (session_status() === PHP_SESSION_NONE) {
session_start();}// 引入数据库连接(请根据实际路径调整)require_once __DIR__ . '/db_connect.php';// 设置响应格式为JSON(适用于前后端分离或Ajax提交)header('Content-Type: application/json; charset=utf-8');// 仅接受POST请求if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['code' => 405, 'msg' => '仅支持POST提交']);
exit;}// --- 1. 获取并过滤输入 ---$name = trim($_POST['user_name'] ?? '');$phone = trim($_POST['user_phone'] ?? '');$email = isset($_POST['user_email']) ? trim($_POST['user_email']) : null;$date = $_POST['appoint_date'] ?? '';$slotId = (int)($_POST['slot_id'] ?? 0);$remark = isset($_POST['remark']) ? trim($_POST['remark']) : '';// 基础验证:必填项$errors = [];if (mb_strlen($name) < 2 || mb_strlen($name) > 30) {
$errors[] = '姓名请填写2~30个字符';}if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { // 简单手机号格式(可替换为更宽松规则)
$errors[] = '手机号格式不正确';}if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = '邮箱格式无效';}if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$errors[] = '日期格式错误,请使用YYYY-MM-DD';}if ($slotId < 1) {
$errors[] = '请选择有效的时段';}// 额外日期校验:不能早于今天(允许当天,可根据业务修改)$today = date('Y-m-d');if ($date < $today) {
$errors[] = '预约日期不能早于今天';}// 可选:限制最大提前天数(例如30天)$maxDate = date('Y-m-d', strtotime('+30 days'));if ($date > $maxDate) {
$errors[] = '仅支持未来30天内的预约';}if (!empty($errors)) {
echo json_encode(['code' => 422, 'msg' => '参数验证失败', 'detail' => $errors]);
exit;}// --- 2. 防刷与重复提交检查(轻量级) ---$sessionId = session_id();$clientIp = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';// 同一会话或IP 10秒内只能提交一次(避免暴力点击)$lockKey = 'booking_lock_' . md5($sessionId . $clientIp);if (isset($_SESSION[$lockKey]) && (time() - $_SESSION[$lockKey] < 10)) {
echo json_encode(['code' => 429, 'msg' => '提交过于频繁,请稍后再试']);
exit;}$_SESSION[$lockKey] = time();// --- 3. 事务内执行核心业务 ---try {
$db->beginTransaction();
// 3.1 查询时段配置,获取容量和有效性
$slotStmt = $db->prepare("SELECT slot_id, max_capacity, is_active FROM time_slots WHERE slot_id = ?");
$slotStmt->execute([$slotId]);
$slot = $slotStmt->fetch(PDO::FETCH_ASSOC);
if (!$slot || $slot['is_active'] != 1) {
throw new Exception('所选时段当前不可用');
}
$maxCap = (int)$slot['max_capacity'];
// 3.2 统计该日期+时段已有的有效预约数
$countStmt = $db->prepare("SELECT COUNT(*) AS booked FROM appointments
WHERE appoint_date = ? AND appoint_time_slot = ? AND status = 1");
$countStmt->execute([$date, $slotId]);
$row = $countStmt->fetch(PDO::FETCH_ASSOC);
$bookedCount = (int)$row['booked'];
if ($bookedCount >= $maxCap) {
throw new Exception('该时段预约已满,请选择其他时间');
}
// 3.3 可选:同一手机号当天同时段是否已存在(与唯一索引互补,返回友好提示)
$dupStmt = $db->prepare("SELECT id FROM appointments WHERE appoint_date = ? AND appoint_time_slot = ? AND user_phone = ? AND status = 1");
$dupStmt->execute([$date, $slotId, $phone]);
if ($dupStmt->fetch()) {
throw new Exception('您已在该时段预约,请勿重复提交');
}
// 3.4 插入预约记录
$insertSql = "INSERT INTO appointments
(user_name, user_phone, user_email, appoint_date, appoint_time_slot, remark, ip_address, session_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$insertStmt = $db->prepare($insertSql);
$result = $insertStmt->execute([
$name,
$phone,
$email,
$date,
$slotId,
$remark,
$clientIp,
$sessionId
]);
if (!$result || $insertStmt->rowCount() < 1) {
throw new Exception('数据写入失败,请重试');
}
// 提交事务
$db->commit();
// 返回成功信息(包含预约ID,便于后续查询)
$newId = $db->lastInsertId();
echo json_encode([
'code' => 200,
'msg' => '预约成功!',
'data' => ['appointment_id' => $newId, 'date' => $date, 'slot' => $slotId]
]);} catch (Exception $e) {
$db->rollBack();
// 记录错误日志(建议写入文件,不暴露给前端)
error_log('[Booking Error] ' . $e->getMessage() . ' | IP: ' . $clientIp);
echo json_encode(['code' => 500, 'msg' => $e->getMessage()]);
exit;}
在实际项目中,您需要配套一个前端预约表单页面,通过Ajax将数据提交至上述接口。最简单的HTML+JS片段如下(省略样式):
html
<form id="bookingForm">
<input type="text" name="user_name" placeholder="您的姓名" required>
<input type="tel" name="user_phone" placeholder="手机号" required>
<input type="email" name="user_email" placeholder="邮箱(选填)">
<input type="date" name="appoint_date" min="<?= date('Y-m-d') ?>" max="<?= date('Y-m-d', strtotime('+30 days')) ?>">
<select name="slot_id">
<option value="1">上午 09:00-12:00</option>
<option value="2">下午 14:00-17:00</option>
<option value="3">晚间 19:00-21:00</option>
</select>
<textarea name="remark" placeholder="备注需求"></textarea>
<button type="submit">提交预约</button></form><script>document.getElementById('bookingForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const resp = await fetch('/booking_submit.php', { method: 'POST', body: formData });
const result = await resp.json();
if (result.code === 200) {
alert(result.msg + ' 预约编号:' + result.data.appointment_id);
} else {
alert('错误:' + (result.detail ? result.detail.join('\n') : result.msg));
}
});</script>
同时,您可能还需要提供可预约时段查询接口(例如:根据日期返回剩余名额),避免用户反复提交后才发现已满。该接口只需复用上述统计逻辑,以GET方式返回JSON,此处不再赘述。
多资源并行预约
在预约表中增加 resource_id 字段,并在统计容量时增加 AND resource_id = ? 条件。前端需先选择资源,再选时段。
周期性规则(如每周二、四开放)
可在时段配置表中增加 weekday_mask(如二进制位表示周几),在提交前校验当前日期是否满足规则。
支付或押金关联
在插入记录后,可调用第三方支付接口生成预支付订单,并将订单号存入 payment_txn 字段,状态改为“待支付”,支付成功后再变更为“有效”。
管理后台审核
增加 status 枚举值(待审核、已确认、已取消),并在提交后发送邮件/短信通知管理员。此段代码目前仅支持“有效/取消”两种状态,可按需扩充。
邮件或短信提醒
在事务提交成功后,异步调用消息队列或直接发送(注意性能),提醒用户预约详情。
时区与节假日处理
若面向多时区用户,统一存储UTC时间,并在展示时转换。节假日可维护一张闭馆日期表,在验证时排除。
防止并发超卖
虽然使用了事务和唯一索引,但在极高并发下(如秒杀类预约),建议增加 SELECT ... FOR UPDATE 行锁,或使用Redis原子递减库存。本代码适用于中小流量(每分钟几十次提交)。
日志与监控
在 catch 块中,除了 error_log,可接入更完善的日志库,记录请求参数和堆栈,便于排查。
SQL注入防护:本代码已使用PDO预处理,但仍需确保所有动态字段均通过参数绑定,尤其注意 order by 或 表名 等无法绑定的位置。
XSS过滤:输出到前端的用户数据(如姓名、备注)需进行 htmlspecialchars 转义,本接口返回JSON,前端渲染时需自行防范。
CSRF防护:建议在表单中植入一次性令牌(Token),并在服务端校验。
敏感数据脱敏:日志中不应记录完整手机号或邮箱,可部分掩码。
限制请求频率:除Session锁外,建议在Nginx或防火墙层设置IP限流(例如每分钟10次)。
HTTPS强制:全程使用加密传输,避免中间人截获预约信息。
将代码放置于网站目录,确保 db_connect.php 正确配置数据库账号密码。
使用测试工具(如Postman)模拟POST请求,验证正常提交、重复提交、满额、日期越界等场景。
观察数据库记录,检查字符集是否乱码,时区是否一致。
开启PHP错误日志,但关闭 display_errors,避免暴露路径信息。
若使用缓存或CDN,注意动态接口不应被缓存(添加 Cache-Control: no-cache 响应头)。
建议将本代码纳入版本管理,并在每次修改时记录变更日志。例如,当您调整时段容量或新增字段时,同步更新数据表结构脚本。对于预约状态的更新(如管理员取消),可另写 booking_update.php,仅允许授权身份访问。
上述代码提供了一套可运行、可修改的预约功能雏形,覆盖了从数据验证到事务写入的完整链路。您完全可以根据自己的业务规则,替换验证正则、调整容量逻辑、增加额外字段,或将其重构为面向对象的类结构。关键在于理解每一段代码的职责,并依照实际需求进行裁剪。切记在修改后,对边界条件进行充分测试,确保线上运行的稳定性。希望这段代码能成为您构建在线预约系统的坚实基础,让您将更多精力投入到个性化体验与运营优化之中。