☰
游泳馆管理系统开发实战:从扫码核销到闸机开闸的完整链路
2026/9/29 1:51:19 网站建设 项目流程

简介:这是一份面向高校计算机与软件工程专业学生的Java数据库课程设计资源,以游泳馆日常运营为业务背景,帮助学习者把数据库原理与Java开发落到一个完整可运行的管理系统上。系统覆盖会员管理、预约管理、场地管理与收费管理四大模块,涉及会员等级与消费记录分析、预约冲突处理与状态实时更新、泳道及更衣室等设施调度、多种支付方式记账与财务报表统计,并贯穿ER模型设计、实体属性关系建模等数据库核心知识点。资源包共1286个文件,以1132个htm页面、63个gif素材、29个class与28个java源码为主,另含js脚本、dll与jar依赖、sql建库脚本及mdf、ldf数据库文件,压缩包约3.81MB,目录结构完整,便于按模块查阅源码与页面。目前已有1533人学习下载,适合作为课程设计参考、Java与数据库综合练习或二次开发的基础工程。

1. 游泳馆管理系统:从手写登记到扫码核销,一套系统到底要管什么

夏天高峰期,前台两个工作人员,一个接电话一个收现金,手写登记本上密密麻麻的名字和手牌号,会员来了翻半天找不到记录,散客排队排到门口,更衣室柜子钥匙对不上号,月底老板问“这个月卖了多少次卡、哪个时段人最多”,没人答得上来。这不是段子,是大多数中小游泳馆的真实状态。游泳馆管理系统要解决的核心问题就三件事:把入场核销从纸面搬到线上、把会员卡和次卡的钱算清楚、把泳池的水质和救生员排班纳入日常记录。它适合单店 200 到 2000 平米、日均客流 100 到 800 人的场馆,也适合连锁 3 到 5 家店需要统一后台的运营方。这套系统不是简单的收银软件,它同时踩在票务、会员、硬件闸机、水质监测四条线上,选型和落地都有讲究。下面按“先想清楚管什么、再动手搭最小可用版本、最后避坑”的顺序讲透。

2. 需求拆解与选型:别一上来就买成品 SaaS

2.1 先画业务流,再决定买还是自己搭

很多馆主第一反应是买个现成的收银系统,结果发现闸机对接不了、次卡核销逻辑对不上、水质记录模块根本没有。我一般建议先用一张纸把三条主线画出来:入场线(散客买票→扫码→闸机开→入场)、会员线(办卡→充值→扣次/扣时长→到期提醒)、运营线(水质检测→投药记录→救生员排班→交接班)。画完之后你会发现,入场线和会员线是强关联的,运营线相对独立但涉及合规检查。

选型上分三种情况。第一种,纯散客、无会员、无闸机,日均 100 人以下,直接用微信/支付宝收款码加一个 Excel 台账就够了,上系统是浪费。第二种,有会员次卡、有闸机、单店经营,建议用轻量级自研或开源方案二次开发,因为成品 SaaS 的闸机对接往往要额外收费且不灵活。第三种,连锁多店、有线上购票小程序、需要总部看板,这时候才值得考虑成熟的商业 SaaS,但一定要确认它开放 API 或者至少支持标准闸机协议。

提示:选型前先确认你的闸机品牌和通信协议。常见的是韦根 26/34 协议和 RS485 串口,如果闸机只支持韦根,那软件端必须能输出韦根信号或者通过控制器中转,这一点直接决定你能不能自己搭。

2.2 技术栈怎么选:一张表看清三种路线

路线适用场景技术栈建议开发周期月成本估算
Excel + 收款码日均<100人,无会员无00
轻量自研单店,有会员和闸机Python FastAPI + SQLite/PostgreSQL + Vue2-4周服务器 50-100元
商业 SaaS连锁,多店统一管理按厂商方案1-2周部署300-2000元

自研路线的核心模块不多:会员表、卡类型表、消费记录表、闸机控制接口、水质记录表。用 FastAPI 是因为它写接口快、自带文档、和硬件串口库配合也方便。前端用 Vue 或者直接服务端渲染都行,前台操作界面越简单越好,最好三个按钮搞定入场。

2.3 数据库表设计:四张核心表撑起整个系统

-- 会员表:一个会员可以有多张卡 CREATE TABLE member ( id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL, phone VARCHAR(20) UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); -- 卡类型表:次卡、时长卡、储值卡 CREATE TABLE card_type ( id SERIAL PRIMARY KEY, type_name VARCHAR(30) NOT NULL, -- '次卡','月卡','储值卡' total_times INT, -- 次卡总次数,其他类型为NULL price DECIMAL(10,2) NOT NULL, valid_days INT -- 有效天数 ); -- 会员卡表:会员持有的具体卡实例 CREATE TABLE member_card ( id SERIAL PRIMARY KEY, member_id INT REFERENCES member(id), card_type_id INT REFERENCES card_type(id), remaining_times INT, -- 剩余次数 balance DECIMAL(10,2), -- 储值余额 start_date DATE NOT NULL, end_date DATE, status VARCHAR(10) DEFAULT 'active' -- active/expired/frozen ); -- 入场记录表:每次核销写一条 CREATE TABLE entry_log ( id SERIAL PRIMARY KEY, member_card_id INT REFERENCES member_card(id), entry_time TIMESTAMP DEFAULT NOW(), gate_id VARCHAR(20), -- 哪个闸机 operator VARCHAR(30) -- 操作员 );

