24小时无人健身房系统开发落地方案:门禁、计费与IoT联动的Java实战

“无人”不等于“没人管”,而是把前台、核销、计费、巡场全部转成系统闭环。24小时自助健身房真正的技术重心,不在小程序页面多好看,而在三件事:门禁能不能可靠开、计时计费准不准、断网异常会不会资损。本文基于 Spring Boot + MQTT 落地一套可生产的架构方案。
一、系统定位与分层架构
无人健身房是“交易面 + 控制面 + 感知面”的三平面系统:
- 交易面:购卡、次卡、月卡、订单、结算(强一致)
- 控制面:闸机、门禁、灯控、空调、储物柜(高可用)
- 感知面:AI摄像头、人数统计、器械占用、SOS告警(最终一致)
技术分层:
小程序/H5 ──► Gateway(JWT限流)
│
┌───────┴────────┐
业务服务层(Spring Boot) IoT接入层(MQTT/EMQX)
会员/订单/计费/权限 设备网关/边缘计算
│ │
MySQL + Redis 门禁/闸机/灯控/传感器
技术栈:Spring Boot + MyBatis-Plus + MySQL 5.7(分表)+ Redis 7 + RabbitMQ + EMQX(MQTT Broker)。

二、核心业务模型
精简版核心表:
— 会员卡(次卡/时段卡/月卡)
CREATE TABLE member_card (
id BIGINT PRIMARY KEY,
user_id BIGINT,
card_type TINYINT COMMENT '1次卡 2时段卡 3月卡 4储值',
remain_times INT DEFAULT 0,
start_time DATETIME,
expire_time DATETIME,
freeze TINYINT DEFAULT 0,
version INT DEFAULT 0
);
— 健身会话订单
CREATE TABLE gym_session (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32) UNIQUE,
user_id BIGINT,
store_id BIGINT,
enter_time DATETIME,
exit_time DATETIME,
duration_min INT,
amount DECIMAL(10,2),
status TINYINT COMMENT '0待入场 1在场 2已离场 3已结算 4异常'
);
人脸数据只存特征哈希,不存原图,符合个人信息保护底线。
三、门禁闭环:业务校验 + 一次性凭证 + 设备回执
扫码/刷脸入场不能“前端验一下就开门”,必须走三层:
public Result requestEntry(Long userId, Long storeId) {
MemberCard card = cardMapper.selectValid(userId, storeId);
if (card == null || card.getFreeze() == 1)
return Result.fail("无有效入场权限");
if (card.getCardType() == 1 && card.getRemainTimes() <= 0)
return Result.fail("次卡余额不足");
// 一次性凭证
String ticket = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("entry:ticket:" + ticket,
userId + ":" + storeId, 60, TimeUnit.SECONDS);
// 下发MQTT(带commandId防重复)
String cmd = buildCmd("open_door", ticket);
mqttGateway.publish("gym/device/" + storeId + "/cmd", cmd);
return Result.success("正在开门");
}
MQTT 网关发指令(带唯一 commandId 匹配回执):
public void publish(String topic, String payload) {
MqttMessage msg = new MqttMessage(payload.getBytes(StandardCharsets.UTF_8));
msg.setQos(1); // 至少一次 + 业务幂等
mqttTemplate.publish(topic, msg);
}
设备回执监听:
@MqttSubscribe(topic = "gym/device/+/event")
public void onAck(MqttMessage msg) {
EntryEvent event = JSON.parseObject(msg.toString(), EntryEvent.class);
if ("door_opened".equals(event.getAction())) {
// 开门成功 → 创建在场会话
sessionService.createSession(event.getTicket());
cardService.deductOnce(event.getTicket());
}
}
断网时边缘网关缓存权限快照,本地核验开门,恢复后补传事件。
四、订单状态机:消灭“幽灵计费”
最经典的坑:用户进场后没刷闸机离开,订单一直 在场 持续扣钱。
状态机收敛:
public enum SessionState {
WAIT_ENTER, IN_GYM, LEFT, SETTLED, ABNORMAL;
public boolean canTransit(SessionState next) {
return switch (this) {
case WAIT_ENTER -> next == IN_GYM || ABNORMAL;
case IN_GYM -> next == LEFT || ABNORMAL;
case LEFT -> next == SETTLED;
default -> false;
};
}
}
离场逻辑:
@Transactional
public void leave(Long sessionId) {
GymSession s = sessionMapper.selectForUpdate(sessionId);
if (!s.getStatus().canTransit(LEFT))
throw new BizException("状态不允许离场");
s.setExitTime(LocalDateTime.now());
s.setDurationMin((int) Duration.between(s.getEnterTime(), s.getExitTime()).toMinutes());
s.setStatus(LEFT);
sessionMapper.updateById(s);
// 投消息进结算队列
mqSender.sendSettleEvent(s.getOrderNo());
}
夜间巡检任务:结合门磁 + 摄像头人数,发现“订单在场但场内无人”自动置 ABNORMAL 待人工复核。
五、分时计费引擎:策略模式 + 服务端计时
计费绝不能用前端时间。服务端以 UTC 时间为唯一基准,规则可配置:
public interface FeeStrategy {
BigDecimal calc(SessionContext ctx);
}
@Component
public class TimeSlotStrategy implements FeeStrategy {
@Override
public BigDecimal calc(SessionContext ctx) {
// 从配置中心读时段单价:早鸟/高峰/平峰
int minutes = ctx.getDurationMin();
BigDecimal rate = ruleMapper.rateOf(ctx.getStoreId(), ctx.enterLocalTime());
return rate.multiply(BigDecimal.valueOf(minutes))
.divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
}
}
支持叠加规则:
- 基础按时长
- 高峰时段附加费
- 月卡免计费(直接跳过扣款)
- 封顶价(单日最多扣 39 元)
结算服务消费 MQ:
@RabbitListener(queues = "gym.settle")
public void settle(SettleEvent e) {
String idem = "settle:done:" + e.getOrderNo();
if (Boolean.TRUE.equals(redisTemplate.hasKey(idem))) return;
BigDecimal fee = feeEngine.calc(e);
accountService.charge(e.getUserId(), fee);
sessionMapper.markSettled(e.getOrderNo(), fee);
redisTemplate.opsForValue().set(idem, "1", 7, TimeUnit.DAYS);
}
钱相关全用 BigDecimal,禁用 double。
六、设备联动:人进灯亮,人走断电
不是每个设备都实时同步,控制面走 MQTT 异步:
- 入场成功 → 发 light:on / ac:on
- 最后一人离场 → 延时 5 分钟关灯关空调
- 门磁异常/烟雾/SOS → 高级告警,声光+推送运维
public void onLastPersonLeft(Long storeId) {
mqttGateway.publish("gym/device/" + storeId + "/cmd",
"{\\"action\\":\\"light_off\\",\\"delay\\":300}");
}
心跳保活:
@MqttSubscribe(topic = "gym/device/+/heartbeat")
public void heartbeat(DeviceHeart h) {
redisTemplate.opsForValue().set("dev:online:" + h.getSn(),
"1", 180, TimeUnit.SECONDS);
deviceStatusMapper.upsert(h);
}
超过 3 次心跳丢失 → 设备离线告警,运营大屏标红。

