做过政务类预约系统的朋友应该都有体会:这类项目看着不大,但“坑”一点不比大型电商少。今天分享的是档案馆参观预约系统的完整落地方案,技术栈是微信小程序客户端 +uniapp跨端框架,后端主服务用PHP,辅助服务用Node.js,管理后台用Vue。整个项目从数据库建模到部署上线,前后折腾了大约三周,我把设计思路、关键代码、踩过的坑都整理出来了。
这套系统解决的核心问题是:档案馆作为人流密集的公共服务场馆,既要满足公众参观需求,又要控制瞬时在馆人数、实现实名可追溯。纯现场排队的方式在节假日基本失控,人工登记身份证件速度慢、容易出错,也无法提前掌握参观人数。预约系统把“现场排队”变成“线上分时预约”,用户在小程序里选日期、选时段、填实名信息,后台自动校验冲突、控制容量、生成核销二维码,管理员在Vue后台审核、核销、看数据报表。无论你是刚接触uniapp的初学者,还是已经在做预约类项目的开发者,这套设计和代码都有直接可抄的部分。
1. 项目整体拆解与选型逻辑
1.1 为什么是 PHP + Node.js 混合后端
不少人一看“PHP + Node.js”就觉得奇怪,一个项目干嘛用两种后端语言?这不是炫技,背后有很现实的原因。
档案馆这类系统的部署环境通常比较特殊,很多单位有内网服务器,运维习惯偏向传统LAMP/LNMP,PHP几乎是“默认选项”,部署简单、出问题好排查。但预约系统有一个绕不开的场景:高峰期的并发写入。比如9点放第2天号源,同一秒可能有几百人同时预约,这时候数据库写入的冲突处理、Redis队列的消费、延迟任务(未核销自动取消)都需要异步能力。PHP处理同步请求很快,但做常驻内存的消费者进程比较别扭。于是我把异步、定时、推送相关的部分交给Node.js,PHP专注业务API,两边通过Redis队列通信——这也是目前中小型团队非常实用的组合。
具体分工是这样的:
- PHP 8.2 + ThinkPHP 8:用户注册登录、预约下单、取消预约、管理员审核、数据统计这些主业务API。
- Node.js 20 + BullMQ(Redis队列):处理预约成功后的短信通知、核销码邮件发送、预约日当天8点批量推送提醒、过期未核销自动释放名额。
- Vue 3 + Element Plus:给管理员用的PC后台,预约审核、核销查询、黑名单管理、数据看板。
- uniapp(Vue3语法) + uview-plus:微信小程序端,用户端的全部界面。
选uniapp而不是原生微信小程序,核心原因是开发和维护成本。后期如果档案馆想上支付宝小程序或者抖音小程序,原生写法基本要重写,uniapp换平台编译就行。另外uniapp的uni.request、uni.login已经封装了跨端差异,配合uview-plus组件库,表单页、列表页开发速度比原生快一倍以上。
1.2 核心需求解析
做预约系统前,需求必须拆到“字段级”。档案馆预约和景区预约最大的区别在于实名制要求更严格:进馆需要人证比对,所以必须收集真实姓名、身份证号、手机号,未成年人还要填监护人信息。这意味着业务上必然出现两个独立实体:参观者档案(visitor)和预约记录(appointment)。
从用户端看,核心流程四步走:
- 登录:微信授权获取openid,绑定手机号(手机号需实名验证)。
- 选时段:看未来7天日历,选一个有余量的日期,再选上午/下午时段。
- 填信息:新增/选择参观人,多人可以一次预约最多5人。
- 拿凭证:预约成功后生成二维码,到馆出示核销。
从管理端看,核心诉求三条:审核确认(部分团体预约需要人工审核)、现场核销(扫二维码验证身份)、数据统计(每日入馆量、爽约率、热门时段)。
我画了一张很粗的状态流转图,供你在脑子里建立模型(本文章不放图片,直接文字描述):
预约记录状态:PENDING(待审核)→APPROVED(已通过)→VISITED(已核销入馆);任何一步都可以CANCELED(用户取消)或NO_SHOW(爽约)。后台管理员还可以把恶意占号用户拉进BLACKLIST黑名单。
这套状态机是所有功能开发的地基。前面没定清楚,后面写接口一定会返工。
2. 数据库建模与核心业务设计
2.1 表结构设计详解
我直接用实际的DDL来说话,这套表结构是我在实际项目中调过三轮的版本。数据库用MySQL 8.0,字符集统一utf8mb4,排序规则utf8mb4_general_ci。
参观者表visitor:
CREATE TABLE `visitor` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL COMMENT '微信openid', `name` VARCHAR(32) NOT NULL, `id_card` VARCHAR(255) NOT NULL COMMENT '身份证号AES加密存储', `id_card_hash` CHAR(64) NOT NULL COMMENT '身份证号SHA256,用于查重', `phone` VARCHAR(20) NOT NULL, `visitor_type` TINYINT NOT NULL DEFAULT 0 COMMENT '0个人 1团体', `org_name` VARCHAR(64) DEFAULT NULL COMMENT '团体单位名称', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_openid` (`openid`), UNIQUE KEY `uk_id_card_hash` (`id_card_hash`), KEY `idx_phone` (`phone`) ) ENGINE=InnoDB;重点说明:身份证号属于敏感个人信息,数据库里绝对不能存明文。存两列——加密列用于生产环境读取,hash列用于唯一性检查。加密用AES-256-CBC,密钥放在服务端环境变量里,代码库里不出现。这个方法政务类的项目尤其重要,等保测评的时候是加分项。
预约记录表appointment_record:
CREATE TABLE `appointment_record` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `appointment_no` VARCHAR(32) NOT NULL COMMENT '预约编号,如YG202408150001', `visitor_id` INT UNSIGNED NOT NULL, `schedule_id` INT UNSIGNED NOT NULL COMMENT '场次ID', `app_date` DATE NOT NULL COMMENT '参观日期', `time_slot` TINYINT NOT NULL COMMENT '1上午 2下午', `visitor_count` TINYINT NOT NULL DEFAULT 1 COMMENT '人数', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1已通过 2已核销 3已取消 4爽约', `qr_code` VARCHAR(255) DEFAULT NULL COMMENT '核销二维码内容', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_appointment_no` (`appointment_no`), KEY `idx_app_date_status` (`app_date`, `status`), KEY `idx_visitor` (`visitor_id`) ) ENGINE=InnoDB;场次/容量表appointment_schedule:
CREATE TABLE `appointment_schedule` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `app_date` DATE NOT NULL COMMENT '日期', `time_slot` TINYINT NOT NULL COMMENT '1上午 2下午', `capacity` INT NOT NULL DEFAULT 100 COMMENT '总容量', `booked_count` INT NOT NULL DEFAULT 0 COMMENT '已预约数', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1可约 0停约', UNIQUE KEY `uk_date_slot` (`app_date`, `time_slot`) ) ENGINE=InnoDB;这个表是并发控制的关键,后面预约接口就是靠它的行锁来避免超卖。
管理员表admin_user、黑名单表blacklist、操作日志表operation_log我就一笔带过了,核心字段就是账号、密码hash、角色权限,以及被拉黑用户ID、原因、失效时间,管理员操作留痕。
2.2 时段容量与预约限制的关键点
容量设计方面,档案馆不是每小时滚动放号的,一般是上午场(9:00-12:00)和下午场(14:00-17:00)两个时段。但有些馆有讲解服务,会细分到具体讲解批次。我在表里用time_slot字段区分上下午,如果要支持批次,可以把time_slot改成批次编号,或者加一个batch_id,逻辑是类似的,这里以最简单的上下午为例说明。
防黄牛、防刷号,我做了三层限制:
- 单用户预约上限:同一openid(即同一个微信用户)在同一个app_date只能有一条状态为“待审核/已通过”的预约记录,也就是说你不能再重复约同一天。
- 7天内取消次数限制:取消次数超过3次,暂停预约权限7天。否则有人反复占号取消,会把真实观众挤掉。
- 爽约惩罚:预约成功但未核销,标记为爽约,每月累计2次爽约,下个月禁止预约。这是最常见的痛点,不惩罚的话实际到馆率可能只有60%。
这些限制的实现位置:第一层在PHP业务代码查询判断,第二、三层在预约下单前做条件检查。都是为了保护公共资源的公平性,这个设计在政务类预约需求里基本是标配。
2.3 预约下单的并发与事务处理
这里直接放PHP核心代码。预约接口最怕的就是“两个人同时抢最后一个名额”,导致预约数量超过容量。解决办法是数据库事务加行锁:
public function reserve(Visitor $user, $date, $slot, $visitorCount) { $pdo = $this->getPdo(); $pdo->beginTransaction(); try { // 1. 锁住场次记录,防止并发超卖 $stmt = $pdo->prepare( "SELECT id, capacity, booked_count FROM appointment_schedule WHERE app_date = ? AND time_slot = ? FOR UPDATE" ); $stmt->execute([$date, $slot]); $schedule = $stmt->fetch(PDO::FETCH_ASSOC); if (!$schedule || $schedule['status'] == 0) { throw new \Exception('该时段不可预约'); } if ($schedule['booked_count'] + $visitorCount > $schedule['capacity']) { throw new \Exception('该时段余票不足'); } // 2. 检查当天是否已有预约(防重复) $check = $pdo->prepare( "SELECT id FROM appointment_record WHERE visitor_id = ? AND app_date = ? AND status IN (0, 1) LIMIT 1" ); $check->execute([$user['id'], $date]); if ($check->fetch()) { throw new \Exception('当天已有预约记录'); } // 3. 生成预约编号、二维码内容并插入 $appointmentNo = 'YG' . date('Ymd') . str_pad((string)mt_rand(1, 9999), 4, '0', STR_PAD_LEFT); $qrContent = json_encode([ 'no' => $appointmentNo, 'date' => $date, 'slot' => $slot, 'count' => $visitorCount ]); $insert = $pdo->prepare( "INSERT INTO appointment_record (appointment_no, visitor_id, schedule_id, app_date, time_slot, visitor_count, qr_code, status) VALUES (?, ?, ?, ?, ?, ?, ?, 1)" ); $insert->execute([$appointmentNo, $user['id'], $schedule['id'], $date, $slot, $visitorCount, $qrContent]); // 4. 更新已预约数 $update = $pdo->prepare( "UPDATE appointment_schedule SET booked_count = booked_count + ? WHERE id = ?" ); $update->execute([$visitorCount, $schedule['id']]); $pdo->commit(); return ['appointment_no' => $appointmentNo, 'qr_content' => $qrContent]; } catch (\Exception $e) { $pdo->rollBack(); throw $e; } }注意几个细节:
FOR UPDATE是MySQL的悲观行锁,锁住的是appointment_schedule中对应的那行数据,并发请求只能串行执行,这是保证不超卖最简单可靠的方式。- 预约编号别用数据库自增ID裸奔给用户看,一是可被遍历,二是位数不固定。我用的格式是
YG前缀 + 日期 + 随机4位,够用。如果要更严格可以加redis自增序列。 - 二维码内容包含预约编号、日期、时段、人数,核销的时候通过预约编号查库比对,而不是只认二维码本身,防止有人PS二维码。
这套事务逻辑在压力测试下(100并发抢同一个场次20个名额)没出现超额,已经跑过实测。预约类项目的核心就在这一段,务必吃透。
3. 前后端实现与联调细节
3.1 小程序端:uniapp中的登录与会话管理
uniapp写微信小程序,登录流程是固定的:uni.login拿code → 后端调微信接口换openid → 签发自定义登录态token → 小程序存储token。我封装了一个统一请求库request.js,核心思路是每次请求自动带上token,遇到401就重新静默登录,避免用户看到“登录过期”弹窗。
// request.js 核心封装 const BASE_URL = 'https://api.example.com'; let isRefreshing = false; function request(options) { return new Promise((resolve, reject) => { const token = uni.getStorageSync('token'); uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Authorization': token ? 'Bearer ' + token : '', 'Content-Type': 'application/json' }, success: (res) => { if (res.statusCode === 401) { // token失效,静默重新登录 if (!isRefreshing) { isRefreshing = true; login().then(() => { isRefreshing = false; uni.request({ ... }); // 重发原请求 }); } } else if (res.statusCode === 200) { if (res.data.code === 0) { resolve(res.data.data); } else { uni.showToast({ title: res.data.message, icon: 'none' }); reject(res.data); } } }, fail: (err) => reject(err) }); }); }这个封装在实际开发中非常实用。你在热搜词里看到的“顶部导航栏高度”问题,在小程序里也是必踩的点。因为不同手机状态栏高度不同,自定义导航栏时高度要动态计算:
// 获取状态栏高度,适配刘海屏 const systemInfo = uni.getSystemInfoSync(); this.statusBarHeight = systemInfo.statusBarHeight; this.navBarHeight = systemInfo.statusBarHeight + 44 + 'px';日历和时段选择是小程序端的核心交互。日历我直接用了uview-plus的u-calendar,配置了禁用日期范围——当天之前的日期不可选,超过7天的不可选。时段列表用u-grid展示,每个格子显示“上午 9:00-12:00 余量xx”,余量实时从接口获取。注意余量展示要做缓存,5秒内不重复请求,避免频繁打后端。
3.2 管理后台:Vue3预约审核与核销
后台用Vue3 + Element Plus,功能不长,但有个点我特别想提醒:审核表格不要简简单单做个全表查询分页。预约数据一多,不带条件的全表扫描会直接压垮MySQL。我做了一个组合查询接口:
GET /api/admin/audit/list?page=1&size=20&status=1&app_date=2024-08-15&keyword=张三keyword支持模糊搜索姓名/预约编号/手机号(密文要注意,手机号如果是明文才能模糊,所以在设计上前端对手机号还是明文存储,身份证号才加密,这样业务和合规都能兼顾)。后端SQL用WHERE status = ? AND app_date = ? AND (name LIKE ? OR appointment_no LIKE ?)这种动态拼接方式,配合appointment_record(status, app_date)联合索引,单表百万级数据查询都能稳定在几十毫秒内。
核销功能是档案馆工作人员最关心的。我的方案是:管理员打开核销页,使用扫码枪或手机摄像头扫用户二维码,系统解析二维码内容拿到appointment_no,调用核销接口,核销成功会显示“预约人姓名+人数+入馆时间”,同时后台记录操作员信息。这里有个坑:扫码枪本质上是个模拟键盘的设备,它扫描二维码后会把内容直接“打”到输入框,所以要监听输入框的change事件自动触发查询,而不是让管理员再点一次“核销”按钮。
3.3 PHP侧跨域与CORS的坑
前后端分离必然遇到跨域。PHP处理CORS有个典型的坑是OPTIONS预检请求。很多人的代码只处理了GET/POST,没处理OPTIONS,浏览器直接报跨域错误。在ThinkPHP中间件里加这么一段:
public function handle($request, \Closure $next) { $origin = $request->header('Origin'); if ($origin) { header('Access-Control-Allow-Origin: ' . $origin); header('Access-Control-Allow-Credentials: true'); } header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With'); if ($request->method() == 'OPTIONS') { return response('', 204); } return $next($request); }注意Access-Control-Allow-Origin不能写成*,因为一旦要携带Cookie或Authorization头,浏览器就要求必须指定具体来源。生产环境最好维护一个白名单数组,动态判断Origin,防止任意网站调用你的接口。
前端调试时还会遇到一个问题:HTTP无法在微信小程序真机上请求,必须HTTPS+域名备案。开发阶段我用uniapp的H5模式在浏览器调试,接口走本机局域网IP,等到真机预览再切换正式地址。这个流程一定要提前规划,我见太多人开发到一半才发现域名没配、证书没弄,整个联调瘫痪。
4. 避坑记录与常见问题速查
4.1 环境安装阶段的三个高频问题
先说一个几乎每个新手都会撞上的问题:在Windows下执行npm install或npm run serve报错,提示npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。这是PowerShell执行策略限制,不是npm坏了。解决办法:
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned执行后选Y确认。或者用管理员身份在Windows PowerShell里执行Set-ExecutionPolicy RemoteSigned临时放开。这个问题的原因很简单:Windows的PowerShell出于安全考虑默认禁止运行未签名的ps1脚本,而npm、vue-cli这些命令行工具恰恰是通过.ps1脚本启动的。这个问题在Node.js安装教程里很少写,但实际开发中遇到概率接近100%。
第二个常见问题是PHP版本从7升级到8之后,项目突然一堆报错。主要是PHP 8把很多ext函数的行为改得更严格了,比如each()函数被移除、字符转义规则变化。我的建议是:新项目一律用PHP 8.2+,老项目迁移前先用php -l全量检查语法,再用PHPUnit回归一遍核心业务。
第三个问题是MySQL时区。前后端联调时发现时间差8小时,十有八九是数据库连接时区没设置。PHP的PDO连接串里加上cursorClass和时区配置:
$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=archive;charset=utf8mb4'; /// PDO 连接后立即执行时区设置 $pdo->exec("SET time_zone = '+08:00'");同时PHP侧date_default_timezone_set('Asia/Shanghai'),这样数据库时间、PHP时间、用户展示时间全部一致,不要在代码里再手动加减8小时,那是灾难。
4.2 微信小程序专属的坑
- 域名白名单:小程序后台必须配置 request合法域名,且必须是HTTPS。开发阶段可以在开发者工具里勾选“不校验合法域名”,但真机预览时这个选项无效。
- 二维码生成:别在前端用canvas手动画二维码,坑太多了(网络图片临时路径、canvas层级遮挡)。直接用后端生成好的图片地址返回给前端渲染,配合
uni.downloadFile保存到相册。服务端用phpqrcode类库生成,一行代码搞定。 - 列表加载更多:预约列表用
onReachBottom触底加载,注意用加载状态锁防止重复请求。我写的分页逻辑是前端维护page和hasMore,每次请求后端返回has_next字段,前端根据它决定是否显示“加载更多”提示。 - 手机号快速填入:真实项目里用户最烦手输手机号,最好用
uni.login+ 微信提供的手机号快捷验证组件button open-type="getPhoneNumber",用户点一下授权就能拿到手机号。但注意这个能力只对企业认证的小程序开放。
4.3 线上运行后的三个必查优化
项目上线后别急着不管,我列出三个真正影响体验的优化点:
一是预约余量的实时性。高峰期用户反复刷新余量会导致后端压力,我加了一层Redis缓存,场次余量写入Redis,读接口只走Redis,MySQL的booked_count在异步队列里批量更新。具体做法:预约下单事务完成后,DEL掉对应场次的缓存键;读余量时先查Redis,没有就回源MySQL并写入缓存,设置过期时间30秒。
二是核销记录留痕。现场核销必须记录操作管理员、核销时间、设备IP,这是出了纠纷(比如“我没预约怎么扫不出来”)唯一的追溯手段。我在核销接口里强制要求管理员token,并把操作日志写入operation_log。
三是数据看板的SQL优化。统计“今日入馆量”要实时,“上月爽约率”可以每天凌晨用定时任务算好存进统计表。不要每次打开看板都全表聚合,数据量大了之后页面会卡死。澎湃的档案预约群体会出现一天几千条记录,看似不多,但加上一个月的聚合和关联查询,没索引真扛不住。
4.4 常见问题速查表
| 症状 | 原因 | 解决方案 |
|---|---|---|
| npm脚本无法运行 | PowerShell执行策略 | 见上文,执行Set-ExecutionPolicy |
| 小程序请求全失败 | 域名未配置/未备案/非HTTPS | 小程序后台配置白名单,务必备案 |
| 预约数据超卖 | 并发下booked_count更新丢更新 | 事务内SELECT...FOR UPDATE行锁 |
| 二维码显示一片白 | canvas绘制异步未完成 | 改用后端生成二维码图片返回 |
| 页面时间差8小时 | PHP/MySQL时区不一致 | 统一Asia/Shanghai,PDO连接设置时区 |
| 预约列表下滑卡顿 | 一次性加载全部数据 | 分页+触底加载,最多一次20条 |
| 审核表格查询慢 | 缺联合索引 | 加(app_date,status)联合索引 |
| 核销时手机扫不了码 | 二维码内容格式不标准 | 二维码内容只放预约编号,别放中文字符,太长容易错 |
4.5 部署时的一个顺序建议
最后说一个部署层面的经验,很多人栽在这里:不要把PHP、Node.js、MySQL一股脑装在同一台机器上。档案馆项目虽然并发不高,但PHP和Node.js的常驻进程会产生端口和内存资源的竞争。我当时的部署形态是两台云服务器:
- 服务器A:Nginx + PHP-FPM + MySQL,跑主业务API和数据库。
- 服务器B:Node.js(PM2托管)+ Redis,跑队列消费者和定时任务。
Nginx代理规则里,/api/*转发到PHP,/ws/*和/tasks/*转发到Node.js。Redis作为两台服务器之间的“消息管道”,PHP处理完预约后向队列push一条消息,Node.js的消费者立刻拿到消息去发通知。这个结构的好处是,即使Node.js服务出问题,也只会影响通知和定时任务,用户最核心的预约接口不会挂。
再说点个人体会
项目做完回头看,这套系统的复杂度其实不在代码量,而在“预约”这个动作的边界情况太多了。今天用户迟到怎么办,明天有人爽约要不要释放名额,节假日要不要加场次,某个团体临时取消40人后名额怎么回流——每一个小问题背后都对应着一条状态分支。我的建议是,动手写第一行代码之前,花两天时间和甲方一起把这些问题全部敲定成文字规则,哪怕只是写在Excel里。后面90%的返工都是因为需求边界没定清,而不是技术实现不了。与其抢时间码代码,不如前期多问几个“如果”。
如果你想在这个基础上扩展,比较实用的方向有:对接省政务服务App的预约入口、增加团体预约的附件材料(单位介绍信、安全承诺书)、把爽约率高的用户加入灰名单后智能释放名额。这些功能都不难,但都很能体现预约系统的管理水平。希望这篇实操总结能帮你少踩几个坑,哪怕只省下半天调试时间,也算值了。