
这套系统我前前后后做了将近一个月从最开始“能跑就行”的想法到最后整理出完整的座位预约流程期间踩了不少坑也沉淀了不少经验。选题就是标题里说的Spring Boot 后端 微信小程序端实现图书馆座位预约系统。如果你正准备做一个类似的小程序不管是课程设计、毕业设计还是公司接到类似“预约类”项目这篇文章应该能帮你少走很多弯路。图书馆座位预约这事儿看起来简单——选座、占座、走人——但一旦要上线给真实用户用涉及到的并发控制、状态流转、违约处理、微信登录鉴权每一样都会让你头疼。我会把整个系统的设计思路、关键代码、数据库表结构以及我实际调试过程中遇到的问题和解决方法全部写出来尽量做到你可以照着复现。1. 系统整体设计与技术选型1.1 项目背景与核心业务链路先说说我为什么会做这个系统。当时某高校图书馆的管理员找到我说占座现象太严重了经常出现“书在人不在”的情况一到期末复习高峰期自习区有一半座位是空着但被书本占着的真正想学习的人找不到位置。他们之前尝试过在门口放纸质签到本但根本管不住。所以想让我做一个座位预约系统做到“先预约、后入座、离开释放座位”通过规则约束和黑名单机制把座位流转起来。这套系统的核心用户就是学生配合图书馆的管理员。学生通过微信小程序查看实时座位状态、选定座位、预约某个时间段到馆以后完成签到离开时释放座位。管理员在后台维护座位数据查看预约记录处理违约申诉。整个业务链路拆开来看其实就是三条线用户线微信登录建立用户身份 - 查看座位 - 预约 - 签到 - 释放座位 - 查看自己的预约记录和信用状态座位线座位库维护 - 座位实时状态展示 - 座位在“空闲、已被预约、使用中、暂离、禁用”之间流转管理线数据看板 - 违规记录 - 座位开关 - 参数配置比如预约开始时间、签到窗口时长等这个模型不限于图书馆很多预约类场景比如会议室、健身房、自习室基本是一样的套路。你把“座位”换成“会议室”或“设备”业务逻辑几乎可以平移。1.2 后端为什么选 Spring Boot后端选 Spring Boot说实话不是因为它有多炫酷而是因为它真的是这类管理系统的“省心之选”。Spring Boot 不需要像传统 SSM 那样配置大量 XML大部分东西都靠自动配置搞定项目结构清晰社区资料丰富招人也好招。我这次用的版本是 Spring Boot 2.7.18搭配 JDK 1.8。有的同学可能会纠结要不要直接用 Spring Boot 3.x 和 JDK 17其实都可以但如果你用的是 MyBatis-Plus3.x 版本下要注意适配问题2.7 更稳一点。具体版本如下组件版本说明Spring Boot2.7.18稳定、兼容性好MyBatis-Plus3.5.x单表 CRUD 效率极高MySQL8.0主流版本注意时区配置Redis6.x缓存座位状态 分布式锁微信小程序原生体积小无需引入额外框架持久层我没用 JPA 而是选了 MyBatis-Plus原因很简单这个系统里查询条件非常多比如“按楼层查座位”“按状态查预约记录”“分页查所有订单”MyBatis-Plus 的 QueryWrapper 写起来非常方便而且不容易出错。原生 MyBatis 写大段 XML 在后期维护时太痛苦了。1.3 前后端交互整体流程小程序端和后端之间的核心交互是围绕微信登录展开的整个流程容易理解但细节容易写错。用户首次打开小程序小程序调用 wx.login 拿到一个临时 code把这个 code 发给后端后端拿着 code 加上 appid 和 secret 去请求微信的接口换回 openid 和 session_key。openid 是用户在你这套系统里的唯一身份标识session_key 用来解密敏感数据。这里有个重点后端不要直接拿微信返回的 session_key 作为登录态因为小程序每次冷启动时 code 都会变session_key 也可能刷新。正确的做法是后端拿到 openid 后查一次用户表如果不存在就自动注册然后生成一个自己的 token比如 UUID 或者 JWT返回给小程序存到 storage 里。后续所有请求都带这个 token后端解析 token 得到用户身份。为什么这么做因为如果每次请求都拿 code 换取 openid那每次都要请求微信接口性能差不说还容易被频率限制。自己生成 token 不仅快还能控制过期时间实现“强制退出登录”。2. 数据库设计与预约状态流转2.1 核心表结构设计数据库是一套系统的地基尤其是这种带“状态”的系统表设计一旦出问题后面改起来想哭。我做过几次类似项目之后总结出一个原则状态字段用数字枚举不要用字符串中文因为中文一旦要改代码里全是魔法值。下面是我在这套系统里用的核心表我已经做了简化但主要结构都在。第一张是用户表存储微信用户的信息。这里有几个容易忽略的点openid 一定要加唯一索引另外建议冗余一个 student_no 字段用来做学号认证因为很多图书馆的座位系统只对内部学生开放到时候如果管理员要求“必须绑定学号才能预约”你已经在表里留好了位置。CREATE TABLE t_user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, openid varchar(64) NOT NULL COMMENT 微信openid, student_no varchar(32) DEFAULT NULL COMMENT 学号/工号, nickname varchar(64) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像URL, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态 1正常 0禁用, violation_count int(11) NOT NULL DEFAULT 0 COMMENT 违约次数, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;第二张是座位表。注意“座位号”是一个语义概念比如“3楼A区12号”“座位编号”seat_no 才是唯一编码。楼层、区域、座位编号这三个字段要配合唯一索引防止管理员在后台重复录入同一个位置的座位。座位的状态我用 0 表示空闲、1 表示已预约、2 表示使用中、3 表示暂离、4 表示禁用。为什么要区分“已预约”和“使用中”因为预约之后用户可能还没到馆签到这是两个完全不同的阶段后续统计入座率、座位利用率都要靠这个状态区分。CREATE TABLE t_seat ( id bigint(20) NOT NULL AUTO_INCREMENT, floor varchar(16) NOT NULL COMMENT 楼层如 1F/2F, area varchar(32) DEFAULT NULL COMMENT 区域如 A区/B区, seat_no varchar(16) NOT NULL COMMENT 座位编号, seat_type tinyint(4) NOT NULL DEFAULT 1 COMMENT 座位类型 1普通 2带插座 3靠窗, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态 0空闲 1已预约 2使用中 3暂离 4禁用, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_floor_area_seatno (floor,area,seat_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT座位表;第三张是预约记录表。这张表是整个系统操作最频繁的表也是并发问题最集中的地方。预约状态我定义为1 预约成功待签到2 已签到使用中3 已结束正常释放4 已取消5 超时未签到违约6 暂离超时违约。这里要特别留意每次用户产生新的有效预约时都应该在 insert 之前检查同一时间段是否已经有冲突预约我在 2.3 节会详细讲加锁方案。CREATE TABLE t_reservation ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 用户ID, seat_id bigint(20) NOT NULL COMMENT 座位ID, reserve_date date NOT NULL COMMENT 预约日期, start_time datetime NOT NULL COMMENT 预约时段开始, end_time datetime NOT NULL COMMENT 预约时段结束, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态 1待签到 2使用中 3已结束 4已取消 5超时违约 6暂离违约, sign_in_time datetime DEFAULT NULL COMMENT 签到时间, release_time datetime DEFAULT NULL COMMENT 释放时间, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_seat_id (seat_id), KEY idx_status_time (status,start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约记录表;第四张是违约记录表。这个表相对简单主要记录用户因为超时未签到、暂离超时、违规占座等行为产生的违约信息管理员在后台处理申诉时也会修改这张表的状态。CREATE TABLE t_violation ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, reservation_id bigint(20) NOT NULL COMMENT 关联的预约记录, type tinyint(4) NOT NULL COMMENT 1超时未签到 2暂离超时 3其他违规, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待处理 1已撤销 2已确认, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT违约记录表;2.2 预约状态机设计这套系统最容易出 bug 的地方就是预约状态没有设计好状态机导致开发时东改一处西改一处最后数据变得一团乱。我这次一开始就把状态流转图画清楚了后面的代码写起来就顺了。预约状态的合法流转如下待签到1用户创建预约成功后进入的状态。在这个状态下座位状态为“已预约”。已签到/使用中2用户到馆后在小程序里点击签到预约状态从 1 变为 2座位状态同步变为“使用中”。正常结束3用户离开时手动点击“释放座位”或者预约时段到达 end_time 后系统自动释放。预约状态变为 3座位变为空闲。已取消4用户在开始时间之前主动取消预约。注意预约一旦签到成功就不能取消只能释放。超时未签到5预约的开始时间到了之后如果超过“签到窗口”我设置的 30 分钟这个值建议做成管理员可配置仍未签到系统判定为违约预约状态变为 5座位释放并给用户记一次违约。暂离违约6用户使用中点击“暂离”暂离限时 20 分钟超过 20 分钟没有点击“回来”就自动释放座位并记违约。这个功能如果你们图书馆不需要可以砍掉但做出来的话对治理“饭点离开占座”非常有效。为了让状态流转代码不散落在各个 Service 里我建议把状态变更统一收敛到一个方法中要么写在 Service 里加事务要么干脆写一个专门的状态处理器。不要直接在 Controller 里写 UPDATE 语句否则后面加判断逻辑时你会到处找 SQL维护成本很高。2.3 索引与并发防重设计座位预约这个场景最大的并发风险来自“同一个座位在同一个时间段被两个人同时预约”。常规的思路是在 t_reservation 表上加唯一索引比如 user_id reserve_date 表示一个用户同一天只能预约一次或者 seat_id reserve_date 表示一个座位同一天只能被预约一次。但是如果你的预约粒度是“时间段”那唯一索引就很难处理了因为两个用户可能预约同一个座位不同时间段这是合法的两个用户也可能预约同一个座位完全重叠的时间段这是非法的。数据库的普通唯一索引解决不了这种区间重叠问题。所以我的做法是双重保障第一层是 Redis 分布式锁。在创建预约时以 seat_id reserve_date 时间段为 key执行 SET key 1 EX 5 NX如果加锁成功再走后续查询和插入流程。如果加锁失败说明同一个座位这个时间段刚被别人抢了直接返回“座位已被预约”。第二层是数据库乐观锁。在 t_seat 表上加一个 version 字段每次更新座位状态时检查 version。比如“用户签到”操作要先把座位从“已预约”更新为“使用中”这时执行 UPDATE t_seat SET status 2, version version 1 WHERE id ? AND version ?如果影响行数为 0说明座位状态已被别人改过逻辑就要回滚并重新查询。这里要提醒一点分布式锁必须设置过期时间而且不能只是简单地 SET NX要加上 EX防止持有锁的服务突然宕机导致死锁。我实际把过期时间设置成了 5 秒正常情况下一个插入操作几十毫秒就完成了完全够用。3. 后端核心模块实现3.1 微信登录与 Token 鉴权微信登录接口的写法比较固定我这里给出核心代码。注意校验 code 是否为空以及后端请求微信接口时要用 HTTPS GET。返回结果里如果有 openid 就说明成功如果有 errcode 则要判断是 code 失效还是 appid/secret 配置错误。RestController RequestMapping(/api/user) public class UserController { Autowired private UserService userService; Autowired private RedisTemplateString, String redisTemplate; PostMapping(/login) public Result login(RequestBody LoginRequest request) { String code request.getCode(); if (StringUtils.isBlank(code)) { return Result.error(code不能为空); } // 向微信接口换取 openid String url https://api.weixin.qq.com/sns/jscode2session?appid appid secret secret js_code code grant_typeauthorization_code; String response restTemplate.getForObject(url, String.class); JSONObject json JSON.parseObject(response); String openid json.getString(openid); if (StringUtils.isBlank(openid)) { return Result.error(微信登录失败 json.getString(errmsg)); } // 查找或创建用户 User user userService.findByOpenid(openid); if (user null) { user new User(); user.setOpenid(openid); userService.save(user); } if (user.getStatus() 0) { return Result.error(该账号已被禁用请联系管理员); } // 生成自己的 token存 Redis 并设置过期时间7天 String token UUID.randomUUID().toString().replace(-, ); redisTemplate.opsForValue().set(login:token: token, String.valueOf(user.getId()), 7, TimeUnit.DAYS); return Result.ok(user, token); } }鉴权方面我用了最直接的拦截器方案。继承 HandlerInterceptor在 preHandle 里从请求头拿到 token然后去 Redis 查用户 id查不到或者过期就直接返回 401。这里有个细节小程序端请求头字段名最好统一比如用 Authorization后端读取的时候大小写也要统一避免前后端因字段名不一致排查半天。3.2 座位列表与状态实时查询座位的实时状态是用户最关心的数据。如果每个用户打开选座页都去 MySQL 里查一次几百条座位数据数据库压力会比较大。我的方案是座位的基础信息存 MySQL座位当前状态高频读写所以在 Redis 里维护了一份 seat:{seatId}:status 的缓存。每次小程序刷新选座页面时后端先查 MySQL 获取座位基础信息列表然后批量从 Redis 读取状态拼装返回。座位状态变更的接口预约、签到、释放、暂离都会同步更新 Redis 缓存。这个小优化在并发不高的时候体现不出优势但一旦到高峰期几百个学生同时刷新页面MySQL 的查询压力会被缓存大幅减轻。同时还能顺便解决一个一致性问题如果管理员临时禁用某个座位只需要修改 Redis 里的状态为 4用户端立刻看不到这个座位不需要等缓存过期。座位列表接口的返回结构大概是下面这样{ floor: 3F, area: A区, seats: [ { seatId: 101, seatNo: A12, type: 1, status: 0, x: 20, y: 80 }, { seatId: 102, seatNo: A13, type: 2, status: 1, x: 60, y: 80 } ] }其中 x 和 y 坐标用来在小程序端绘制座位图这样可以做到前端的座位图和后台录入的座位位置一致而不是前端写死一张图片。后面小节我会讲小程序端怎么利用这两个坐标渲染座位。3.3 创建预约与防重复处理创建预约是整套系统的核心接口也是并发问题最容易爆发的地方。我先说业务判断条件用户必须在可预约时间段内比如预约开始时间不能早于当前时间 30 分钟且一天内不能有超过 N 条有效预约座位必须存在且状态不能是禁用同一个座位在目标时间段不能存在已预约或使用中的预约记录。全部通过后生成预约记录把座位状态改为已预约并同步 Redis 缓存。核心代码我简化之后大概是这样的public Result createReservation(CreateReservationDTO dto, Long userId) { // 1. 校验用户和座位基本状态 Seat seat seatService.getById(dto.getSeatId()); if (seat null || seat.getStatus() 4) { return Result.error(座位不存在或已禁用); } User user userService.getById(userId); if (user.getViolationCount() 3) { return Result.error(违规次数过多本周暂停预约); } // 2. 分布式锁key 粒度到 座位日期时间段 String lockKey seat:lock: dto.getSeatId() : dto.getReserveDate() : dto.getStartTime(); Boolean lock redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (Boolean.FALSE.equals(lock)) { return Result.error(手速太快座位刚刚被别人抢了); } try { // 3. 校验冲突预约 Long count reservationService.lambdaQuery() .eq(Reservation::getSeatId, dto.getSeatId()) .eq(Reservation::getReserveDate, dto.getReserveDate()) .in(Reservation::getStatus, 1, 2) .ge(Reservation::getStartTime, dto.getStartTime()) .lt(Reservation::getStartTime, dto.getEndTime()) .count(); if (count 0) { return Result.error(该座位当前时段已被预约); } // 4. 插入预约 更新座位状态事务 reservationService.createReservationWithSeat(dto, userId); return Result.ok(预约成功); } finally { redisTemplate.delete(lockKey); } }这里我顺便提一个常见错误不要把删除锁放在业务逻辑之前。上面的代码用的是 try-finally 来保证无论业务成功还是异常都释放锁是因为这个锁的粒度是针对同一个座位同一时段的互斥业务执行完必须马上释放否则同一个时段的第二次预约会一直被阻塞。但也要注意释放锁的风险是删掉了别人刚抢到的同 key 锁。稳妥的解法是把锁的 value 设置为一个随机值释放时先判断 value 是否匹配再删除。并发量不大的情况下用 SET NX EX 加 finally 删除基本够用但如果要上线到大流量场景建议还是用 Redisson 这类自带看门狗机制的客户端。3.4 签到、释放与暂离逻辑签到的业务规则是只有预约状态为“待签到”的预约才能签到当前时间必须落在预约开始时间前后 30 分钟内太早不能签太晚直接判定超时违约签到后预约状态变为 2座位状态变为 2同步缓存。释放的逻辑和签到是对称的。用户点击释放座位时把预约状态改为 3座位改为 0记录 release_time。这里有个必要校验只有当前预约状态为“使用中”或“暂离”才允许释放否则可能出现用户对待签到的预约直接点击释放造成状态错乱。暂离功能我建议做成独立的表字段比如在 t_reservation 里加一个 left_time 字段记录暂离开始时刻然后定时任务判断如果 left_time 不为空且当前时间减去 left_time 超过 20 分钟就自动执行释放并记录违约。实现不复杂但对座位流转效率的提升很有帮助。这三个接口本质上都是“状态变更 座位状态同步”建议统一封装到一个方法里Transactional public void changeReservationStatus(Long reservationId, Integer targetStatus, Long seatId, Integer targetSeatStatus) { Reservation reservation reservationService.getById(reservationId); if (reservation null || !reservation.getStatus().equals(nextValidStatus)) { throw new BizException(当前状态不允许该操作); } // 先更新预约记录再更新座位状态事务保证原子性 reservationService.updateStatus(reservationId, targetStatus); seatService.updateStatus(seatId, targetSeatStatus); // 同步 Redis redisTemplate.opsForValue().set(seat:status: seatId, String.valueOf(targetSeatStatus)); }3.5 定时任务超时自动释放与违约记录超时未签到的处理不能依靠用户主动操作必须由后端兜底。我用的是 Spring 自带的 Scheduled 定时任务每隔一分钟扫描一次预约记录。扫描条件status 1待签到并且 start_time 当前时间减去 30 分钟把这些预约批量改成 status 5然后把对应座位状态改回 0同时生成一条违约记录。这里的“每隔一分钟”有一种优化空间可以用 Redis 的过期键监听来做更实时性更强的处理但我评估了一下图书馆的预约场景对实时性要求没那么高一分钟延时完全感知不到用定时任务成本最低逻辑也最好排查。Scheduled(cron 0 * * * * ?) public void handleTimeoutReservations() { LocalDateTime deadline LocalDateTime.now().minusMinutes(30); ListReservation timeoutList reservationService.lambdaQuery() .eq(Reservation::getStatus, 1) .lt(Reservation::getStartTime, deadline) .list(); for (Reservation reservation : timeoutList) { reservation.setStatus(5); seatService.updateStatus(reservation.getSeatId(), 0); violationService.addViolation(reservation.getUserId(), reservation.getId(), 1); redisTemplate.opsForValue().set(seat:status: reservation.getSeatId(), 0); } reservationService.updateBatchById(timeoutList); }这个定时任务在生产环境要注意一个问题如果实例部署了多台那么多个实例会同时跑同一个定时任务造成重复处理。虽然我们的业务代码里因为重复更新同一记录不会产生严重问题但总归不优雅。解决方案有很多最简单的是用 ShedLock 或者在任务开始前尝试获取一个 Redis 分布式锁保证只有一个实例执行。4. 微信小程序端关键实现4.1 小程序页面结构与登录态管理小程序端我用的原生框架没用 uni-app 或者 Taro原因是这个项目只有一个简单的小程序端原生框架的包体积最小、启动速度最快、调试也最方便。页面一共五个首页显示图书馆楼层和座位概览、选座页座位图、预约详情页、我的预约页、个人中心页。登录态管理是整个小程序端最容易出问题的地方。我在每个页面加载时都会先调用一个统一的方法判断本地 storage 里有没有 token没有 token 就先调用 wx.login 然后请求后端登录接口拿到 token 再继续后续操作。这里有个细节wx.login 本身不需要弹窗授权任何用户都能拿到 code所以“登录”动作对用户来说是完全无感的。只有在需要用户头像昵称时才会用到 wx.getUserProfile但那只是获取资料和身份登录是两码事。接口请求我封装成一个 request.js统一在 header 里带上 token遇到 401 就清除本地 token 并跳转到重新登录逻辑。这个小封装几乎每个项目都用得到提前做好能省大量重复代码。const request (url, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: baseUrl url, method, data, header: { Authorization: wx.getStorageSync(token) || }, success: (res) { if (res.data.code 401) { wx.removeStorageSync(token); // 重新调用静默登录 login().then(() resolve(res.data)); } else if (res.data.code 200) { resolve(res.data); } else { wx.showToast({ title: res.data.msg, icon: none }); reject(res.data); } }, fail: reject }); }); };4.2 选座页面与座位状态渲染选座页面是整个小程序端视觉和交互最核心的页面。常见的做法有两种一种是用一张平面图把每个座位画成一个小圆点或矩形点击之后再弹出座位信息另一种是用一个长列表显示所有座位和状态。前者体验好后者实现简单。我在这个项目里选了第一种因为图书馆管理员说学生更愿意“看图选座”一屏就能看到哪个区域还剩多少位置。座位图渲染我用的是普通 view 组件加绝对定位。后端返回每个座位的基础信息时带一个 x 和 y 坐标前端用一个容器里面每个座位 view 的 style 设置 left: x px、top: y px。座位的颜色根据状态变化空闲绿色、已预约黄色、使用中红色、暂离灰色、禁用深灰色。为了避免用户多次点击同一个座位造成重复请求我在选中后座位 view 上加了一个“锁定中”状态同时按钮变成 loading 状态等后端返回成功后再跳转。view classseat wx:for{{seatList}} wx:keyseatId styleleft: {{item.x}}px; top: {{item.y}}px; background: {{item.color}} bindtaponSeatTap>
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。