这四张表的关系是:会员办卡时在 member_card 里插一条记录,每次入场先查 member_card 的剩余次数和有效期,扣减后写 entry_log。储值卡扣的是 balance,次卡扣的是 remaining_times。注意 end_date 为空表示长期有效,但次卡一般也要设有效期,防止一张卡用五年。

参数说明:remaining_times 用 INT 而不是 BOOLEAN,因为次卡可能一次买 50 次;balance 用 DECIMAL 不用 FLOAT,金额计算不能有浮点误差;entry_log 的 gate_id 用于排查“明明刷卡了闸机没开”的问题。

3. 最小可用版本搭建:从扫码到开闸的完整链路

3.1 环境准备与项目骨架

# 创建项目目录 mkdir swimming-pool-system && cd swimming-pool-system python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 安装核心依赖 pip install fastapi uvicorn sqlalchemy psycopg2-binary pyserial qrcode pillow

pyserial 是用来控制闸机的,如果你的闸机走网络 TCP 就换成 socket 库。qrcode 用来生成会员码,前台扫码枪扫的就是这个码。数据库用 PostgreSQL,SQLite 也能跑但并发写入容易锁表,高峰期 200 人同时入场会出问题。

项目结构建议:main.py 放 FastAPI 入口,models.py 放 SQLAlchemy 模型,gate.py 封装闸机控制,templates/ 放前台页面。不要一上来就搞微服务,单店系统一个进程足够。

3.2 会员核销接口:扣次、扣费、开闸三步走

from fastapi import FastAPI, HTTPException from sqlalchemy.orm import Session from datetime import date import serial app = FastAPI() # 闸机串口初始化,根据实际设备改端口和波特率 gate_serial = serial.Serial('/dev/ttyUSB0', 9600, timeout=1) @app.post("/api/entry/{card_id}") def entry(card_id: int, gate_id: str = "gate_01"): db = Session() card = db.query(MemberCard).filter(MemberCard.id == card_id).first() if not card: raise HTTPException(404, "卡不存在") if card.status != 'active': raise HTTPException(400, "卡已冻结或过期") if card.end_date and card.end_date < date.today(): raise HTTPException(400, "卡已过期") # 次卡扣次,储值卡扣费,时长卡不扣 if card.remaining_times is not None: if card.remaining_times <= 0: raise HTTPException(400, "次数已用完") card.remaining_times -= 1 elif card.balance is not None: if card.balance < 30: # 假设单次入场30元 raise HTTPException(400, "余额不足") card.balance -= 30 # 写入场记录 log = EntryLog(member_card_id=card_id, gate_id=gate_id) db.add(log) db.commit() # 发送开闸信号,韦根协议一般是发一串十六进制 gate_serial.write(b'\x01\x02\x03') # 具体指令看闸机手册 return {"status": "ok", "remaining": card.remaining_times, "balance": float(card.balance or 0)}

逻辑说明:先校验卡状态和有效期,再根据卡类型决定扣次还是扣费,最后写记录并开闸。注意扣减和写记录必须在同一个事务里,否则扣了次但记录没写,对账时就是一笔糊涂账。开闸信号放在 commit 之后,万一开闸失败至少钱扣了有记录可查。

参数说明:gate_serial 的端口和波特率必须和实际设备一致,韦根协议通常需要额外的控制器把串口信号转成韦根,直接用串口发指令只适用于支持串口控制的闸机。单次入场价格 30 元是示例,实际按场馆定价改。

3.3 前台扫码页面:三个按钮搞定入场

<!DOCTYPE html> <html> <head><meta charset="utf-8"><title>入场核销</title></head> <body> <h2>游泳馆入场核销</h2> <input id="cardInput" placeholder="扫码或输入卡号" autofocus> <button onclick="doEntry()">确认入场</button> <div id="result"></div> <script> async function doEntry() { const cardId = document.getElementById('cardInput').value.trim(); if (!cardId) return; const res = await fetch(`/api/entry/${cardId}`, {method: 'POST'}); const data = await res.json(); const el = document.getElementById('result'); if (res.ok) { el.innerHTML = `<p style="color:green">入场成功,剩余次数:${data.remaining ?? '不限'},余额:${data.balance}</p>`; } else { el.innerHTML = `<p style="color:red">失败:${data.detail}</p>`; } document.getElementById('cardInput').value = ''; document.getElementById('cardInput').focus(); } </script> </body> </html>

这个页面故意做得极简,因为前台高峰期没时间点来点去。扫码枪本质上是一个键盘输入设备,扫完自动回车触发 doEntry。autofocus 保证光标始终在输入框,扫完一张卡立刻能扫下一张。结果区域用颜色区分成功失败,前台一眼就能判断。

