广州榄雕是非物质文化遗产项目中很有代表性的传统技艺,围绕它开发一个“文化展示 + 体验预约”的系统,是目前不少计算机毕业设计和课程设计里比较亮眼的选题。它不是一个简单的信息展示站,而是要把非遗内容、用户注册、线上预约、后台审核、课程管理串成一条完整的业务链路。这篇文章使用 PHP + MySQL 作为后端技术栈,从数据库设计到前台展示、预约提交、后台审核,完整走一遍开发流程。无论你是正在找毕业设计题目的学生,还是想把本地管理系统练熟的开发者,都可以按这篇文章的思路落地一个可运行的项目。
为了让内容更贴近真实开发,我会把“广州榄雕非遗文化展示和体验预约系统”拆成两个核心部分:一部分是面向浏览者的非遗文化展示和预约入口,另一部分是面向管理员的体验课程管理和预约审核后台。前端页面可以用 PHP 直接渲染,也保留改造为 Vue 前后端分离的思路,我把两种方式的衔接点也写清楚。
1. 非遗文化展示与体验预约系统能做什么
1.1 功能需求分析
先不急着写代码,我们先把需求理清楚。一个合格的“非遗文化展示 + 体验预约”系统,至少包含下面几块能力:
第一,文化展示模块。系统要展示广州榄雕的历史背景、代表作品、传承人、工艺流程等信息。比较常见的做法是做一个图文列表页,用户进入首页后能浏览所有非遗项目,点击进入详情页查看完整介绍,同时在详情页展示相关的体验课程。
第二,在线预约模块。用户看到某个体验课程后,可以选择日期、时间段、人数,填写联系方式和备注,提交预约申请。提交后预约状态默认为“待审核”,管理员在后台审核通过或拒绝。
第三,用户管理模块。用户需要注册和登录才能预约。如果没有登录,预约前要跳转到登录页面;登录成功后,用户可以在“我的预约”中查看自己的预约记录和审核状态,也可以取消尚未开始的预约。
第四,后台管理模块。管理员登录后台后,可以维护非遗项目内容、体验课程、预约记录,并且处理预约审核。管理员不需要像普通用户一样注册,直接在数据库或初始化脚本里创建账号即可。
1.2 为什么用 PHP 来实现这类管理系统
很多毕设系统会纠结语言选型,项目标题里也出现了 PHP、Python、Vue 等标签。对于这类“管理系统”和“网页制作”场景,PHP 的优势其实很明显:
- 部署简单,本地用集成环境就可以跑起来,不需要像 Python 那样做额外的虚拟环境配置,也不需要手写 Node 服务。
- PHP 和 MySQL 配合非常成熟,PDO 预处理方式可以很好地防止 SQL 注入,代码门槛较低。
- 动态网页渲染能力强,一个 PHP 文件既能做业务逻辑,又能输出 HTML,适合快速完成功能演示。
- 网上 PHP 管理系统的学习资料非常多,遇到报错容易检索。
如果你的导师或答辩老师更希望看到前后端分离架构,也可以把 PHP 当成后端接口层,只提供 JSON 数据,前端使用 Vue 来渲染。文章最后我会给一个最简单的接口改造示例,方便你横向扩展。
1.3 系统整体流程
整个预约流程可以概括为:
- 游客访问非遗文化展示页面,浏览榄雕项目介绍。
- 用户点击体验课程,进入预约表单。
- 如果未登录,系统引导用户先登录;已登录用户直接填写表单。
- 用户提交预约后,系统校验预约日期、时间段和名额。
- 预约成功生成预约单号,状态为“待审核”。
- 管理员登录后台,查看预约列表,审核通过或拒绝。
- 用户进入“我的预约”,查看审核结果。
这个流程并不复杂,但把用户角色、业务状态和数据库表关系都串起来了,是典型的“管理系统”实战案例。
2. 开发环境准备与项目结构
2.1 本地 PHP 运行环境
为了快速搭建本地开发环境,建议使用 PHPStudy 这类集成环境软件,它内置了 Apache/Nginx、PHP 和 MySQL,可以减少很多环境配置成本。版本方面不需要刻意追求最新,以当前常见环境为例,PHP 使用 7.4 或者 8.0 都可以,MySQL 使用 5.7 或 8.0 均可。如果你在自己电脑上使用的是其他版本,功能上也没有太大区别,重点是确保 PHP 开启了pdo_mysql扩展。
安装好集成环境后,将项目代码放在网站根目录下,例如phpstudy_pro/WWW/lan_diao,然后在浏览器中访问:
http://localhost/lan_diao/public/index.php如果使用的是 Apache 或 Nginx,还需要把虚拟主机或站点根目录指向项目的public目录,这样更方便管理入口文件。
2.2 项目目录规划
为了让代码结构清晰,同时又不至于让新手感到过于复杂,我建议使用下面这套轻量目录结构:
lan_diao/ ├── config/ │ └── config.php # 数据库等全局配置 ├── public/ │ ├── index.php # 前台入口 │ ├── css/ │ └── js/ ├── app/ │ ├── controllers/ │ │ ├── HeritageController.php # 非遗展示 │ │ ├── AppointmentController.php # 预约 │ │ ├── UserController.php # 用户登录注册 │ │ └── AdminController.php # 后台管理 │ ├── models/ │ │ ├── HeritageModel.php │ │ ├── AppointmentModel.php │ │ └── UserModel.php │ └── views/ │ ├── heritage/ │ ├── appointment/ │ ├── user/ │ └── admin/ ├── uploads/ │ └── images/ # 非遗图片上传目录 └── db.php # PDO 连接封装如果你觉得 M V C 分层概念还比较陌生,也可以先使用扁平目录:把所有 PHP 页面放在一个目录下,例如index.php、heritage_list.php、appointment_add.php等。我个人更推荐上面这个结构,因为后期扩展功能、拆分为 Vue 接口时会更顺手。
2.3 先准备好数据库
在开发前,先创建数据库。使用数据库管理工具 phpMyAdmin 或命令行都可以。下面给出命令行创建方式:
CREATE DATABASE lan_diao DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;数据库名我设置为lan_diao。字符集使用utf8mb4,可以正常存储生僻字和特殊符号,避免非遗介绍中出现生僻字乱码。
3. 数据库设计:核心表结构与建表 SQL
数据库是这类管理系统的核心。如果表结构设计不合理,后面做预约冲突校验时会非常被动。这里我设计了 5 张表:管理员表、用户表、非遗项目表、体验课程表、预约记录表。
3.1 表结构总览
| 表名 | 说明 | 关键字段 |
|---|---|---|
| admin_user | 后台管理员 | username, password, status |
| user | 前台注册用户 | username, password, phone, email |
| heritage | 非遗项目与文化展示内容 | title, category, cover_image, content |
| experience | 体验课程/活动 | title, max_people, price, location |
| appointment | 预约记录 | order_no, user_id, experience_id, appointment_date, time_slot, status |
这里有一个设计细节:非遗文化展示内容(heritage)和体验课程(experience)是分开的,因为一条非遗文化可以对应多个体验课程,比如“广州榄雕历史文化”可以配一个参观讲解课,也可以配一个动手雕刻体验课。分开建表更灵活。
3.2 建表 SQL
USE lan_diao; -- 管理员表 CREATE TABLE admin_user ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE COMMENT '管理员账号', password VARCHAR(255) NOT NULL COMMENT 'password_hash 加密结果', real_name VARCHAR(50) NOT NULL DEFAULT '' COMMENT '管理员姓名', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0禁用', last_login_time DATETIME DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员表'; -- 前台用户表 CREATE TABLE user ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', password VARCHAR(255) NOT NULL COMMENT 'password_hash 加密结果', phone VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号', email VARCHAR(100) NOT NULL DEFAULT '' COMMENT '邮箱', avatar VARCHAR(255) NOT NULL DEFAULT '' COMMENT '头像地址', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='前台用户表'; -- 非遗项目表 CREATE TABLE heritage ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL COMMENT '非遗项目标题', category VARCHAR(50) NOT NULL DEFAULT '' COMMENT '分类,如 榄雕历史/传承人/工艺流程', cover_image VARCHAR(255) NOT NULL DEFAULT '' COMMENT '封面图片', intro VARCHAR(500) NOT NULL DEFAULT '' COMMENT '一句话简介', content LONGTEXT COMMENT '详细介绍', view_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '浏览次数', status TINYINT NOT NULL DEFAULT 1 COMMENT '1发布 0下架', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='非遗项目表'; -- 体验课程表 CREATE TABLE experience ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, heritage_id INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联非遗项目ID', title VARCHAR(100) NOT NULL COMMENT '体验课程名称', cover_image VARCHAR(255) NOT NULL DEFAULT '', intro VARCHAR(500) NOT NULL DEFAULT '', location VARCHAR(255) NOT NULL DEFAULT '' COMMENT '体验地点', duration INT UNSIGNED NOT NULL DEFAULT 90 COMMENT '课程时长(分钟)', max_people INT UNSIGNED NOT NULL DEFAULT 10 COMMENT '每场最大预约人数', price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '体验价格,0表示公益免费', content LONGTEXT COMMENT '课程详细介绍', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体验课程表'; -- 预约记录表 CREATE TABLE appointment ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '预约单号', user_id INT UNSIGNED NOT NULL COMMENT '用户ID', experience_id INT UNSIGNED NOT NULL COMMENT '体验课程ID', appointment_date DATE NOT NULL COMMENT '预约日期', time_slot VARCHAR(20) NOT NULL COMMENT '时间段,如 09:00-11:00', people_count INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '预约人数', contact_name VARCHAR(50) NOT NULL COMMENT '联系人', contact_phone VARCHAR(20) NOT NULL COMMENT '联系电话', remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1已通过 2已拒绝 3已完成 4已取消', reject_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '拒绝原因', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_exp_date (experience_id, appointment_date, time_slot), KEY idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约记录表';3.3 索引与引擎说明
预约表里的组合索引idx_exp_date非常重要。实际查询预约记录时,最常用的条件就是“某个体验课程在某个日期某个时间段内已经预约了多少人”,这个组合索引可以直接命中,查询速度会明显提升。
另外,所有表都要使用 InnoDB 引擎。原因很直接:InnoDB 支持事务和行级锁,在预约提交场景中,我们要避免两个用户同时抢同一个时间段导致预约人数超员。如果使用 MyISAM,表锁会导致并发能力差,事务控制也不好实现。
4. 公共层开发:数据库连接与公共函数
4.1 数据库连接封装
在项目根目录创建一个db.php文件,统一封装 PDO 连接。这样做的好处是,每个页面如果需要数据库操作,直接调用db()函数即可,不需要重复写连接代码。
<?php // db.php function db(): PDO { static $pdo = null; if ($pdo === null) { $config = require __DIR__ . '/config/config.php'; $dsn = sprintf( 'mysql:host=%s;port=%s;dbname=%s;charset=%s', $config['host'], $config['port'], $config['dbname'], $config['charset'] ); $pdo = new PDO($dsn, $config['username'], $config['password'], [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); } return $pdo; }config/config.php文件内容:
<?php // config/config.php return [ 'host' => '127.0.0.1', 'port' => 3306, 'dbname' => 'lan_diao', 'username' => 'root', 'password' => 'root', 'charset' => 'utf8mb4', ];这里有一个容易被忽略的点:PDO 构造方法的参数列表中,charset要写在 DSN 里,不要在连接成功后单独执行SET NAMES,这样更规范。同时开启ERRMODE_EXCEPTION,这样 SQL 出错时能直接抛出异常,方便调试。
4.2 公共函数
建立一个helpers.php,放入一些通用的函数。比如获取当前登录用户、判断用户是否登录、格式化日期等。后面多个控制器和页面都会用到。
<?php // helpers.php function is_logged_in(): bool { return !empty($_SESSION['user_id']); } function current_user(): ?array { if (empty($_SESSION['user_id'])) { return null; } $stmt = db()->prepare('SELECT id, username, phone, email, avatar FROM user WHERE id = ?'); $stmt->execute([$_SESSION['user_id']]); return $stmt->fetch() ?: null; } function require_login(): void { if (!is_logged_in()) { header('Location: /login.php?redirect=' . urlencode($_SERVER['REQUEST_URI'])); exit; } } function e(?string $value): string { return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8'); }其中e()函数是输出前转义函数。所有从数据库中读取并输出到页面的内容,都应该经过e()处理,目的是防止 XSS 攻击。这是一个非常值得养成习惯的安全操作。
4.3 用户登录注册基础逻辑
用户登录注册逻辑比较标准,这里只展示登录验证片段:
<?php // login.php 核心逻辑 session_start(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; $stmt = db()->prepare('SELECT * FROM user WHERE username = ? AND status = 1 LIMIT 1'); $stmt->execute([$username]); $user = $stmt->fetch(); if ($user && password_verify($password, $user['password'])) { $_SESSION['user_id'] = $user['id']; header('Location: /index.php'); exit; } $error = '用户名或密码错误'; }注册时,密码一定不要明文存储,要使用password_hash()生成哈希值:
$hashed = password_hash($password, PASSWORD_DEFAULT); $stmt = db()->prepare('INSERT INTO user (username, password, phone) VALUES (?, ?, ?)'); $stmt->execute([$username, $hashed, $phone]);后台管理员登录逻辑与用户登录一致,只是判断的字段改成了管理员表,并把admin_id写入 session。
5. 前台非遗文化展示模块
5.1 非遗项目列表页
首页的核心是把非遗项目列出来。使用一个很简单的查询,注意只查询status = 1的已发布内容,下架内容不在前台出现。
<?php // index.php require __DIR__ . '/db.php'; require __DIR__ . '/helpers.php'; $stmt = db()->query( 'SELECT id, title, category, cover_image, intro, view_count FROM heritage WHERE status = 1 ORDER BY sort_order ASC, id DESC' ); $heritageList = $stmt->fetchAll(); ?> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>广州榄雕非遗文化展示与体验预约</title> </head> <body> <div class="container"> <h1>广州榄雕非遗文化展示</h1> <?php if (empty($heritageList)): ?> <p>暂无非遗内容,请在后台添加。</p> <?php else: ?> <div class="heritage-grid"> <?php foreach ($heritageList as $item): ?> <div class="heritage-card"> <?php if ($item['cover_image']): ?> <img src="<?= e($item['cover_image']) ?>" alt="<?= e($item['title']) ?>"> <?php endif; ?> <h2><?= e($item['title']) ?></h2> <p class="category"><?= e($item['category']) ?></p> <p class="intro"><?= e($item['intro']) ?></p> <a href="heritage_detail.php?id=<?= (int)$item['id'] ?>">查看详情</a> </div> <?php endforeach; ?> </div> <?php endif; ?> </div> </body> </html>这段代码包含了一个很基础的模板循环。列表页不需要复杂的分页,因为非遗项目通常数量有限,但如果后续内容多了,建议加上 LIMIT 分页。
5.2 非遗详情页
详情页接收id参数,查询对应项目,并且把浏览量加一。浏览量加一的处理方式需要注意:不要先查询一次再更新一次,直接使用 SQL 的view_count = view_count + 1原子更新,避免并发时计数不准确。
<?php // heritage_detail.php require __DIR__ . '/db.php'; require __DIR__ . '/helpers.php'; $id = (int)($_GET['id'] ?? 0); $stmt = db()->prepare('SELECT * FROM heritage WHERE id = ? AND status = 1 LIMIT 1'); $stmt->execute([$id]); $heritage = $stmt->fetch(); if (!$heritage) { http_response_code(404); exit('非遗项目不存在或已下架'); } db()->prepare('UPDATE heritage SET view_count = view_count + 1 WHERE id = ?')->execute([$id]); ?> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title><?= e($heritage['title']) ?></title> </head> <body> <article> <h1><?= e($heritage['title']) ?></h1> <p>浏览 <?= (int)$heritage['view_count'] ?> 次</p> <div><?= $heritage['content'] ?></div> </article> </body> </html>这里有一个需要特别说明的地方:content字段如果保存的是富文本内容,那么输出时不应该经过htmlspecialchars,否则 HTML 标签会被全部转义。实际开发中,要么在富文本入库前做好白名单过滤,要么只允许管理员在后台录入内容,降低 XSS 风险。如果是普通文本,则应该继续使用e()。
5.3 相关体验课程展示
非遗详情页除了展示文化介绍,还应该把关联的体验课程列出来,用户看完文化内容后可以直接预约。体验课程表里有heritage_id字段,查询时按该字段关联即可。
$stmt = db()->prepare( 'SELECT id, title, intro, max_people, price, duration, location FROM experience WHERE heritage_id = ? AND status = 1 ORDER BY id DESC' ); $stmt->execute([$heritage['id']]); $experienceList = $stmt->fetchAll();每个体验课程卡片上都放一个“立即预约”按钮,跳转到appointment_add.php?experience_id=课程ID。
6. 体验预约模块开发
体验预约是这套系统的核心业务,也是最容易在答辩或测试时暴露问题的地方。下面我从预约表单、提交逻辑、防超员、我的预约四个方面来讲。
6.1 预约流程设计
预约时需要确定几个关键信息:
- 体验课程:从哪个课程入口进入,就带着哪个课程的 id。
- 预约日期:用户选择未来某一天。
- 时间段:为了简化管理,建议预设固定时间段,例如
09:00-11:00、14:00-16:00、16:30-18:30,不要让用户任意输入。 - 预约人数:用户本次预约几个人参加,默认 1。
- 联系人、联系电话、备注:方便管理员联系和确认。
时间段为什么要求固定选项?因为如果不固定,后台审核时很难判断场次冲突,也难统计每个时段的名额。固定时间段后,名额校验只需要对experience_id + appointment_date + time_slot这三个字段做判断。
6.2 预约表单与提交逻辑
在appointment_add.php中,先根据experience_id查询课程信息,如果课程不存在或下架,直接提示错误。用户点击提交后,进行基础校验和业务校验。
<?php // appointment_add.php 核心逻辑 session_start(); require __DIR__ . '/db.php'; require __DIR__ . '/helpers.php'; require_login(); $experienceId = (int)($_GET['experience_id'] ?? $_POST['experience_id'] ?? 0); $stmt = db()->prepare('SELECT * FROM experience WHERE id = ? AND status = 1 LIMIT 1'); $stmt->execute([$experienceId]); $experience = $stmt->fetch(); if (!$experience) { exit('体验课程不存在或已下架'); } $allowSlots = ['09:00-11:00', '14:00-16:00', '16:30-18:30']; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $date = trim($_POST['appointment_date'] ?? ''); $timeSlot = trim($_POST['time_slot'] ?? ''); $peopleCount = (int)($_POST['people_count'] ?? 1); $contactName = trim($_POST['contact_name'] ?? ''); $contactPhone = trim($_POST['contact_phone'] ?? ''); $remark = trim($_POST['remark'] ?? ''); $errors = []; if (strtotime($date) === false || strtotime($date) < strtotime(date('Y-m-d'))) { $errors[] = '请选择有效的预约日期'; } if (!in_array($timeSlot, $allowSlots, true)) { $errors[] = '请选择合法的时间段'; } if ($peopleCount < 1 || $peopleCount > 10) { $errors[] = '预约人数必须在1到10之间'; } if ($contactName === '') { $errors[] = '请填写联系人'; } if (!preg_match('/^1[3-9]\d{9}$/', $contactPhone)) { $errors[] = '请填写正确的手机号'; } if (empty($errors)) { // 执行事务校验与插入 } else { foreach ($errors as $err) { echo '<p style="color:red">' . e($err) . '</p>'; } } } ?>这里有两个细节值得关注:第一,时间段的合法性一定要做白名单校验,避免用户通过构造请求绕过前端限制,提交任意内容;第二,手机号校验要用正则,保证后台联系时信息准确。
6.3 事务处理:防止超员预约
这是预约系统最重要的技术点。如果只做普通 INSERT,两个用户同时提交同一个课程、同一个日期、同一个时间段,就有可能出现预约人数超出max_people的情况。解决思路是使用事务 +SELECT ... FOR UPDATE行锁。
先查体验课程并锁定该行,再统计当前已预约人数,判断是否还有名额,最后插入预约记录并提交。
try { $pdo = db(); $pdo->beginTransaction(); // 锁定体验课程记录,防止并发修改 $stmt = $pdo->prepare('SELECT * FROM experience WHERE id = ? AND status = 1 LIMIT 1 FOR UPDATE'); $stmt->execute([$experienceId]); $experience = $stmt->fetch(); if (!$experience) { throw new RuntimeException('体验课程不存在或已下架'); } // 统计该时段已预约人数,待审核和已通过都占用名额 $stmt = $pdo->prepare( 'SELECT COALESCE(SUM(people_count), 0) AS total FROM appointment WHERE experience_id = ? AND appointment_date = ? AND time_slot = ? AND status IN (0, 1)' ); $stmt->execute([$experienceId, $date, $timeSlot]); $booked = (int) $stmt->fetch()['total']; if ($booked + $peopleCount > $experience['max_people']) { throw new RuntimeException('该时间段剩余名额不足,请选择其他时段'); } // 生成预约单号 $orderNo = date('YmdHis') . mt_rand(1000, 9999); $stmt = $pdo->prepare( 'INSERT INTO appointment (order_no, user_id, experience_id, appointment_date, time_slot, people_count, contact_name, contact_phone, remark, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)' ); $stmt->execute([ $orderNo, $_SESSION['user_id'], $experienceId, $date, $timeSlot, $peopleCount, $contactName, $contactPhone, $remark ]); $pdo->commit(); header('Location: appointment_my.php'); exit; } catch (Throwable $e) { $pdo->rollBack(); $error = $e->getMessage(); }这里有几个点要解释清楚:
第一,FOR UPDATE是 InnoDB 的行级排他锁。事务提交或回滚后锁自动释放。它保证同一个体验课程在同一时刻只有一个请求能执行到后续的名额判断和插入。
第二,统计已预约人数时,状态取了IN (0, 1),也就是“待审核”和“已通过”都会占用名额。如果某个预约被拒绝,或用户主动取消,就释放名额。
第三,order_no作为预约单号,在表结构里已经加了唯一约束,这样即使并发情况下生成订单号重复,数据库也会拦截,避免脏数据。
6.4 我的预约与取消操作
用户提交预约后,需要有一个页面能看到自己的预约列表。查询时根据user_id过滤,并且关联体验课程名称。
<?php // appointment_my.php 核心逻辑 session_start(); require __DIR__ . '/db.php'; require __DIR__ . '/helpers.php'; require_login(); $user = current_user(); $stmt = db()->prepare( 'SELECT a.*, e.title AS experience_title, e.location FROM appointment a LEFT JOIN experience e ON a.experience_id = e.id WHERE a.user_id = ? ORDER BY a.create_time DESC' ); $stmt->execute([$user['id']]); $appointments = $stmt->fetchAll();展示时,状态字段可以用一个数组映射成中文:
$statusMap = [ 0 => '待审核', 1 => '已通过', 2 => '已拒绝', 3 => '已完成', 4 => '已取消', ];取消预约的操作相对简单,用户只能取消“待审核”或“已通过”状态的预约,而且应该在预约开始前取消。取消时修改状态为4即可:
$stmt = db()->prepare( 'UPDATE appointment SET status = 4 WHERE id = ? AND user_id = ? AND status IN (0, 1)' ); $stmt->execute([$appointmentId, $user['id']]);注意,SQL 的 WHERE 条件里必须同时带上user_id,防止用户通过修改请求参数操作其他人的预约。这就是最基础的数据权限控制。
7. 后台管理模块开发
7.1 管理员登录
后台的入口建议单独放在admin/目录下,与前台页面隔离。登录逻辑与前台用户登录类似,但 session 键名使用admin_id,并且进入后台每个页面时都要校验。
<?php // admin/index.php 进入后台前的统一校验 session_start(); if (empty($_SESSION['admin_id'])) { header('Location: admin_login.php'); exit; }登录成功时,可以顺手更新管理员最后登录时间:
$stmt = db()->prepare('UPDATE admin_user SET last_login_time = NOW() WHERE id = ?'); $stmt->execute([$admin['id']]);7.2 非遗与体验课程管理
这部分本质是 CRUD,不需要过度设计。以非遗项目新增为例,需要考虑图片上传。图片上传是管理系统常见的扩展点,这里给出一个简化的文件上传处理:
// admin/heritage_save.php 图片上传部分 $targetDir = __DIR__ . '/../../uploads/images/'; if (!is_dir($targetDir)) { mkdir($targetDir, 0775, true); } $fileName = date('YmdHis') . '_' . mt_rand(1000, 9999) . '.' . strtolower(pathinfo($_FILES['cover_image']['name'], PATHINFO_EXTENSION)); $targetFile = $targetDir . $fileName; // 仅允许常见图片格式 $allowTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp']; $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); if (!in_array($ext, $allowTypes, true)) { exit('不支持的图片格式'); } if (move_uploaded_file($_FILES['cover_image']['tmp_name'], $targetFile)) { $coverImage = '/uploads/images/' . $fileName; } else { $coverImage = ''; }保存到数据库时,cover_image字段存放的是浏览器可访问的 URL 路径,而不是服务器绝对路径。这一点很重要,否则前端页面<img>标签无法正常显示图片。
非遗项目和体验课程的管理页面结构相似:列表页显示所有记录,编辑页更新内容,删除操作需要二次确认,避免误删。对于管理后台,建议使用简单清晰的表格布局,再配合一个后台管理模板,例如 Layui 或 AdminLTE,能让界面专业很多。
7.3 预约审核
后台预约列表需要连表查询,把体验课程和用户信息带出来:
<?php // admin/appointment_manage.php 核心逻辑 $stmt = db()->query( 'SELECT a.*, e.title AS experience_title, u.username FROM appointment a LEFT JOIN experience e ON a.experience_id = e.id LEFT JOIN user u ON a.user_id = u.id ORDER BY a.create_time DESC' ); $appointments = $stmt->fetchAll();审核时,管理员点击“通过”或“拒绝”,提交到appointment_review.php:
$id = (int)($_POST['id'] ?? 0); $status = (int)($_POST['status'] ?? 0); $rejectReason = trim($_POST['reject_reason'] ?? ''); if ($status === 1) { $rejectReason = ''; } $stmt = db()->prepare('UPDATE appointment SET status = ?, reject_reason = ? WHERE id = ?'); $stmt->execute([$status, $rejectReason, $id]);这里建议在状态变更时增加一次校验:如果操作的是“通过”,可以重新检查该时段剩余名额,防止管理员在用户取消后又超额通过。毕竟审核也是异步操作,直接用事务提交会更稳妥。
8. 常见问题与排查思路
PHP 管理系统开发中,报错和异常情况非常多。我整理了几个高频问题,你可以直接对照排查。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 连接数据库报 1045 Access denied | 数据库账号或密码错误 | 检查 config 配置,使用 phpMyAdmin 验证账号 |
| 连接数据库报 Connection refused | MySQL 服务未启动,或端口不是 3306 | 启动 MySQL,核对端口配置 |
| 页面中文显示乱码 | 页面、数据库、连接字符集不一致 | 统一使用 utf8mb4,并在 HTML head 设置 charset |
| 图片上传后访问 404 | 上传文件保存路径与访问 URL 不一致 | 确认uploads根目录映射关系 |
| 预约提示“名额不足”但明明有人取消 | 取消预约状态未更新,或统计状态范围不对 | 检查 status 状态范围,确认已取消不占名额 |
| 后台登录后刷新又跳到登录页 | session 未开启或 session 保存异常 | 检查每个后台文件是否执行 session_start() |
| 当前页面报未定义函数 db() | 未引入 db.php 或文件路径不对 | 使用 require_once 引入公共层文件 |
| 富文本内容显示为 HTML 源码 | 输出前使用了 htmlspecialchars | 富文本直接输出,普通文本转义输出 |
8.1 环境与连接类问题
本地开发最常见的是数据库连接失败,原因通常集中在三处:MySQL 没有启动、账号密码不对、配置中 host 写错。我建议在db()函数里开启ERRMODE_EXCEPTION,这样报错信息里会直接显示连接失败原因,不用猜测。
如果使用 PHP 8 环境,部分老式mysql_*函数已经移除,应统一使用 PDO 或 mysqli。本文所有示例都使用 PDO,兼容 PHP 7 和 PHP 8。
8.2 预约功能典型问题
预约功能的“超员”问题很难通过页面测试暴露,通常需要并发模拟。这里推荐一个简单的自查方式:连续打开两个浏览器窗口,用不同账号同时提交同一个时间段,观察是否会出现超过max_people的记录。如果出现,说明事务FOR UPDATE没写对,或者代码在beginTransaction之前先执行了其他查询。
另一个常见问题是状态混乱。如果预约状态机不统一,比如把“待审核”设成 1,那统计名额时就要对应修改IN条件。建议把状态常量提前写好,不要在使用时随手写数字。
8.3 文件上传与编码问题
图片上传失败大多是目录权限问题。Linux 环境下,uploads/images目录需要有写权限,可以用:
chmod -R 775 uploadsWindows 下一般不会遇到这个问题,但如果使用 Nginx,需要确保client_max_body_size配置足够大,否则上传稍大的图片会直接报 413 错误。
9. 工程化与安全建议
很多毕设项目能跑通,但经不起推敲,主要是因为忽略了安全和工程化细节。下面这些建议不需要增加太多工作量,却能在答辩和后续维护中明显加分。
9.1 安全底线:预处理、哈希、权限
第一,所有 SQL 语句都要使用 PDO 预处理,严禁直接拼接用户输入。尤其是后台管理页面,更要警惕通过查询参数构造 SQL 注入。
第二,所有密码必须使用password_hash()加密存储,登录时用password_verify()校验。控制台中不要出现明文密码。
第三,后台管理页面必须做独立登录校验。建议把后台相关页面统一放到一个目录下,在入口文件中统一校验admin_id,避免遗漏。
第四,用户操作自己的数据时,必须携带user_id作为 WHERE 条件。预约取消、修改个人信息等接口都要这样做,防止水平越权。
9.2 状态与数据一致性
预约状态建议使用状态机思想来管理:
- 0:待审核
- 1:已通过
- 2:已拒绝
- 3:已完成
- 4:已取消
不是所有状态之间都能随意跳转。例如,已拒绝的预约不能又改成已完成,已取消的预约也不能再审核通过。如果你希望在代码中体现这一层约束,可以在更新前先查询当前状态,再判断是否允许目标状态:
$allowTransition = [ 0 => [1, 2, 4], 1 => [3, 4], 2 => [], 3 => [], 4 => [], ]; if (!in_array($newStatus, $allowTransition[$currentStatus] ?? [], true)) { throw new RuntimeException('非法的状态变更'); }9.3 再次谈谈 Vue 前端改造
如果你希望把系统改造成 Vue + PHP 接口的前后端分离架构,其实不需要重写业务逻辑。只需要让 PHP 文件只输出 JSON,不再输出 HTML,然后在页面中通过 Vue 的axios或fetch去请求这些接口即可。
一个最简单的 JSON 接口示例:
<?php // api/heritage_list.php require __DIR__ . '/../db.php'; header('Content-Type: application/json; charset=utf-8'); $stmt = db()->query('SELECT id, title, category, cover_image, intro FROM heritage WHERE status = 1'); echo json_encode(['code' => 0, 'data' => $stmt->fetchAll()]);前端 Vue 部分可以是这样的思路:
// Vue 3 示例 const app = Vue.createApp({ data() { return { heritageList: [] }; }, mounted() { fetch('/api/heritage_list.php') .then(res => res.json()) .then(res => { if (res.code === 0) { this.heritageList = res.data; } }); } }); app.mount('#app');在正式比赛或毕设答辩中,能说清楚“为什么用 PHP 做后端、为什么用 Vue 做渲染”是一个加分项。需要特别注意的是,前后端分离后,登录态可以继续使用 session,也可以通过接口返回 token。如果只是本地毕设演示,session 方案足够简单稳定,不需要引入 JWT 等复杂机制。
9.4 部署与备份
本地开发完成后,如果要部署到服务器,建议使用宝塔面板或直接使用 Nginx + PHP-FPM。部署前重点检查三件事:
- 数据库连接配置是否修改为服务器参数。
uploads目录权限是否正确。- 关闭 PHP 错误展示,将错误日志写入文件。
数据库备份是最容易被忽略的工作。开发期间可以每天用命令行导出一次:
mysqldump -u root -p lan_diao > lan_diao_backup.sql恢复时使用:
mysql -u root -p lan_diao < lan_diao_backup.sql涉及线上数据结构的修改,一定先备份,再在测试环境验证 SQL 语句,最后在生产环境执行。
10. 总结与下一步学习建议
通过这篇文章,我们完整搭建了一个基于 PHP + MySQL 的广州榄雕非遗文化展示和体验预约系统。在功能上,它覆盖了