简介:一套基于Java技术实现的游泳馆管理系统课程设计项目,面向熟悉数据库原理与Java编程的在校生及初入行的开发者。系统覆盖会员管理、在线预约、场地调度与收费统计等核心模块,演示了从ER模型设计到业务逻辑落地的完整过程。资源包共1286个文件,约3.81MB,包含28个java与29个class源码文件、sql数据库脚本以及mdf/ldf数据库备份,另有大量htm页面和gif演示图,便于直接部署和对照界面理解功能。已有1534人学习下载。通过该资源可掌握会员等级设置、预约冲突处理、场地状态管理与多方式计费报表等关键实现,为课程设计、毕业设计或企业初版原型提供直接参考。
1. 游泳馆管理系统:不是订票软件,是一场场馆数字化改造
如果你以为游泳馆管理系统就是做个预约小程序、放几个二维码让顾客扫码进场,那大概率会把项目做成一个“漂亮的玩具”。真正让游泳馆老板愿意掏钱的系统,是能帮他解决三件事:高峰期前台排队堵成血栓、手环押金和超时费对不上账、私教课约了不来还找不到人。也就是说,这套系统的核心不是“在线订票”,而是“场馆运营的数字化骨架”——从会员入馆、闸机通行、更衣柜分配、淋浴计时,到离场结算、营收统计、教练排课,一整条业务链路都要在系统里闭环。适合谁做?适合那些已经接过类似“体育场馆管理系统”外包、手上有硬件资源(闸机、储物柜锁控板)或者有真实场馆运营方需求的团队。它比纯电商系统糙,但比进销存系统要“带物理设备”,调度和并发问题也更真实。
2. 业务流程先理清,再谈表结构:散客、会员年卡、培训课三种计费模型
2.1 游泳馆的业务流和订单流:从入场到离场要过几个状态
游泳馆和健身房最大的区别在于“计时计次”的复杂度。健身房一般按次卡或月卡进门即可,但游泳馆有散客计时(超时加收)、年卡不限次、次卡扣次数、培训课固定时段、团体包场等多种计费方式,而且这些计费方式可以叠加——比如一个学员既办了年卡,又报了暑期培训班,还买了私教课。系统如果按“一张卡一种规则”来做,第二天就会被场馆方的真实需求打脸。
我一般会先画一条“场内动线”:顾客到达 → 前台购票/验卡 → 过闸机入场 → 领手环/分配更衣柜 → 游泳/上课 → 淋浴 → 归还手环 → 离场结算(散客超时补费)。这个动线里,核心状态节点只有四个:已入场(IN)、在池(ACTIVE)、已归还(RETURNED)、已离场(OUT)。所有计费逻辑都围绕这四个状态切换展开,不要搞一堆花哨的“已预约”“已取消”“已过期”状态去干扰判断。
订单表的设计上,必须把“票券”和“订单”分离。票券是用户持有的权益(一张年卡、一张10次卡、一个散客入场券),订单是每次入场产生的消费记录。一个用户持年卡来游泳,入场动作生成一张订单(关联年卡券),离场时订单关闭。这样统计营收时看订单表,统计会员数时看票券表,两边互不污染。很多新手把票券信息直接冗余到订单表里,结果月底对账时怎么都对不上。
2.2 核心表结构设计:价格策略单独建表,别写死在代码里
价格策略是游泳馆管理系统最容易翻车的地方,因为场馆的计费规则经常变:暑期旺季散客 50 元/次,淡季 30 元/次;周一至周五上午 25 元/次(早场票),周六日全天 50 元/次;超时每小时加收 15 元,超过 30 分钟按一小时算;年卡售价 2800 元,但老会员续费打八折。这些规则如果写在代码里,每一次调价都要发版,运营人员会骂娘。我的做法是单独建一张price_policy表,把计费规则抽象成“时段 + 适用人群 + 时长+ 费用”的四元组。
-- 价格策略表:散客计费、次卡扣次、超时费都在这里配置 CREATE TABLE `price_policy` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `policy_name` VARCHAR(64) NOT NULL COMMENT '策略名,如:暑期散客票', `scene_type` TINYINT NOT NULL COMMENT '1-散客计时 2-次卡扣次 3-年卡不限次 4-超时补费', `applicable_days` VARCHAR(32) DEFAULT '1,2,3,4,5,6,7' COMMENT '适用星期,1-7', `time_start` TIME DEFAULT '00:00:00' COMMENT '时段开始', `time_end` TIME DEFAULT '23:59:59' COMMENT '时段结束', `base_fee` DECIMAL(10,2) DEFAULT 0 COMMENT '基础费用', `max_duration_minutes` INT DEFAULT 120 COMMENT '含时分钟数,超时另计', `overtime_fee_per_hour` DECIMAL(10,2) DEFAULT 0 COMMENT '超时每小时费用', `overtime_min_charge` INT DEFAULT 15 COMMENT '超时不足15分钟不收费', `sort_order` INT DEFAULT 0, `enable_flag` TINYINT DEFAULT 1, KEY `idx_scene_days` (`scene_type`, `applicable_days`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='价格策略表';这张表的核心逻辑是“按时间段匹配”。用户入场时,系统根据当前时刻的weekday()和TIME(NOW())去查这条策略表,命中后取出base_fee和max_duration_minutes。离场结算时,用“实际时长 - 含时分钟数”得到超时分钟数,再按overtime_min_charge取整计算超时费。这里有个隐藏设计点:超时费用必须走补费订单,不能直接改原订单金额,否则财务流水对不上。散客入场时收了 50 元,超时补收 15 元,这是两笔订单,打印小票时也分开打。把补费合进原订单,退款时就讲不清楚了。
另一个容易被忽略的表是member_card_consume_log(卡券消费流水)。次卡用户每次入场扣 1 次,但这个扣减必须发生在闸机放行之后,而不是用户扫码付款时。因为存在“扫了码但因为手环不够没进去”的情况,如果扫码就扣次,用户会找前台吵。所以卡券扣次要跟随“入场状态确认”事件,而不是“支付成功”事件。
2.3 计费生命周期管理:状态机随手环走,别用定时任务扫表
游泳馆有个很现实的场景:顾客拿着手环进了场,游了三个小时才出来。如果系统只在入场时记录enter_time,离场时简单算“当前时间 - 进入时间”,看起来没问题。但遇到跨天营业(比如凌晨场的场馆)、手环丢失、顾客中途出馆又返回,时间计算就会乱。更关键的是,超时计费的起点应该是“最后一次确认顾客还在场内”的时间,而不是入场时间。
我用的方案是“手环心跳 + 状态机更新”。每个手环绑定一个订单,顾客经过闸机、领取储物柜、淋浴区出门这几个物理节点时,读卡器都会上报一次手环 ID,系统把订单的last_confirm_time更新为当前时间。离场结算时,以last_confirm_time为计费终点(其实也就是离场时间本身)。这个设计看似多余,但它解决了一个血淋淋的坑:顾客入场时刷了卡,手环没戴好,出闸机时刷不到,前台只能手工补录离场时间,结果顾客硬说自己只游了一个小时。有了last_confirm_time,至少能证明他最后一次经过泳池区的时间。
订单状态机的迁移也建议用一张状态变更日志表来记录,order_id、from_status、to_status、changed_by(设备或操作员)、changed_at。方便事后审计,特别是顾客投诉“我明明没入场但你扣了钱”的时候,直接查日志比查订单表高效得多。这张表还有一种隐藏用途:统计每个顾客在泳池内的平均停留时长。游泳馆运营方非常看重这个指标——停留 60 分钟是合理游泳时长,停留 3 小时的多半是来蹭淋浴的,这类顾客占比过高说明淋浴区管理有漏洞。
3. 预约与计费的并发处理:一个 Redis 预扣模板解决超卖和重复支付
3.1 高峰期并发场景为什么会让普通 CRUD 系统崩掉
游泳馆管理系统高峰期集中在工作日晚 18:00-21:00 和周末全天。这时候会发生什么?前台两台收银机在打票,闸机口十几个人排队刷手环,更衣柜管理员的平板在不停刷新分配记录。如果系统的事务全部落在 MySQL 上,每秒可能只有几十个事务,正常情况下没问题,但问题是“散客超时补费”和“储物柜押金”这类操作会锁行。比如有 100 个人同时离场,每个人都在更新自己的订单状态,这没什么冲突;但如果是 30 个人同时去抢同一个更衣柜的“空闲”状态,那就是实实在在的行锁竞争。
更麻烦的是预约场景。暑期游泳培训班的课程名额有限,一个班 15 人,家长在小程序上同时抢课,如果控制不好,一个名额可能被 20 个人“预约成功”,最后到场 25 人,教练崩溃。解决这类问题,我不会把精力花在 MySQL 事务上,而是用 Redis 做预扣。Redis 的DECR原子操作天然适合这种库存扣减场景,配合 Lua 脚本还能做成“预扣 + 释放”的完整流程。
3.2 课程名额与时段人数的双重预扣:让 DB 只做落库,不做实时判断
// 课程预约预扣名额:Redis Lua 脚本,原子完成“检查余量 + 扣减” String luaScript = "local remaining = tonumber(redis.call('GET', KEYS[1]) or '0') " + "if remaining <= 0 then " + " return -1 " + "end " + "redis.call('DECR', KEYS[1]) " + "redis.call('SADD', KEYS[2], ARGV[1]) " + // 记录已预约用户ID "return remaining - 1";这段脚本的含义是:先取当前剩余名额,小于等于 0 直接返回 -1;否则执行DECR扣减,并把用户 ID 写进一个 Set,记录谁占了这个名额。返回值是扣减后的剩余数。这里有个细节:DECR之后立即把用户写进SADD,是为了防止同一个用户重复抢同一个课程。因为如果只扣名额不记用户,用户换个手机号再抢一次,名额就多扣了。KEYS[1]是课程库存键(如course:stock:1024),KEYS[2]是已预约用户集合键(如course:booked:1024),ARGV[1]是用户 ID。
数据库侧只需要接收“预扣成功”的消息,然后异步落库订单。预约表里加一个reserve_status字段:0-待支付,1-已确认,2-已取消。预扣后写入“待支付”订单,用户十分钟内付款则更新为“已确认”;超时未付,用延迟任务恢复 Redis 库存。这套流程下,MySQL 不需要任何SELECT ... FOR UPDATE,大幅降低锁竞争。
池同时在池人数控制也是同理。游泳馆有安全规范,比如标准池同时最多 200 人,超了要限流。我在 Redis 里维护一个pool:occupancy计数器,顾客入场时INCR,离场时DECR。这不仅用于安全限流,还用于前台展示“当前在池人数”,方便运营决定要不要卖票。这里要注意,闸机和人工通道都要走同一个计数器,否则人工带进来的散客不算人头,限流就成摆设了。
3.3 离场结算的异步化:为什么说同步算费是给自己挖坑
离场结算如果做成同步事务——查询订单、计算超时费、生成补费订单、更新状态、释放更衣柜——在高峰期会非常慢。因为涉及多张表的读写,还要和更衣柜控制板的硬件通信。我见过一个项目把更衣柜释放做成了同步调用,结果某个柜子的控制板没响应,等了 5 秒超时,整个离场结算失败,顾客在前台排了长队。
正确做法是“状态先行、算费异步”。顾客刷手环离场时,闸机系统只做一件事:把订单状态更新为OUT,记录leave_time,然后立刻放行。这一步只更新一条记录,耗时毫秒级。真正的费用计算、更衣柜释放、流水入账,推给消息队列异步处理。算错了怎么办?系统里设计一个“人工纠偏”入口,前台可以调出订单重新计算费用,生成差额退款或补费单。
这个异步化思路也适用于手环遗失。顾客弄丢手环,前台在系统里挂失,系统自动生成“手环赔偿 + 超时费”两张账单,同时释放原手环绑定的更衣柜。挂失操作不需要等柜锁控制板响应,先记账、再物理开锁,人工去确认柜子清空。如果同步等硬件响应,又会在高峰期卡死。
4. 闸机与更衣柜硬件联调:TCP 长连接、心跳保活和 SQLite 补传
4.1 硬件设备选型和通信边界:别指望 USB 转串口能用一年
游泳馆管理系统的硬件层主要有三类:闸机(摆闸/翼闸)、更衣柜锁控板、手环读写器(RFID)。这三个设备看似独立,实则共享同一个通信链路,而且它们的稳定性决定了整个系统在顾客眼中的口碑。设备通信的常见做法是用 485 转 TCP 网关,也就是把设备的 RS485 信号转换成网络信号,统一接入局域网。不要直接用 USB 转串口线接电脑,因为电脑重启后串口会掉、驱动会丢,前台电脑一旦蓝屏,整个场的闸机全瘫。
每个网关会挂载多台设备,通信协议通常是厂商自定义的命*令帧**格式。我一般会让硬件厂商提供一份通信协议文档,然后自己做网关层适配。这里有个血泪经验:不要同时开多个线程往同一个网关写指令。同一个网关下的所有设备共享一条 485 总线,两个线程并发写,总线数据会互相干扰,设备直接无响应。必须在程序里给每个网关维护一个写队列,所有指令串行发送。
设备连接状态要展示在前台界面上,用红绿灯标识——闸机离线、柜锁离线是运营事故级别的告警。我一般会专门做一张device_status表,记录每台设备的last_heartbeat_time,超过 20 秒未收到心跳就标为离线。
4.2 一个稳定的设备通信服务:串行写队列 + 心跳自动重连
// 设备通信管理器:每个网关一个独立连接,写指令全部走串行队列 public class DeviceGatewayManager { private final Map<String, DeviceConnection> connectionMap = new ConcurrentHashMap<>(); private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4); public void sendCommand(String gatewayIp, byte[] command) { DeviceConnection conn = connectionMap.get(gatewayIp); if (conn == null || !conn.isAlive()) { conn = reconnect(gatewayIp); // 断线自动重连,重试3次 connectionMap.put(gatewayIp, conn); } conn.enqueue(command); // 串行发送,避免485总线冲突 } public void startHeartbeat() { // 每10秒向所有网关发送心跳探测指令,连续3次无响应则标记离线 scheduler.scheduleAtFixedRate(() -> { connectionMap.forEach((ip, conn) -> { if (!conn.isAlive()) { reconnect(ip); } }); }, 10, 10, TimeUnit.SECONDS); } }这段代码的背后逻辑是每个网关对应一个DeviceConnection对象,内部维护一个写队列和一个 Socket 连接。所有要发给该网关的指令都进入队列,由单线程消费者逐条发送,确保 485 总线上不会出现并发写。reconnect方法负责断线重连,但要注意重连不能无限循环,否则网关坏掉时程序会空转。常规做法是连续重试 3 次、间隔 2 秒,仍然失败就把事件写入告警表,并在前端弹红点。
硬件通信最容易被低估的是响应超时。设备执行一条“开锁”指令可能需要 1-2 秒(机械结构动作慢),程序如果等 500 毫秒没收到响应就报错,那这个系统一天能报几百次错。我一般会把设备响应超时设为 5 秒,同时把“指令已发送”和“设备已响应”两个事件分开记录。顾客看到的是“开锁中…”转圈 2 秒然后打开,完全可以接受。
4.3 闸机离线时的本地缓存:SQLite 临时存储 + 恢复后补传
场馆网络从来不是 100% 稳定的。路由器和交换机过热重启、物业拉闸检修、运营商光缆被挖断,这些我都遇到过。闸机如果直接依赖服务器,网络一断就罢工,场馆门口排队的人能把前台玻璃挤碎。所以闸机端必须能离线独立运行,本地缓存通行记录,恢复联网后再补传。
我这边的做法是闸机程序内置一个 SQLite 数据库,入场记录先写本地,同时异步同步到云端(或本地服务器)。离线时记录累积在 SQLite 里,网络恢复后按时间顺序补传。补传最怕的是“重复”,所以每条记录要有一个全局唯一 ID(UUID 或雪花 ID),云端接口做幂等校验——同一 ID 只入库一次。如果云端没做幂等,断网 5 分钟、补传 200 条记录,里面可能有一半是重复的,会员的入场次数直接翻倍。
4.4 更衣柜分配的三种模式:固定柜、随机柜、临时柜
更衣柜子系统的核心矛盾是“柜子数量少于在池人数上限”。游泳馆不可能给每个顾客配一个固定柜,所以分配逻辑必须支持三种模式:固定柜(长租给会员,年费 600 元)、随机柜(散客当天分配)、临时柜(手环直接挂门上防水袋里,不额外收费)。固定柜和随机柜的物理锁不同——固定柜用机械钥匙或独立密码锁,随机柜用电控锁接系统。
随机柜的分配接口要注意一个并发边界:前台或顾客小程序同时发起开柜请求,系统先查 Redis 里的“空闲柜队列”,弹出一个柜号,标记为预占,再下发开锁指令到锁控板。预占状态必须有过期时间,比如 3 分钟内顾客没走到柜子前刷手环开锁,预占就释放回空闲队列。不然顾客开了柜又关掉走人,这个柜子就成了僵尸资源,永远被占用。这个过期机制用 Redis 的SETEX或SET key val EX 180就能实现。
5. 游泳馆管理系统的 5 个高频踩坑点与排查思路
5.1 闸机离线恢复后补传数据把计次卡扣成负数
现象:场馆网络恢复后,闸机自动补传了离线期间的上百条通行记录,结果一批次卡用户的剩余次数变成负数,前台被投诉淹没。 原因:补传记录和线上实时记录发生了重复。闸机本地 SQLite 里有一条记录,云端也收到过一次,但云端接口没有做幂等校验,同一 UUID 入账两次。 解决:所有设备上报数据入口统一做幂等处理。用记录的唯一 ID 做主键或唯一索引,INSERT ... ON DUPLICATE KEY UPDATE保证重复 ID 不再扣次。另外给卡券扣次逻辑加“余额不足则拒绝”的保护,宁可拒绝入场也不能把次数扣成负数。
5.2 散客手环超时未归还,账上显示“在池”,人其实早走了
现象:顾客拿了手环进池,游完直接把手环带走或扔在淋浴区,前台没做归还登记,系统一直显示该顾客“在场内”。 原因:手环归还依赖前台人工操作,漏刷了就漏了。没有离场超时保护机制。 解决:增加“超时未归还自动挂起”逻辑。入场后超过 6 小时(可配置)没有离场记录,订单自动置为“挂起”,手环作废,押金自动转为赔偿金。同时保留人工补录通道,顾客拿手环回来也能正常退押金。
5.3 更衣柜预占用过期释放,但顾客还在池子里游
现象:顾客扫码开了柜门,放完东西进池,但锁控板响应慢,预占 3 分钟过期,柜子被分给下一个人。第二位顾客打开柜门,发现里面有别人的衣服。 原因:预占释放逻辑只看 Redis 过期时间,没有和“手环入场状态”联动。顾客已经入场,他的柜子就不该被释放。 解决:预占释放前先查订单状态。如果手环已入场(订单状态为 ACTIVE),则预占自动续期;只有订单未确认入场时才做释放。这个查询可以放在锁控板开锁之后触发,不需要额外加定时任务。
5.4 数据库连接池被打满,一到高峰期系统卡成PPT
现象:高峰期所有操作都变得很慢,数据库 CPU 不高但show processlist里有大量Sleep状态的连接。 原因:代码里写了new Connection()忘记关闭,每个请求泄漏一个连接。也可能是 Redis 操作超时后,代码没有走降级逻辑,一直在等。 解决:全局统一用连接池(HikariCP),配置maximumPoolSize=20、minimumIdle=5、connectionTimeout=3000。代码里禁止手动new Connection,全部走try-with-resources。Redis 操作加超时(set timeout 500),超时后走 DB 降级,先保证核心流程不中断。
5.5 计费策略改了但订单还是按旧价格算
现象:运营在后台把暑期散客价从 50 改到 60,但新订单仍然按 50 结算。 原因:价格策略表用了缓存,但缓存更新不及时。或者订单表的unit_price字段是在入场时冗余的,后续策略修改不影响已生成的订单。 解决:区分“订单生成时快照”和“策略实时查询”。已生成的订单必须保留入场时的价格快照(这是正确的),但策略修改后新的订单必须走新价格。排查时先看缓存 TTL 设置,再看订单表的价格来源字段。如果订单表价格来自冗余字段,确认入场服务有没有拿到最新策略——重点查分布式缓存有没有做EVENT失效通知。
6. 报表与能耗监测:让数据替场馆运营做决策
系统稳定运行后,游泳馆老板真正关心的是三张报表:营收流水表(每日/每周/每月对比)、客流时段分布图(几点人最多、星期几最空)、会员转化漏斗(散客多久转化为年卡会员)。这些数据在订单表和票券表里都有,难的是怎么展示。我不建议在管理后台里堆一堆 ECharts 折线图,而是做一个“每日运营早报”推送给馆长——早上 9 点推送昨日营收、客流量、同比环比、今日预约数。馆长不需要打开系统,只看一条消息就知道今天该怎么排班。
能耗监测是很多人忽略的加分项。游泳馆的循环水泵和恒温加热是电费大头,系统如果能接入电表数据,把“营收/耗电”比值做成 KPI,就能帮场馆发现“营收涨了但利润没涨”的问题。做法上不复杂——在总电表和主要设备电表上加带 Modbus RTU 协议的智能电表,网关定时拉取数据写入时序表,按小时汇总。这套东西做出来,馆长会觉得你的系统不止是收银工具,而是经营参谋。
关于游泳馆管理系统的项目交付,我个人的经验是:先做能抗住高峰期的骨架,再谈报表和智能硬件的花活。很多时候问题不是功能不够,而是高峰期闸机一卡、全盘皆输。项目做完、系统稳定跑过三个月的暑期高峰之后,你再回头优化算法、调整页面都来得及。希望这个方向的经验能帮你在接这类项目时少走点弯路。
本文还有配套的精品资源,点击获取