注意:扫码枪的输入速度很快,如果页面有防抖逻辑要关掉,否则会漏字符。另外卡号建议用纯数字,避免扫码枪把字母识别错。

3.4 水质记录模块:合规检查要能导出

@app.post("/api/water-quality") def add_water_record(ph: float, chlorine: float, temp: float, operator: str): # pH 标准 7.2-7.8,余氯 0.3-1.0mg/L,水温 26-28度 warnings = [] if not (7.2 <= ph <= 7.8): warnings.append(f"pH异常:{ph}") if not (0.3 <= chlorine <= 1.0): warnings.append(f"余氯异常:{chlorine}") record = WaterQuality(ph=ph, chlorine=chlorine, temp=temp, operator=operator) db.add(record) db.commit() return {"status": "ok", "warnings": warnings}

水质记录看起来简单,但卫生监督所检查时要看连续记录,缺一天就要写说明。所以这个接口要加一个定时任务,每天固定时间提醒值班人员录入。warnings 字段直接返回给前台,超标了立刻处理,别等检查时才发现。

4. 避坑与排查:上线后最容易翻车的五个地方

4.1 闸机开了但系统没扣次

现象:会员刷卡后闸机开了,但后台查剩余次数没变。原因:开闸信号和数据库 commit 的顺序反了,先开闸后 commit,commit 失败时闸已经开了。解决:严格按“校验→扣减→commit→开闸”的顺序,commit 失败就不发开闸信号。如果闸机支持双向通信,最好等闸机返回“已开”再写记录。

4.2 高峰期扫码枪漏字符

现象:排队人多时,扫码枪扫了但输入框只显示一半卡号。原因:页面有输入防抖或者扫码枪回车触发太快,上一次请求还没返回就扫了下一张。解决:去掉所有防抖逻辑,请求改成同步或者加一个“处理中”状态锁,锁住期间不接受新输入。另外扫码枪本身可以设置字符间隔,调慢一点。

4.3 次卡扣次和时长卡混淆

现象:月卡会员入场一次扣了一次次数,月底发现次数扣完了。原因:卡类型判断逻辑写成了 if remaining_times is not None,但月卡的 remaining_times 字段没设成 NULL 而是 0。解决:建卡时严格按类型赋值,次卡设次数,月卡和储值卡的 remaining_times 必须为 NULL,判断时用 is not None 而不是 > 0。

4.4 数据库连接池耗尽

现象:下午高峰期系统卡死,报错“too many connections”。原因:每个请求都新建 Session 没关闭,连接池默认 5 个连接很快用完。解决:用 FastAPI 的依赖注入管理 Session,或者用 context manager 确保每个请求结束后 close。PostgreSQL 默认最大连接 100,但应用层连接池设 10-20 就够了。

4.5 水质记录补录导致时间戳混乱

现象:检查前补录前几天的水质数据,导出报表时时间顺序乱了。原因:created_at 用了默认 NOW(),补录时插入的是当前时间。解决:水质表加一个 record_date 字段,补录时手动指定日期,报表按 record_date 排序而不是 created_at。

5. 进阶技巧:用入场数据反推运营策略

系统跑起来之后,entry_log 表就是一座金矿。我一般会加一个简单的统计接口,按小时聚合入场人数,跑一周就能看出高峰时段。比如数据显示 18:00-20:00 入场人数占全天的 45%,那救生员排班就要重点覆盖这个时段,而不是平均分配。再进一步,把会员卡的到期时间和剩余次数结合,筛选出“30 天内到期且剩余次数大于 10 次”的会员,前台可以定向提醒续卡,转化率比群发短信高得多。

@app.get("/api/stats/hourly") def hourly_stats(date_str: str): # 按小时统计入场人数 rows = db.execute(""" SELECT EXTRACT(HOUR FROM entry_time) AS hour, COUNT(*) AS cnt FROM entry_log WHERE entry_time::date = :d GROUP BY hour ORDER BY hour """, {"d": date_str}).fetchall() return [{"hour": int(r[0]), "count": r[1]} for r in rows]

这个查询在 entry_log 数据量到十万级时开始变慢,加一个 entry_time 的索引就能解决。如果连锁多店,把 gate_id 也加进 GROUP BY,就能对比不同门店的客流曲线。

另一个实用技巧是给闸机加一个“反向核销”按钮。会员出场时再刷一次卡,记录离场时间,这样能算出平均停留时长。停留时长超过 3 小时的要留意,可能是教练在私下带课,也可能是设备故障导致重复入场。这些细节不一定要马上做,但表结构里预留 exit_time 字段,以后想加随时能加。

我自己踩过最深的坑是早期没做操作日志,前台误操作把会员卡删了,查不到是谁干的。后来加了 operator 字段和软删除,所有删除都是标记 status='deleted',数据还在,只是不显示。这个习惯救过我好几次,希望帮到你。

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

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

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

立即咨询