Spring Boot自习室选座系统:状态机设计、并发控制与门禁对接
2026/9/16 22:30:10 网站建设 项目流程

简介:基于Spring Boot框架与微信小程序实现的研学自习室选座与门禁系统,面向计算机专业学生、小程序开发者及自习室运营者,适用于毕业设计、课程设计或实际自习室管理场景。系统包含后台管理和前台用户两大板块:后台支持管理员账户维护、用户权限分配、座位占用状态管理、门禁进出控制及数据库连接参数配置;前台支持用户注册登录、在线选座预约、门禁扫码进出和个人信息修改,业务链路完整,具有较强实用性。资源包共749个文件,以java后端逻辑、vue前端页面、js交互脚本为主,辅以json配置、wxml/wxss小程序页面、svg/png图标等,另含sql数据库脚本和bat启动脚本,整体约17.09MB,目录划分清晰,便于按模块学习、部署与二次开发。目前已有48人学习浏览,适合需要完整前后端案例参照、希望快速搭建同类型选座门禁系统的开发者。

1. 自习室选座系统的难点,不在选座在状态机

一个研学自习室的选座小程序,表面看就是“座位表加预约表”的增删改查,但真正上线后最先出问题的往往是三件事:同一个座位被两个用户同时抢到;用户明明选座成功,门禁却拒绝开门;用户提前离场,座位却还占着约不到别人。这套基于 Spring Boot 与微信小程序的选座与门禁系统,核心不是画座位图,而是把“选座→预约→开门→离场”这条链路上的座位状态、预约状态、门禁权限状态对齐到同一个节奏上。这套方案的读者对象是预约类小程序的后端开发、需要对接门禁等硬件设备的工程师,以及想从源码层面改造一套完整项目的学习者。你从文中拿到的不只是一段接口代码,而是一套可以搬到自己项目里的状态设计与防并发方案。

2. 用 Spring Boot 四层架构拆座位模型与预约单

2.1 座位为什么要拆成 seat 与 seat_plan 两张表

很多初次接触预约系统的开发者会直接设计一张seat表,把“可约/不可约”直接写在座位的字段上。这个做法在单日单场次的场景下够用,一旦引入日期和时段,问题就来了:判断“这个座位今天上午能不能约”需要实时去预约表里查重,并发稍微一高,两个请求同时查到“无冲突”,然后都创建预约单,超卖就发生了。

常见做法是把“座位”与“某天某时段的可售状态”分开建模。seat表只存放静态属性:属于哪个房间、座位编号、靠窗还是静音区、是否停用。seat_plan表则把“座位 + 日期 + 时段”这个售卖单元物化成一行,状态落在单行上,后续所有抢座、锁座、释放都在这一行上做状态流转,不需要跨表实时计算。每日由定时任务按营业日历批量生成后几天的计划数据,状态初始化为可约,这样也方便在生成时统一处理节假日闭馆、某个座位临时维修等规则。

2.2 建表 SQL 与状态字段约定

以下是这套系统最核心的三张表的 DDL,字段做了精简,去掉了审计字段,保留业务关键列:

CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id VARCHAR(32) NOT NULL COMMENT '自习室ID,如 ROOM-A100', seat_no VARCHAR(16) NOT NULL COMMENT '座位编号,如 A-12', seat_type TINYINT NOT NULL DEFAULT 0 COMMENT '0普通 1靠窗 2静音', status TINYINT NOT NULL DEFAULT 0 COMMENT '0启用 1停用', UNIQUE KEY uk_room_seat (room_id, seat_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE seat_plan ( id BIGINT PRIMARY KEY AUTO_INCREMENT, seat_id BIGINT NOT NULL COMMENT '座位ID', biz_date DATE NOT NULL COMMENT '营业日期', period_no VARCHAR(16) NOT NULL COMMENT '时段编码,如 MORNING/AFTERNOON', status TINYINT NOT NULL DEFAULT 0 COMMENT '0可约 1锁座 2已约满', UNIQUE KEY uk_seat_date_period (seat_id, biz_date, period_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '业务单号,全局唯一', user_id BIGINT NOT NULL COMMENT '用户ID', seat_plan_id BIGINT NOT NULL COMMENT '售卖单元ID', biz_date DATE NOT NULL, period_no VARCHAR(16) NOT NULL, start_time DATETIME NOT NULL COMMENT '时段开始时间', end_time DATETIME NOT NULL COMMENT '时段结束时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0已预约 1已开门 2已结束 3已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_date (user_id, biz_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

seat_plan.status的状态流转决定了座位能不能被抢到:可约状态才能创建预约单,创建成功立刻置为已约满,取消时再回到可约。这里不要把“锁座”和“已约满”混成一个值,1表示用户正在提交流程中的短暂占位,2才是真正被下单占用。两者在超时处理和用户体验上是两套逻辑,合并后很难写清楚“这个座位到底是被人占着还是正在被别人抢”。reservation表里的order_no是门禁侧识别预约的唯一凭证,UUID 可用,但带日期和随机数的业务单号在排查问题时更直观。

status 值含义触发时机
0可约每日开台生成计划时
1锁座用户开始提交流程,短暂占位
2已约满预约单创建成功后
0(恢复)释放用户取消或超时未开门

2.3 包结构与四层架构落点

Spring Boot 项目的目录规范一般按“Controller→Service→Mapper→Entity”四层切分,这套源码也是这么组织的。controller层只做参数校验和登录态解析,不写业务规则;service层承载选座、取消、超时释放等核心逻辑;mapper层只负责 SQL 与实体映射。业务规则往 service 层收拢有一个实际好处:门禁回调、管理后台、小程序端三个入口最终都调用同一个 service 方法,就不会出现“小程序端取消了座位但门禁回调里没有取消逻辑”这种分叉。

很多项目写着写着就把状态判断散落到 controller 里,例如在某个接口里顺手写一句“如果 status 等于 2 就返回失败”,后续另一处入口复用时漏掉了这个判断,线上就出现脏数据。四层架构不是形式主义,它是在给状态流转划定唯一出口。增删改查以外的规则,包括时间校验、状态机流转、并发控制,统一收口到 service 层,改一处即可全端生效。

2.4 选座接口:Redis 预占 + 数据库行锁兜底

选座接口是整套系统并发压力最大的入口,常见做法是先用 Redis 的 SetNX 做分布式预占,再用数据库行锁兜底。参考实现如下:

@Service @RequiredArgsConstructor public class SeatReserveService { private final StringRedisTemplate redisTemplate; private final SeatPlanMapper planMapper; private final ReservationMapper reservationMapper; @Transactional(rollbackFor = Exception.class) public String reserve(ReserveRequest req) { String lockKey = "seat:plan:lock:" + req.getSeatPlanId(); // SetNX 抢锁,5 秒过期防止服务异常时锁残留 Boolean locked = Boolean.TRUE.equals(redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(5))); if (!locked) { throw new BizException("座位正在被其他人预约,请刷新后重试"); } try { // selectByIdForUpdate 走数据库行锁,是最终兜底 SeatPlan plan = planMapper.selectByIdForUpdate(req.getSeatPlanId()); if (plan == null || plan.getStatus() != 0) { throw new BizException("该时段座位不可预订"); } Reservation r = Reservation.builder() .orderNo(generateOrderNo(plan)) .userId(req.getUserId()) .seatPlanId(plan.getId()) .bizDate(plan.getBizDate()) .periodNo(plan.getPeriodNo()) .startTime(plan.getStartTime()) .endTime(plan.getEndTime()) .status(0) .build(); reservationMapper.insert(r); planMapper.updateStatus(plan.getId(), 2); return r.getOrderNo(); } finally { redisTemplate.delete(lockKey); } } }

setIfAbsent是 Redis SetNX 命令的 Spring 封装,返回值直接表示是否抢到锁。5 秒过期时间对于“查计划 + 插入预约 + 更新状态”这个短事务足够,即使服务在锁内崩溃,锁也会自动释放,不会卡死后续请求。数据库行锁作为第二道防线,处理的是 Redis 锁异常释放后两个请求同时进入临界区的极端情况。有一个细节要注意:Redis 锁在finally中释放时,事务可能还没提交,严格场景下应把释放动作放到事务提交后的回调里,或者直接换 Redisson 的看门狗锁,避免锁先释放、事务后提交造成的短暂状态不一致。

3. 微信小程序端链路:登录、扫码进房间、座位图点选

3.1 微信登录:code 换 openid,后端发 JWT

微信小程序的登录链路是固定的:前端调wx.login拿到一次性code,后端拿这个code去微信接口换openid,再签发自家的登录态。这段逻辑无论原生小程序还是 uniapp 编译产物都一样,区别只在于请求封装方式。

后端接口参考实现:

@PostMapping("/api/auth/wechat") public ResponseEntity<AuthResponse> wechatLogin(@RequestBody WechatLoginReq req) { String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + appId + "&secret=" + appSecret + "&js_code=" + req.getCode() + "&grant_type=authorization_code"; // 使用 RestTemplate 发起 GET 请求,解析响应中的 openid WechatSessionResp resp = restTemplate.getForObject(url, WechatSessionResp.class); if (resp == null || resp.getOpenid() == null) { throw new BizException("微信登录失败,请重试"); } // openid 查用户表,不存在则创建新用户 Long userId = userMapper.selectOrCreateByOpenid(resp.getOpenid()); String token = jwtUtil.generateToken(userId, Duration.ofHours(2)); return ResponseEntity.ok(new AuthResponse(token)); }

code是一次性的,有效期约 5 分钟,而且只能使用一次,重复提交会返回错误。appSecret绝对不能出现在小程序端代码里,只能配置在后端,否则任何人拿到都能冒充你的小程序调用接口。openid是用户在某个小程序内的唯一标识,同一用户在不同小程序下 openid 不同,跨端统一需要依赖unionid,那需要在开放平台下绑定账号体系。后端生成的 JWT 建议设置 2 小时左右的有效期,配合小程序的静默续期机制,避免 token 被截获后长期有效。

3.2 扫码进入选座页:scene 参数解析

门禁旁边贴的小程序码,用户扫进来直接看到对应房间当天可选的座位。这里靠的是小程序码的scene参数。生成小程序码时把roomId、日期、时段编码进去,前端在onLoad里解析:

// pages/select-seat/index.js Page({ onLoad(options) { // 扫普通二维码时 roomId 直接通过 query 传入 // 扫小程序码时参数在 options.scene 里,且需要解码 const scene = decodeURIComponent(options.scene || ''); // 约定格式:room=ROOM-A100&date=2025-06-01&period=MORNING const params = scene.split('&').reduce((acc, item) => { const [k, v] = item.split('='); acc[k] = v; return acc; }, {}); this.setData({ roomId: params.room, bizDate: params.date, period: params.period }); this.loadSeatMap(params); }, async loadSeatMap(params) { const res = await wx.request({ url: `${getApp().globalData.baseUrl}/api/seat/plan`, data: { roomId: params.room, bizDate: params.date, periodNo: params.period } }); this.setData({ seatMap: res.data.data }); } });

scene参数有长度限制,只能承载可见字符,所以不要在scene里拼完整对象,传 ID 和枚举编码就够了。前端解析后立即用decodeURIComponent做一次解码,否则中文房间名会变成乱码。这里有个容易被忽略的点:options.scene只在扫小程序码时存在,从首页分享链接进入时scene为空,代码里要做默认值兜底,否则页面会白屏。

3.3 座位图渲染与重复提交拦截

座位图用最简单的view网格实现,不依赖 canvas。每个座位是一个矩形,颜色区分可约、已选、已约满,点击事件通过><view class="seat-grid"> <view wx:for="{{seatMap}}" wx:key="id" class="seat {{item.status === 0 ? 'available' : 'occupied'}} {{selected === item.id ? 'selected' : ''}}" >onSeatTap(e) { const { id, status } = e.currentTarget.dataset; if (status !== 0) return; this.setData({ selected: id }); }, async onSubmit() { if (this.data.submitting) return; this.setData({ submitting: true }); try { const res = await wx.request({ url: `${getApp().globalData.baseUrl}/api/seat/reserve`, method: 'POST', data: { seatPlanId: this.data.selected } }); if (res.data.code === 0) { wx.showToast({ title: '选座成功', icon: 'success' }); } } finally { this.setData({ submitting: false }); } }

座位图渲染时,不要用原生view的边框模拟桌子之间的通道,实际项目中走廊宽度不一,直接用百分比宽度加 margin 控制即可。submitting标志是为了防止用户连续点击提交按钮导致同一个座位被提交两次,前端拦截只是体验层优化,真正的防重复还得靠后端的锁和唯一索引。座位状态在用户停留在页面期间可能已经被别人抢走,提交时后端返回“座位不可预订”,前端应该重新拉取座位图而不是只弹一个错误提示。

4. 门禁对接与状态一致性:从凭证生成到一次开门

4.1 门禁对接的三种常见方式

门禁设备五花八门,对接方式主要分三类,选型时要看硬件本身支持什么协议,而不是先定技术栈。

对接方式适用硬件延迟离线能力开发量
HTTP 回调支持联网的智能门禁控制器
MQTT支持物联网协议的门禁网关弱网可用
蓝牙直连带 BLE 模块的一体门锁完全离线

最常见的做法是 HTTP 回调:门禁控制器扫码或刷脸后,把识别到的凭证上报到后端接口,后端校验权限后返回开门指令,门禁收到指令驱动电锁开门。这个模式开发量最小,但依赖网络,门禁设备离线时段内无法正常验证,需要配合本地白名单。MQTT 适合网关类设备,服务端直接发布开门主题,弱网环境下的消息重发机制比 HTTP 更成熟。蓝牙直连适合小范围部署,小程序端通过蓝牙与门锁通信,服务端只负责下发密钥,离线能力最强,但需要处理 iOS 和 Android 的蓝牙兼容性问题。

4.2 一次性开门凭证的生成与校验

在线场景下,用户在预约时段内点击“开门”,后端生成一个短期有效的凭证,门禁设备拿这个凭证到后端换开门指令。凭证生成逻辑:

public String issueOpenCode(Long reservationId) { Reservation r = reservationMapper.selectById(reservationId); LocalDateTime now = LocalDateTime.now(); // 状态必须是已预约,且当前时间必须在预约时段内 if (r.getStatus() != 0 || now.isBefore(r.getStartTime()) || now.isAfter(r.getEndTime())) { throw new BizException("当前不在可用时段"); } // 凭证内容:预约单号 + 随机串 + 过期时间戳 String nonce = UUID.randomUUID().toString() .replace("-", "").substring(0, 16); String payload = r.getOrderNo() + ":" + nonce + ":" + (now.plusSeconds(30).toEpochSecond(ZoneOffset.ofHours(8))); String code = hmacSign(payload, secretKey); // 凭证本身作为 key,value 存预约单号,30 秒后自动过期 redisTemplate.opsForValue().set( "door:code:" + code, r.getOrderNo(), Duration.ofSeconds(30)); return code; }

30 秒的有效期是权衡后的结果:门禁控制器从扫码到上报通常不超过 1 秒,30 秒足够完成整个验证流程,同时把重放攻击的时间窗压缩到最小。凭证不能直接使用预约单号明文,否则任何人拿着单号就能开门。这里的hmacSign用 HMAC-SHA256 对 payload 做签名,密钥只配置在服务端和门禁硬件的安全存储区。门禁设备上报凭证后,后端先查 Redis 里有没有这个 key,没有直接拒绝;有则进入下一步校验预约状态并触发开门指令,同时删除这个 key,保证一个凭证只能开一次门。

4.3 预约状态机的流转与超时释放

预约单的状态流转是门禁系统的核心约束。状态机只有四条合法路径:

  1. 已预约在预约时段内通过门禁校验,流转为已开门
  2. 已预约在开始时间之前由用户主动取消,流转为已取消
  3. 已预约在开始时间之后超过 15 分钟仍未开门,由定时任务流转为已取消,同时释放seat_plan的已约满状态;
  4. 已开门在预约结束时间到达后流转为已结束,座位自动释放。

超时释放这个动作容易被忽略。用户预约了上午 9 点到 12 点的座位,9 点 15 分还没到,这个座位如果继续锁到 12 点,上午就少卖一个时段。常见做法是每分钟跑一次定时任务,扫描reservation表中“已预约且开始时间超过 15 分钟”的记录,批量取消并释放座位。释放时要注意先更新reservation状态,再更新seat_plan状态,顺序反了会出现座位释放但预约单还挂着已预约的脏数据。

5. 并发、安全与上线前必查的配置项

5.1 座位并发抢占的三种锁怎么选

座位抢购是典型的并发写场景,可选方案有 synchronized、数据库行锁、Redis 分布式锁,以及乐观锁 version。四种方式的差异直接决定选型:

方案实现成本并发能力主要风险
synchronized单机有效多实例部署时失效
数据库行锁受数据库连接数限制长事务占用连接
Redis SetNX锁过期后锁被误删
乐观锁 version冲突后需要重试

单体部署且并发量不高时,synchronized 加在 service 方法上最省事,但一旦部署多个实例,锁就变成各锁各的,超卖问题立刻回来。数据库行锁select ... for update是兜底方案,任何分布式锁最终都要靠它保证数据一致性,缺点是事务时间长了会占用连接。Redis 分布式锁是中间态的最佳选择,并发能力和实现成本平衡得最好。乐观锁适合“失败的请求可以丢弃”的场景,比如点赞这种非关键操作;选座这种用户明确期待成功结果的场景,冲突后重试成本高,不如直接提示用户重新选择。

5.2 收敛 Actuator 暴露面,用抓包回放做一遍自测

Spring Boot 项目只要引入了spring-boot-starter-actuator,默认就会暴露部分端点。生产环境如果没收敛,/actuator/env/actuator/heapdump这类端口能直接读出环境变量和 JVM 内存快照,配置里的数据库密码、密钥全都在里面。这是实际发生过的未授权访问事故。

生产环境的配置至少要做到这样:

management: endpoints: web: exposure: include: health,info endpoint: health: show-details: never

include只保留healthinfoshow-details设为never,这样健康检查只返回 UP/DOWN,不暴露数据库连接池、磁盘空间等内部指标。如果确实需要监控大盘,走独立的监控端口加 IP 白名单,不要和业务端口混在一起。上线前自己用抓包工具(例如 Charles 或 Burp Suite)把小程序端的请求导出来,对开门接口和选座接口做一次回放测试,观察同样的请求发两次会不会产生两条预约单或两次开门。回放发现的问题,就是防重放逻辑该补的位置。

5.3 自定义导航栏的高度适配

微信小程序的导航栏在 Android 和 iOS 上高度不同,很多页面为了沉浸式体验选择自定义导航栏,这时顶部高度不能写死。最稳的获取方式是读取系统信息加胶囊按钮位置:

const { statusBarHeight } = wx.getSystemInfoSync(); const menu = wx.getMenuButtonBoundingClientRect(); // 胶囊按钮顶部到状态栏底部的距离,乘以 2 加上胶囊高度,就是导航栏总高 const navBarHeight = (menu.top - statusBarHeight) * 2 + menu.height;

状态栏高度在 iPhone 全面屏和普通屏之间差异明显,胶囊按钮的位置微信官方 API 可以直接拿到。这里算出的navBarHeight在自定义导航栏组件的 style 中作为 padding-top 使用。不要用固定值 64 或 88 去适配所有机型,新旧设备的刘海高度不同,一旦适配错位,页面顶部要么被刘海遮挡,要么按钮明显偏下。这套计算逻辑在不同机型上的一致性远好于硬编码。

6. 门禁回调的幂等与重放防御

6.1 门禁上报为什么必须防重

门禁设备在弱网环境下上报开门结果时,经常出现一次开门事件上报多次的情况。服务端如果收到一次就更新一次状态,问题不大,但如果是“开门成功事件”同时被用来抵扣次数或触发扣费,重复上报就会造成重复扣款。更严重的是重放攻击:攻击者截获一次合法的开门凭证请求,在凭证有效期内反复发送,就可能在非本人操作的情况下触发多次开门。门禁回调接口必须做两层防护:一是确认这次上报来自可信设备,二是同一个事件只能被处理一次。

6.2 时间戳 + nonce + 签名防重放落地

参考实现如下,直接放到门禁回调的入口处:

@PostMapping("/api/door/callback") public Result doorCallback(@RequestBody DoorEvent event) { // 1. 时间戳容差校验:设备时间和服务器时间不能偏差超过 5 分钟 long now = System.currentTimeMillis() / 1000; if (Math.abs(now - event.getTimestamp()) > 300) { return Result.fail("timestamp expired"); } // 2. 签名校验:secret 只配置在服务端和设备端 String sign = hmacSign(event.getTimestamp() + ":" + event.getNonce() + ":" + event.getOrderNo(), deviceSecret); if (!sign.equals(event.getSign())) { return Result.fail("invalid sign"); } // 3. nonce 防重放:同一个 nonce 5 分钟内只能消费一次 Boolean first = redisTemplate.opsForValue() .setIfAbsent("door:event:" + event.getNonce(), "1", Duration.ofMinutes(5)); if (!Boolean.TRUE.equals(first)) { return Result.ok("duplicated"); } // 4. 更新预约状态:已预约 -> 已开门 reservationMapper.updateStatusByOrderNo(event.getOrderNo(), 1); return Result.ok(); }

时间戳校验解决的是“过期请求重放”,超过 5 分钟容差直接拒绝,要求门禁设备必须做 NTP 时间同步,设备时间不准是这个方案最常遇到的坑。nonce 防重放解决的是“短时间重复上报”,同一个随机串 5 分钟内第二次到达直接返回 success,但不能修改状态;这里返回duplicated而不是fail,是因为门禁设备收到 fail 可能会继续重试,反而造成日志刷屏。签名使用设备维度独立的密钥,每个门禁控制器单独分配,设备被盗后可以只吊销一台的密钥而不影响其他门禁。这套设计同时覆盖了凭证校验和门禁回调两个入口,选座系统的最终一致性就落在这一层判断上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询