七、防超卖与并发细节
次卡扣减必须乐观锁,防止并发重复消次:
<update id="deductOnce">
UPDATE member_card
SET remain_times = remain_times – 1,
version = version + 1
WHERE id = #{id}
AND remain_times > 0
AND version = #{version}
</update>
扣减失败抛异常,回滚会话创建,避免“门开了但没扣次”。
开门接口限流:
// 单用户 1s 最多 3 次开门请求
if (redis.incr("rate:entry:" + userId) > 3) return Result.fail("操作频繁");
八、安全与容灾底线
- AB门防尾随:A门开→确认单人→B门开,两道互锁
- 双因子:动态码 + 人脸活体,防截屏代刷
- 断网逃生:门禁本地缓存 7 天有效会员,4G 备份链路
- 审计日志:每次开门/计费/异常都落 trace 表,财务可对账
- 隐私:人脸特征哈希化,原始视频本地加密,云端只拿事件
九、小程序端关键页面
用户端:
- 附近门店(实时在场人数)
- 购卡/次卡/月卡
- 一键开门(码+人脸)
- 当前会话计时与预估费用
- 历史账单、异常申诉
运营端:
- 门店设备状态大屏
- 异常订单复核台
- 告警中心(门磁/离线/SOS)
- 分时段营收报表

十、落地节奏
别一上来就接支付分账和AI行为分析。先把“开门可靠、计时准确、异常可追”三件事做硬,再谈智能化。
24小时无人健身房的系统价值,是把“信任”写进代码里:用户敢刷脸进,商家不怕跑单,半夜断网也能开门、恢复后能对账。状态机锁流程、MQTT 解耦硬件、服务端单一计时基准,这三根柱子立住,产品才真的“无人可跑”。


