图书馆座位预约这套东西我做了不止一遍,这次交付的项目编号是weixin094,微信小程序端的图书馆自习室座位预约管理系统。最初接到需求的时候,甲方描述很简单,“就想解决占座、抢座的问题”,但真正动手你就会发现,这个“小”项目牵扯到的东西一点也不简单,甚至可以说,它是微信小程序一个非常典型且完整的业务闭环样例。
这个项目解决的是实实在在的痛点:一到考研季、期末周,图书馆座位就是稀缺资源,有人拿书占座一占一整天,有人来了发现座位被人堆满杂物,管理员在几个楼层之间跑来跑去也管不过来。这套系统通过小程序端实现选座预约,配套后端服务和数据库设计,把“预约—签到—释放-违约”这套规则固化下来,让占座变成看得见、管得住的数据。适合以下几类人参考:刚学完小程序基础、想找一个完整实战项目练手的学生;毕业后准备做管理系统类毕设的开发新手;以及在高校或企业图书馆想低成本落地一套预约方案的信息化人员。
我这次就以这个项目为例,把整体设计、技术选型、核心逻辑、实操过程和踩坑经验全部拆开写一遍,代码层面尽量讲透。
1. 项目到底在解决什么问题:从占座乱象到预约闭环
1.1 真实场景里的混乱与隐性成本
不要小看图书馆座位预约这个需求。表面上是“占座”问题,深层次是资源分配和公平性的问题。在我接手这个项目前,他们尝试过用问卷统计、用飞书表格登记,效果都很差:问卷无法实时反映座位状态,而飞书表格在高峰期会被几十个人同时编辑,数据锁来锁去基本没法用。
痛点可以归纳成三条:
- 座位状态不透明:读者不知道哪个座位空着,管理员也不知道哪个座位长时间无人。
- 占座零成本:放一本书就代表座位有主了,但主人可能下午三点才来,座位就被白白浪费一上午。
- 管理动作滞后:管理员清理占座物品容易引发矛盾,没有数据依据,全凭肉眼判断。
预约管理系统把这些隐性成本用一个状态机解决:座位从“空闲”到“已预约”再到“签到使用”,每个状态都有时间约束和自动释放规则。读者看到的是实时座位图,管理员看到的是每一个座位的流转记录。
1.2 用户角色与功能版图拆解
这个项目分用户端和管理端两个视角,功能边界要划清楚,否则页面会越做越乱。
用户端核心功能:
- 查看图书馆楼层与座位分布图,实时浏览座位状态。
- 按日期、时间段筛选座位并发起预约。
- 签到与取消预约,查看自己的预约记录与违约记录。
- 接收预约成功、签到提醒、座位释放等订阅消息通知。
管理端核心功能:
- 座位基础数据维护,比如批量生成座位号、调整楼层布局、停用维修座位。
- 预约记录查看与统计,支持按日期、按楼层维度筛选。
- 违约规则配置,如允许迟到时长、违约次数上限、超时自动释放策略。
- 座位利用率报表,用于后勤或运营做空间优化参考。
为什么选择微信小程序而不是独立App?原因很现实:微信小程序无需下载、扫码即用,图书馆场景本身就在微信生态内,用户不需要额外注册账号,通过微信授权就能完成身份绑定。再者,小程序的订阅消息能力可以精准推送预约结果和提醒,比短信成本低得多,比站内信触达率高。
2. 技术选型:原生小程序 + Spring Boot + MySQL 够不够用
2.1 前端框架的取舍:原生、uniapp 还是 Taro
我在这个项目里选择了原生微信小程序开发,没有用 uniapp。原因一,项目功能边界清晰,涉及页面大概六七个,用原生开发完全可控;原因二,原生小程序的体积控制最好,这个项目最终打包出来的主包不到 1MB,审核和加载都更轻松;原因三,原生开发没有跨端需求,不需要为了“未来可能上支付宝小程序”这种不确定性引入一层编译框架。
当然,如果你熟悉 uniapp,用 uniapp 来写也不是不行,但要小心几个问题:
- uniapp 在编译到微信小程序时,部分 CSS 效果和组件 API 会有差异,比如 rpx 单位的边界情况。
- 涉及自定义组件时,编译产物体积容易膨胀,曾经有个项目编译后 source size 超了 2MB 上限,排查半天才发现是引了一个冗余图标库。
- 微信小程序专有的 API(如获取胶囊按钮位置)在 uniapp 里需要条件编译,多一层复杂。
所以说,如果只是做单小程序,原生开发反而是最优路径。尤其是页面结构简单、纯业务逻辑的,原生小程序的开发体验其实不比框架差多少。
2.2 后端选型:Spring Boot 是稳的选择
后端我用了 Spring Boot 2.7 + MyBatis Plus + MySQL 8.0。选这套组合的原因很直接:成熟、生态好、资料多。毕设和中小型业务用这个组合,遇到问题几乎都能搜到现成解法,团队接手成本也低。
如果你手里只有 Node.js 经验,那用 Express 或 Egg.js 写这个项目也完全可以,核心差异不大。但要注意一点:座位预约的业务核心是事务控制和并发安全,这跟语言关系不大,跟数据库设计和接口逻辑强相关。所以我的建议是,后端框架选你最熟的,但下面几件事一定要做对:
- 预约表要有唯一约束,防止同一个用户同一时间段重复预约。
- 座位更新操作必须走乐观锁或条件更新,防止高并发下“多人同时约到同一个座位”。
- 签到超时释放的逻辑要用定时任务或懒触发兜底,不能只依赖用户操作。
这几点在本篇第 4 部分会展开讲。
2.3 认证费用与主体资质:一个容易忽略的准备工作
微信小程序开发前一定要先想清楚主体资质问题。个人主体小程序注册是免费的,可以扮演开发调试,但涉及手机号快速验证组件、订阅消息等能力时,部分能力对企业主体的支持更完整。而企业主体需要微信认证,认证费用每年 300 元,这个费用是交给微信官方的,不是技术服务费。
我在项目启动前就会先确认甲方的主体资质和应用场景,避免代码写完才发现某个关键 API 没法用。比如手机号快速验证组件,早期版本对个人主体是不开放的,虽然现在政策逐步放宽,但不同时期、不同类目下限制不一样。稳妥的做法是提前在微信公众平台的“开通服务”列表里查看当前账号已具备的权限,再决定要不要在登录流程里依赖手机号。
3. 核心逻辑拆解:座位状态、数据表与时间冲突判断
3.1 座位状态机:业务规则的根
座位预约管理系统的灵魂,不是页面好不好看,而是状态机的合理性。一套清晰的状态机,能让你写业务代码时事半功倍。
我把座位状态定义为七种:
| 状态码 | 状态名 | 含义 | 可流转目标 |
|---|---|---|---|
| 0 | 空闲 | 无人占用,可预约 | 已预约 |
| 1 | 已预约 | 已锁定,等待用户签到 | 已签到、已取消 |
| 2 | 已签到 | 用户已到场,座位使用中 | 已释放、已违约 |
| 3 | 已释放 | 用户主动结束使用 | 空闲 |
| 4 | 已取消 | 预约被取消 | 空闲 |
| 5 | 已违约 | 超时未签到或超时未释放 | 空闲 |
| 6 | 禁用 | 座位维修或永久停用 | 空闲(重新启用) |
这个状态机确定以后,接口的设计难度就大幅下降。每一次操作,本质上都是“在满足前置状态的条件下,把状态从 A 流转到 B”。比如用户点击“取消预约”,程序先检查这条记录的状态是否为“已预约”,是才能流转到“已取消”,同时把对应座位置为“空闲”。
有人会问,为什么状态字段不直接叫“使用中”而要区分“已签到”和“已释放”?因为签到只是确认用户到场,并不代表使用结束。保持粒度清晰,后续做统计数据时才不会混沌。
3.2 数据表设计:三张核心表能撑起整个项目
数据库设计我采用了最经典的三表结构:用户表、座位表、预约记录表。别觉得表少,业务的扩展性完全不差。
用户表关键字段:
- id:主键。
- openid:微信登录的 openid,唯一。
- nickname、avatar:用户昵称头像。
- phone:手机号(通过微信手机号快速验证组件获取)。
- status:用户状态,用于封禁或违规限制。
- create_time:注册时间。
座位表关键字段:
- id:主键。
- floor:楼层,比如 3F。
- area:区域,比如 A区。
- seat_no:座位编号,同一楼层内唯一。
- status:座位当前状态,对应状态机里的值。
- location_x、location_y:座位在座位图纸上的坐标,用于前端绘制。
- version:乐观锁版本号,高并发必用。
预约记录表关键字段:
- id:主键。
- user_id:用户 id。
- seat_id:座位 id。
- reserve_date:预约日期。
- start_time:开始时间。
- end_time:结束时间。
- status:预约状态:预约中、已签到、已取消、已违约、已释放。
- sign_time:签到时间,空表示未签到。
- cancel_time:取消时间。
- create_time:创建时间。
这里有几个关键的约束要注意:
- 预约表要加联合唯一索引,例如
UNIQUE KEY uk_user_date (user_id, reserve_date),保证同一用户同一天不能重复预约多个座位(如果业务规则允许一人一天多次,可以用user_id, reserve_date, start_time来限定同一时间窗口唯一)。 - 座位表和预约记录表的一对多关系要清晰,一个座位在某个时间段只能有一条有效的预约记录,这个要在业务层保证,不能只靠数据库。
3.3 时间段冲突判断:最容易写错的地方
这是整个项目技术含量最高的一个模块,见真章的地方。
假设业务规则是:用户可以选择某天的某个时段预约座位,比如“明天 9:00-11:00”。那么,怎么判断一个新预约和已有预约是否存在时间段冲突?这一句话能把很多新手卡死。
判断两个时间段是否重叠的条件,标准写法是:
新预约开始时间 < 已有预约结束时间 AND 新预约结束时间 > 已有预约开始时间写成 SQL 就是:
SELECT COUNT(*) FROM reserve_record WHERE seat_id = #{seatId} AND reserve_date = #{reserveDate} AND status IN ('预约中', '已签到') AND start_time < #{endTime} AND end_time > #{startTime}注意,是start_time < #{endTime}而不是<=,边界值要格外小心。举个例子,已有预约是 10:00-11:00,新预约是 11:00-12:00,这两个时间段恰好首尾相接,严格说不冲突。如果 SQL 里用了<=,就会把这种合法预约误判为冲突。相反,已有预约 10:00-11:00,新预约 10:30-11:30,这就是明显冲突,SQL 能正确捕获。
再考虑一个细节:预约日期字段reserve_date是一个DATE类型,只存日期,不存时间,而start_time、end_time是TIME类型。很多人在建表时喜欢把开始时间和结束时间设成DATETIME,然后把日期也塞进去,结果就是冲突判断要多写一大堆DATE()函数,性能差还容易错。拆开存,查询时用索引,干净利落。
4. 实操全流程:从登录授权到座位图交互
4.1 微信登录与手机号获取的正确姿势
小程序的登录流程,很多新手都喜欢用wx.login直接返回 code,然后传给后端换取 openid,再把 openid 当用户唯一标识。
这套逻辑没错,但要补充几个关键点。
第一,wx.login拿到的 code 只能用一次,且有效期只有五分钟,后端拿 code 去调用微信接口换取 openid 和 session_key,这个过程一定不能泄给前端,否则你的服务端就等于裸奔了。
第二,换 openid 的接口调用放在后端完成,接口是code2Session,参数包括appid、secret、js_code、grant_type。返回包里有openid和session_key,其中 session_key 不要回传给前端。
第三,业务上需要手机号时,不要先弹窗让用户填手机号再校验验证码,直接用微信的button组件,设置open-type="getPhoneNumber"。用户点击触发后,前端拿到一个加密的 code,同样传给后端,后端调用phonenumber.getPhoneNumber接口换取手机号明文。
下面是一个关键注意点:
手机号获取按钮绑定的是
<button>组件,不能换成<view>,否则无法触发授权弹窗。而且在真机调试时,如果提示“手机号授权失败”,先检查小程序主体类型和该接口的权限是否已开通,这个问题我遇到太多次了。
前端的关键代码大致如下:
<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumberHandler"> 微信手机号登录 </button>getPhoneNumberHandler(e) { const { code } = e.detail if (!code) { wx.showToast({ title: '已取消授权', icon: 'none' }) return } wx.request({ url: 'https://your-api.com/api/auth/phone', method: 'POST', data: { code }, success(res) { // res.data.phone 就是用户手机号 } }) }后端换取手机号的逻辑(Spring Boot + Hutool 简化写法):
Map<String, Object> map = new HashMap<>(); map.put("code", code); String response = HttpUtil.post("https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" + accessToken, map);这里要注意,getuserphonenumber接口要求先获取access_token,而access_token应该独立缓存,不要每次请求都调用gettoken接口,刷太频繁会被微信限流。
4.2 座位图的绘制与导航栏适配
微信小程序里画座位图,我没有用 canvas,而是直接用view组件堆格子。每个座位是一个固定宽高的圆角方块,通过绝对定位放到楼层背景图上。
为什么不用 canvas?因为 canvas 做不了事件绑定,你没法轻轻松松知道用户点的是哪个座位。用 view 画图,一个<view>对应一个座位,绑定>const { statusBarHeight } = wx.getSystemInfoSync() const menuRect = wx.getMenuButtonBoundingClientRect() const navBarHeight = (menuRect.top - statusBarHeight) * 2 + menuRect.height
状态栏高度加胶囊按钮占位,才是自定义导航栏的总高度。这个写法我用过很多次,建议封装成工具函数直接调用,比硬编码安全得多。
4.3 预约接口的并发安全:一个 UPDATE 语句解决的问题
图书馆的某个热门座位,在早上 8 点放号的瞬间,可能会有几十个人同时点击预约。如果后端这么写:
// 错误示范 ReserveRecord record = reserveMapper.selectOne(...) if (record == null) { reserveMapper.insert(...) }那并发请求会同时读到“没有记录”,然后全部插入成功,造成同一时段多人抢到同一个座位,也就是俗称的“超卖”。
正确的做法很朴素,用数据库的原子更新充当乐观锁:
UPDATE seat SET status = 1, version = version + 1 WHERE id = #{seatId} AND status = 0 AND version = #{oldVersion}如果这条 UPDATE 返回的影响行数为 1,说明座位抢到了;显示为 0,说明座位已经被别人占用,本次预约失败。
然后再插入预约记录,放在同一个数据库事务里。Spring Boot 加一个@Transactional注解即可:
@Transactional public Result bookSeat(BookRequest req) { int updated = seatMapper.compareAndSetStatus(req.getSeatId(), 0, 1); if (updated == 0) { return Result.error("座位已被抢走"); } reserveMapper.insert(buildReserveRecord(req)); return Result.success(); }这个方案不需要引入 Redis 分布式锁,对中小型系统而言,一个条件 UPDATE 配合事务,已经能应对绝大多数并发场景。如果未来日活量真的非常大,再考虑缓存 + MQ 削峰,不要一开始就上分布式那一套给自己添堵。
这里我多说一句,网上很多人开口闭口 Redis 分布式锁,但实际项目里,很多所谓高并发场景只是技术想象出来的。座位预约这种业务,全校同时抢座的峰值不过几千 QPS,一个普通 MySQL 实例配合乐观锁完全扛得住。真到需要 Redis 那天,你的系统瓶颈大概率不在预约,而在权限下发和座位图加载。
4.4 签到、取消与违约定时任务
预约成功不是结束,签到才是关键。
业务规则我设定了:预约成功后 15 分钟内必须签到,否则座位自动释放,并记录一次违约;签到时通过扫码或点击“签到”按钮,调用后端接口确认。后端将预约状态置为“已签到”,同时把座位状态置为“已签到”。
这里有一个设计细节,很体现工程经验:超时释放不能只靠一个全局定时任务全表扫。最合理的方案是:懒触发 + 定时触发的双保险。
懒触发,就是用户端在获取座位图或发起新预约前,先调用一个releaseExpiredReservations接口,让后端把所有过期未签到的预约全部处理掉。因为每次有用户访问就会触发一次,系统的数据始终能保持最新。
定时触发,则是后端用@Scheduled注解写一个兜底任务,每 5 分钟扫描一次过期预约,避免有用户不在线导致数据长期不刷新。
定时任务的简化写法:
@Component public class ReleaseTask { @Scheduled(fixedDelay = 300000) public void releaseExpired() { List<ReserveRecord> expiredList = reserveRecordMapper.selectExpiredReservations( new Date(System.currentTimeMillis() - 15 * 60 * 1000) ); for (ReserveRecord record : expiredList) { reserveRecordMapper.updateStatus(record.getId(), "已违约"); seatMapper.updateStatus(record.getSeatId(), 0); // 座位回空闲 } } }这个任务的频率不要太高,5 分钟一次合理。如果设成每 10 秒一次,对数据库压力大,而且座位释放的实时性并不会有多大的提升。
5. 问题排查与经验实录:那些真机才暴露的坑
5.1 手机号授权失败但开发者工具显示正常
模拟器里手机号授权一路通过,真机一测就失败,这个坑非常经典。原因通常是以下两个:
第一,getPhoneNumber接口对个人主体小程序限制。开发者工具模拟时,使用的是测试号或开发版账号,权限校验宽松;真机一旦走正式账号,接口权限就严格按照账号类型执行了。解决方案是先确认账号主体,如果业务确实需要手机号,就提前办理企业主体认证。
第二,微信基础库版本过低。老版本基础库对新的手机号组件支持不完善,建议在app.json里设置最低基础库版本,比如"libVersion": "3.0.0",但也要覆盖用户群体的手机版本情况。
5.2 预约记录重复:我加了唯一索引,为什么还插进去了
我也遇到过这种情况,排查半天发现是“唯一索引”加错了字段组合。原本业务意图是一个用户一天只能预约一个座位,但我给表加的索引组合是(user_id, reserve_date, start_time)。用户 A 在 10:00-11:00 和 13:00-14:00 各约了一次,如果系统允许多时段预约,这个索引形同虚设。我的建议是,先把业务规则定死:默认一个用户同时段只能有一个有效预约,索引用(user_id, reserve_date, start_time),状态进一步限定为“预约中”和“已签到”。
然后再配合业务判断,双保险。
5.3 签到超时判定不准:服务器时间与本地时间之争
小程序的用户设备时间是可以手动改的,你不能用用户手机上的时间判断是否在 15 分钟内签到。我在实际开发中吃过这个亏:有人把手机时间往前调了几分钟,结果系统判定他迟到,记了一次违约,用户投诉过来我才意识到。
正确方案是,所有时间戳判定都以后端服务器时间为准:
- 前端展示倒计时时,用后端返回的
serverTime计算。 - 后端在创建预约记录时,直接
new Date()取服务端时间。 - 不要在 SQL 里用
NOW()跟本地变量混用,保持时间来源的统一。
5.4 小程序审核被拒:不要最后才检查类目和隐私政策
发布小程序时被拒是常有的事,尤其是涉及预约和用户信息采集的。一旦涉及收集手机号、位置、摄像头(扫描签名)等信息,微信会要求你在小程序后台完善“用户隐私保护指引”,并在代码中调用wx.getPrivacySetting获取用户隐私授权状态。
不要等审核被打回才开始补,项目启动时就先做两件事:第一,查一下预定的小程序类目是否需要额外资质;第二,把隐私政策弹窗提前做好,在与用户交互前先行通知用户。这能省下至少一次提审流程。
6. 沉淀下来的一点心得
这套座位预约系统做下来,我最深的体会是:微信小程序这块的技术难点,其实不在 UI 效果能做得多炫,而是在登录链路、数据状态、并发控制这几个“看不见”的地方。页面做得好是锦上添花,但一个 UPDATE 语句写错了,数据错乱才是灾难。
最后分享一个小技巧,座位图的页面加载优化:首次进入时先请求座位总览数据,返回所有座位的状态;预约成功或他人释放后,通过下拉刷新或 WebSocket 推送更新单个座位状态,而不是每次都重新拉全量数据。这个优化对高峰期体验提升非常明显,尤其是手机网络不稳定的场景下,页面响应能快很多。
如果以后要在这套系统上做扩展,我建议优先考虑两件事:一是利用预约历史数据做座位利用率热力分析,指导图书馆调整座位数量;二是接入硬件设备,比如座位上的二维码铭牌,让签到这个动作从点击按钮变成扫码识别,门槛更低、也更有仪式感。代码工程在手里,这些扩展点随时都可以做。