
简介面向Java课程设计与数据库实践学习者的一份游泳馆管理系统项目涵盖会员管理、预约管理、场地管理、收费管理等核心业务模块可帮助读者理解真实业务系统的需求分析与数据库建模也适合作为课程设计参考或二次开发基底。压缩包内共有一千二百八十六个文件以HTML网页、Java源码、编译后的Class文件及GIF图片为主另附SQL数据库脚本与MDF、LDF数据库文件便于直接运行或导入数据库学习整包体积仅三点八一兆字节。目前平台显示已有一千五百三十四人学习浏览。项目重点实现了预约冲突检测、会员等级划分、多支付方式记账等典型功能同时提供图形界面源码读者可对照程序理解JDBC连接、ER模型设计及前端交互文件目录按模块分类清晰便于逐个查阅是巩固数据库原理和Java工程能力的实用资料。1. 游泳馆管理系统为什么它是中小场馆最值得落地的数字化切入点游泳馆和其他业态最大的区别在于它的收入高度依赖时段和物理空间。同一个泳池上午可能只有几个人在游晚上高峰期却要在岸边排队私教课占用的泳道和散客是冲突的票务、次卡、月卡、培训班的计费规则又完全不一样。市面上很多通用会员管理系统根本扛不住这种场景管着管着账就对不上了。游泳馆管理系统要解决的正是场馆排课、入场核销、票卡计费和设备状态串联这件事。这套系统适合谁做呢——不是要做成互联网大厂级别的中台而是要满足一家或几家游泳馆的日常运营从前台卖票、办卡、上课预约到泳池水质记录、设备巡检、库存管理再到月底对账和经营者看报表。技术栈没有悬念国内场馆数字化最落地的一套组合是 Spring Boot 3 MyBatis-Plus MySQL Redis前端配 Vue 3 或直接使用 Element Admin 脚手架。接下来我会按数据模型、票卡计费、场地预约、设备管理、避坑和进阶六个部分把这个系统怎么做、参数怎么设、雷区在哪讲清楚。你照着这条路走能在一个月内跑出可投入使用的最小版本。2. 先把账算清游泳馆管理系统的数据模型与核心表设计2.1 从业务对象反推表结构别套通用会员模板我见过不少第一次做游泳馆系统的人直接拿一套通用会员管理的建表脚本改改就上。结果跑到第二个月就崩了——因为游泳馆的业务对象和健身房、美容院完全不同。游泳馆的账要从三个维度算人会员、物票卡/私教课、场泳道/时段。人和物的关系是持有票卡物和场的关系是预约占用人和场的关系是入场核销。我先给出核心表的最小集合这八张表跑通后能覆盖游泳馆 80% 的日常业务-- 会员表 CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, gender TINYINT COMMENT 1男 2女, birthday DATE, level TINYINT DEFAULT 1 COMMENT 会员等级默认1, status TINYINT DEFAULT 1 COMMENT 1正常 2冻结, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT 游泳馆会员表; -- 票卡表 CREATE TABLE card ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL, card_no VARCHAR(32) NOT NULL UNIQUE, card_type TINYINT NOT NULL COMMENT 1次卡 2月卡 3年卡 4私教课卡, total_times INT COMMENT 次卡总次数月卡年卡为空, remaining_times INT COMMENT 剩余次数, valid_start DATE NOT NULL, valid_end DATE NOT NULL, status TINYINT DEFAULT 1 COMMENT 1启用 2挂失 3过期, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT 票卡表; -- 泳票订单表 CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, member_id BIGINT, card_id BIGINT, ticket_type TINYINT NOT NULL COMMENT 1单次票 2次卡核销 3月卡核销, amount DECIMAL(10,2) COMMENT 单次票金额, pay_type TINYINT COMMENT 1现金 2微信 3支付宝 4挂账, status TINYINT DEFAULT 1 COMMENT 1待入场 2已入场 3已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT 泳票订单表; -- 场地/泳道表 CREATE TABLE lane ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, type TINYINT COMMENT 1普通泳道 2私教泳道 3儿童泳池区, capacity INT COMMENT 可容纳人数, status TINYINT DEFAULT 1 ) COMMENT 泳道表; -- 时段预约表 CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL, lane_id BIGINT NOT NULL, card_id BIGINT COMMENT 私教课卡时必填, lesson_id BIGINT COMMENT 私教课ID散客预约为空, reserve_date DATE NOT NULL, time_slot VARCHAR(20) NOT NULL COMMENT 例如 18:00-19:00, status TINYINT DEFAULT 1 COMMENT 1已预约 2已入场 3已取消 4已爽约, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_lane_slot (lane_id, reserve_date, time_slot, status) ) COMMENT 时段预约表; -- 私教课表 CREATE TABLE lesson ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, coach_id BIGINT NOT NULL, lane_id BIGINT NOT NULL, max_students INT DEFAULT 6, lesson_date DATE NOT NULL, time_slot VARCHAR(20) NOT NULL COMMENT 例如 18:00-19:00, total_hours DECIMAL(4,1) COMMENT 购买总学时, used_hours DECIMAL(4,1) DEFAULT 0 COMMENT 已用学时, status TINYINT DEFAULT 1 ) COMMENT 游泳私教课表; -- 水质/设备巡检记录表 CREATE TABLE facility_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_type TINYINT COMMENT 1水质检测 2设备巡检 3药品投放, pool_id INT COMMENT 1成人池 2儿童池, ph_value DECIMAL(3,1), residual_chlorine DECIMAL(3,1), temperature DECIMAL(3,1), device_name VARCHAR(50) COMMENT 设备名例如循环泵1号, content VARCHAR(255) COMMENT 巡检说明, operator VARCHAR(30) NOT NULL COMMENT 操作人, record_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT 水质设备记录表; -- 充值记录表 CREATE TABLE recharge_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, gift_amount DECIMAL(10,2) DEFAULT 0 COMMENT 赠送金额, balance DECIMAL(10,2) COMMENT 账户余额, operator VARCHAR(30) COMMENT 操作人, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT 充值记录表;这套表结构有四个我特别想说明的设计决策。第一card表的card_type是关键游泳馆最常见的财务纠纷就是次卡/月卡/年卡混着用——一个会员手里可以同时有次卡和私教课卡入场核销时必须指定用哪张卡所以ticket_order里单独存了card_id而不是笼统地扣会员余额。第二reservation表的唯一索引是(lane_id, reserve_date, time_slot, status)这条索引的意义在于防止同一个泳道在同一时段被重复预约但注意它把status也放进了唯一键——这样做的原因是允许同一条记录先插成已预约再更新为已入场而不和未来的新预约冲突。第三所有涉及金额的字段都用DECIMAL(10,2)绝不用FLOAT游泳馆前台退款频繁浮点精度误差只要发生一次月底对账就是一场灾难。第四facility_record不是参考设计它是卫生巡检的刚需——很多健身房管理系统不做这张表但游泳馆卫生监督抽查时会要求你出示近半年的水质记录。2.2 泳道与时段游泳馆和健身房在空间管理上的关键差异健身房的空间管理是器械位一个人占用一个跑步机不需要在意时间重叠游泳馆的空间管理是泳道 时段的二维模型。一个泳道长 50 米高峰期一条泳道可以同时容纳 46 个散客但私教课泳道在整点时间段内只服务一个教练和最多 6 个学员儿童区的浅水池承载量更不能按人数硬算要按家长陪同的物理空间估算。-- 时段配置表约定场馆每天的场次 CREATE TABLE time_slot_config ( id BIGINT PRIMARY KEY AUTO_INCREMENT, start_time TIME NOT NULL, end_time TIME NOT NULL, slot_tag VARCHAR(30) COMMENT 例如早场/日场/晚场, price DECIMAL(10,2) COMMENT 该场次散客票价, is_peak TINYINT DEFAULT 0 COMMENT 1高峰期 ) COMMENT 时段配置表;我在搭系统时一般会把time_slot设计成配置文件而不是硬编码在业务逻辑里因为游泳馆的季节性很明显夏天晚上 18:00-21:00 是高峰期单价上涨冬天白天才是主力时段。运营人员会频繁调整几点到几点算晚场、晚场加价多少。如果这些是代码里的 if 判断每次调整都要发版放在time_slot_config表里前台在系统里就能改。预约服务在创建reservation前先去查这张表取出对应的price再作为待支付金额写入订单。这个做法理论上属于垂直切分的配置表实际用下来最大的收益不是灵活性而是可审计——月末回看某天某个时段卖了多少张票直接按这张表分组汇总就行运营和财务对账时能少吵很多架。顺便说一个很多人忽略的设计游泳馆的预约time_slot字段我特意用VARCHAR存18:00-19:00这样的字符串而不是拆成start_time和end_time两个TIME字段。哪边更好用拆开存更标准化但实际场景里场次是一个业务概念散客买的是晚场不是精确的 60 分钟。如果拆成两个时间字段你反而要在业务层维护它们的配对关系否则数据库里可能出现18:00-19:30这种零碎场次。用字符串保存场次标签业务含义一目了然缺点是查询当前时间属于哪个场次需要做字符串匹配。游泳馆不会有跨零点营业的场次这种取舍完全值当。3. 票卡计费与入场核销把钱和次数这对冤家理顺3.1 会员办卡流程开卡、充值、赠送金额的连带事务办卡是整个系统的资金入口流程上包含两步充值到会员账户、生成票卡。这两个动作不是独立的——你充值 500 送 100然后拿这 600 元余额去买一张次卡余额扣减和次卡生成必须在一个事务里不能先充值成功再办卡失败。很多翻车现场就发生在这里充值写成功了次卡插入时因为卡号重复抛异常结果会员钱扣了票卡没生成。前台一慌走手工补卡流程账就乱了。Service public class CardService { Transactional(rollbackFor Exception.class) public void openCard(OpenCardRequest req) { // 1. 做充值记录更新余额 MemberAccount account accountMapper.selectForUpdate(req.getMemberId()); BigDecimal newBalance account.getBalance().add(req.getRechargeAmount()); int updated accountMapper.updateBalance(req.getMemberId(), newBalance); if (updated 0) { throw new BusinessException(账户更新失败请重试); } // 2. 生成票卡次卡类型 Card card new Card(); card.setMemberId(req.getMemberId()); card.setCardNo(generateCardNo()); card.setCardType(1); card.setTotalTimes(req.getTimes()); card.setRemainingTimes(req.getTimes()); card.setValidStart(LocalDate.now()); card.setValidEnd(LocalDate.now().plusMonths(req.getValidMonths())); card.setStatus(1); cardMapper.insert(card); // 3. 再单独插入一条充值记录便于月底对账 rechargeRecordMapper.insert(buildRechargeRecord(req, newBalance)); } }这里最关键的是第 1 步的selectForUpdate。为什么必须加锁因为 MySQL 默认的REPEATABLE_READ隔离级别下两个前台同时给同一个会员充值时后一个事务读取到的balance可能是前一个事务更新前的旧值导致充值金额互相覆盖。用select ... for update把这一行锁住第二个事务必须等第一个事务提交后才能读到新余额。有人会用先 update 再判断影响行数来代替锁但如果balance没有变化比如送 0 元update affected rows可能为 0业务会误判为失败。所以我一般总是先锁后改。另一个细节是generateCardNo()不要用数据库自增 ID 裸拼至少拼成C yyyyMMddHHmmss 四位随机数的形式避免卡号被恶意遍历。办卡时的赠送金额我单独记在gift_amount字段里而不是和充值金额混在一起。这个设计的价值会在退卡和月底对账时体现出来如果会员要求退卡运营一般只退实充金额不退赠送部分如果混在一个余额字段里你根本说不清这里面的 100 元是赠送还是实付。分字段存退款时按amount - gift_amount的占比折算法律纠纷和客服纠纷都能少一半。3.2 入场核销按卡类型分流一次扣减的原子性入场核销是前台每天做几百次的操作也是并发压力最集中的地方。常见流程是前台扫会员码拿到会员 ID 和卡 ID判断这张卡在今天是否有效再做扣次。这里有个特别容易踩坑的判断逻辑——月卡和年卡在有效期内入场不该判断剩余次数而次卡必须判断次数如果系统里存在多人拼一张次卡的情况还要考虑remaining_times的并发扣减导致负数的情况。public CheckInResult checkIn(Long memberId, Long cardId) { Card card cardMapper.selectByIdForUpdate(cardId); if (card null || !card.getMemberId().equals(memberId)) { return CheckInResult.fail(卡与会员不匹配); } LocalDate today LocalDate.now(); if (card.getValidEnd().isBefore(today)) { return CheckInResult.fail(卡已过期请续卡); } if (card.getStatus() ! 1) { return CheckInResult.fail(卡状态异常无法入场); } // 按卡类型做扣减 if (card.getCardType() 1) { // 次卡 if (card.getRemainingTimes() 0) { return CheckInResult.fail(次卡次数已用完); } int updated cardMapper.decreaseTimes(cardId, card.getRemainingTimes()); if (updated 0) { return CheckInResult.fail(操作太频繁请重试); } } // 月卡/年卡不做次数扣减但同一张卡当天不能重复核销同一场次 Integer count ticketOrderMapper.countTodayChecked(cardId, LocalDate.now()); if (count 0) { return CheckInResult.fail(该卡今日已入场); } // 插入入场记录 ticketOrderMapper.insertCheckInOrder(...); return CheckInResult.success(); }decreaseTimes的 SQL 长这样UPDATE card SET remaining_times remaining_times - 1 WHERE id #{cardId} AND remaining_times 0注意这条 SQL 里的AND remaining_times 0条件。它是扣次防超卖的兜底即使前端的 if 判断因为并发穿透了数据库层面的UPDATE也会因为条件不满足而影响 0 行服务端通过updated 0感知并返回失败。这是典型的乐观锁思路避免了对整个表加锁。月卡当天重复入场的问题我用countTodayChecked子查询挡掉——月卡理论上可以不限次数入场但如果你放开了不限次数有会员一天进出五六次更衣室流量和安全压力都会上来。绝大多数游泳馆的实际运营规则是日卡限一次、月卡分场次限一次这条限制不是技术限制是运营限制。3.3 私教课课时扣减学时和次卡分开计费游泳馆的一对一私教课和健身房不同课时费普遍按次或学时计算而且教练排课往往一次划扣 1 学时60 分钟或 1.5 学时90 分钟。lesson表里的total_hours和used_hours就是为了这个场景。如果在核销时统一走次卡逻辑私教课就变成买 10 次送 2 次的算法排课记录和财务结算很难挂钩到具体教练。我最终做的是把课时账户和游泳票卡完全拆开。私教课卡在办卡时录入total_hours结课时按used_hours 本次课时更新。这里的坑在于教练在前端排课时必须能直观看到剩余课时否则教练为了消耗课时频繁给学员加课场馆成本会失控。前端页面上要在教练工作台常显一行大字剩余课时X后台逻辑每次预约排课都先查剩余课时不足时不允许提交。public void scheduleLesson(Long cardId, BigDecimal lessonHours) { Card lessonCard cardMapper.selectByIdForUpdate(cardId); if (lessonCard.getCardType() ! 4) { throw new BusinessException(非私教课卡); } BigDecimal remain lessonCard.getRemainingHours(); if (remain.compareTo(lessonHours) 0) { throw new BusinessException(剩余课时不足); } lessonCard.setRemainingHours(remain.subtract(lessonHours)); cardMapper.updateById(lessonCard); }注意这里用的是compareTo而不是因为BigDecimal的equals会比较精度0.0和0.00的equals结果为 false但compareTo就认为是相等的。很多人第一次用BigDecimal时在这里翻过车。4. 场地预约与泳道分配让高峰期不再靠吼4.1 预约选座还是预约选泳道先定规则再写接口游泳馆的预约和电影院不一样。电影院座位是强绑定游泳馆是弱绑定——预约的重点是保证这个人这个时段能下水而不是必须占住哪条泳道。所以预约系统的核心不是座位矩阵而是泳道 时段的人数配额。设计逻辑是这样的每个lane表里有一个capacity字段代表这条泳道本时段最多容纳的散客数量。散客预约某个时段时后台统计该泳道该时段已有的有效预约数count如果count capacity就提示该时段已满否则插入预约记录并扣减一次票卡次数。私教课的预约则不走capacity因为私教泳道的名额由lesson.max_students控制两者要分别校验。这里的关键是两条预约通道的校验逻辑不能混用否则散客把私教泳道约满教练排课就冲突了。SELECT COUNT(*) FROM reservation WHERE lane_id #{laneId} AND reserve_date #{date} AND time_slot #{slot} AND status IN (1, 2)因为reservation表有唯一索引uk_lane_slot (lane_id, reserve_date, time_slot, status)所以高并发下SELECT COUNT(*)不是线程安全的——两个请求同时看到 count 9都往库里插第 10 条唯一索引只保证同一条记录不重复不保证总数不超过 capacity。所以我的做法是先用SELECT COUNT(*)做初步判断插入时用 MySQL 的INSERT ... SELECT ... WHERE NOT EXISTS子句做二次校验。INSERT INTO reservation (member_id, lane_id, reserve_date, time_slot, status) SELECT #{memberId}, #{laneId}, #{date}, #{slot}, 1 FROM dual WHERE NOT EXISTS ( SELECT 1 FROM reservation WHERE lane_id #{laneId} AND reserve_date #{date} AND time_slot #{slot} AND status IN (1, 2) GROUP BY lane_id, reserve_date, time_slot HAVING COUNT(*) #{capacity} )这招叫条件插入它把容量校验挪到数据库层面规避了应用层并发原子性的问题。执行后如果你用 MyBatis/MyBatis-Plus 获取affected rows为 0 就说明插入失败要么冲突要么满员。4.2 泳道状态联动水质不达标时自动关停预约游泳馆的泳道并不是一直开放的。水质检测不合格、设备检修、水温异常都要临时关闭泳道。系统里我加了一个lane.status字段1 正常 / 2 临时关闭 / 3 检修中然后在预约接口的开头就先查泳道状态public Boolean isLaneAvailable(Long laneId, LocalDate date, String timeSlot) { Lane lane laneMapper.selectById(laneId); if (lane.getStatus() ! 1) { return false; } // 额外检查该时段是否被运营在后台强制锁定例如包场活动 return !lockMapper.existsLock(laneId, date, timeSlot); }这里面最容易忽略的需求是包场场景。游泳馆偶尔会承接团队活动包场要求某条泳道某个时段一整条锁死禁止散客预约。如果只靠单个泳道的 status 字段包场后运营要手动去改泳道状态结束后又要改回来非常容易忘记。我建议加一个lock_rule表存(lane_id, start_date, end_date, time_slots)预约的校验逻辑里统一查这张表。运营端勾选包场生成锁定规则系统自动阻止冲突时段预约到期自动失效。加这张表只花了不到一个下午但它避免了运营忘开闸导致全场投诉的血泪事故。5. 设备巡检与水质在线登记别让系统输给一张纸质巡检表5.1 水质指标记录比用了再说多走一步的闭环卫生监督部门对游泳馆的检查核心看两项游离性余氯和 pH 值另外还有浑浊度和水温。传统做法是一张纸质表每天填两次检查时翻本子。问题在于填表的真实性无法保证——很多前台是闭着眼填余氯 0.5、pH 7.2反正领导不看。管理系统应该把记录和复核做成两步。我的做法是给facility_record表加一个confirm_status字段默认 0待复核店长每天在后台对当天的水质记录做一次批量确认确认后记录进入已复核状态。系统在生成日报时只统计已复核的记录如果某个班次漏了记录报表上会直接出现空缺时段比纸质表的事后补填真实得多。从技术上来说这张表不需要什么高深设计但它倒逼运营流程规范化。我曾经在一个项目里把巡检记录做成 App 端扫码上报泳池旁的设备上贴二维码扫码后手机直接推送到后台自动带出设备 ID 和时间戳——一线员工想补造假记录都难。-- 每日水质汇总视图按日统计用于日报 SELECT DATE(record_time) AS record_date, pool_id, COUNT(*) AS record_count, ROUND(AVG(ph_value), 2) AS avg_ph, ROUND(AVG(residual_chlorine), 2) AS avg_chlorine, MAX(temperature) AS max_temp FROM facility_record WHERE confirm_status 1 GROUP BY DATE(record_time), pool_id ORDER BY record_date DESC;这个聚合查询就是卫生检查时能拿出手的数据基础。ROUND(..., 2)是为了归一度数精度化验室用的测试仪器读数本来就是小数点后两位不加ROUND反而可能出现 7.35000001 这种怪数。5.2 设备维保提醒把坏在路上变成提前换胎游泳馆的设备集中在循环过滤泵、加热泵、臭氧发生器和投药泵。它们不是坏了才修而是到了运行时长就要保养。我在设备表上加了两个字段last_maintain_date上次保养时间和maintain_cycle_days保养周期天数比如水泵是 90 天臭氧机是 180 天。系统每天跑一个定时任务扫描last_maintain_date maintain_cycle_days 当天的设备在后台待办中心生成保养工单同时给店长微信推送提醒。这个功能用 Spring 的Scheduled就能写但是要注意定时任务的时间粒度。我用cron 0 0 8 * * ?每天早上八点整扫描一次而不是每五分钟扫一次。为什么因为保养工单不是交易晚半小时知道设备该保养了没有致命影响但如果每五分钟扫一次大批量数据反而白白增加数据库压力。坑点在于maintain_cycle_days的初始值千万别为 0否则所有设备一上线就是待保养状态。初始化数据时要么给默认值要么在 SQL 里用IFNULL兜底SELECT id, device_name, DATE_ADD(last_maintain_date, INTERVAL IFNULL(maintain_cycle_days, 90) DAY) AS next_maintain_date FROM device WHERE DATE_ADD(last_maintain_date, INTERVAL IFNULL(maintain_cycle_days, 90) DAY) CURDATE();6. 避坑指南游泳馆系统上线后逃不掉的三类翻车事故6.1 并发场景下的超卖两笔订单最后只剩一张票现象周末晚场高峰期前台同时接待两位顾客明明系统显示余票 1 张两个订单却都支付成功最后一个人到场发现没有泳道可用。原因两个请求同时读到了remaining_times 1各自走完支付流程再回写扣减后者更新时用的是自己读到的旧值覆盖了前者的更新结果。这本质上是丢失更新问题。解决我上面写的UPDATE card SET remaining_times remaining_times - 1 WHERE id ? AND remaining_times 0就是第一道防线。支付和扣次必须在同一个事务中先锁行再扣减。如果你用的 Redis 预扣库存还要在数据库里保持最终一致性不能只扣缓存。我见过最离谱的翻车项目是用 Redis 的DECR扣库存以为万事大吉结果 Redis 崩溃恢复后库存数字回滚前台卖出了超出泳池承载量的票。6.2 退款时金额和次数对不上月卡退了一半系统不知道该退给谁现象会员买了 2000 元的年卡游了 3 个月要退卡财务按剩余天数比例算出应退 1400 元但系统里只有一张年卡的记录没有已用多少天/剩余多少天的拆分数据退款后年卡状态不知道设成什么。原因当初设计card表时只关注了卡从哪天到哪天有效没设计退款状态和退款金额字段导致退款流程靠人工在备注里写。解决在设计初期就给card表加上refund_status0 未退 / 1 部分退 / 2 全额退、refund_amount、refund_date。退款时写入退款记录同时把卡状态置为已退。另外提醒一句退款金额的计算逻辑不要放在前端 JS 里——前端算出的 1400 元可以被篡改后端必须根据valid_start和valid_end重新计算截至退款当天的剩余天数比例。这个接口一定要写单元测试至少覆盖刚办卡 1 天退和过期前一天退两个边界。6.3 日期边界循环依赖跨天场次和夏令时不是最大坑最大坑是时区现象系统晚上 10:00 后卖的票第二天一大早对账发现日期错位前一天晚上 23:50 的订单被记到了第二天。原因服务器时区是 UTC数据库时区是 CST中国标准时间Java 服务在写入create_time时用了LocalDateTime.now()而数据库字段用了TIMESTAMPMySQL 的 TIMESTAMP 会自动把 UTC 转成会话时区。两套时区一叠加晚上 10 点后的订单就被平移到了次日。解决统一时区是第一原则。数据库连接串里明确serverTimezoneAsia/ShanghaiJVM 启动参数加-Duser.timezoneGMT8并且所有时间字段在 Java 实体里一律用LocalDateTime不要用Date。TIMESTAMP字段存储范围只有 2038 年而游泳馆会员卡最长也就 3 年但万一哪天你要存一个长期培训合同的截止时间DATETIME 不香吗所以建表时时间字段优先DATETIME。6.4 代码里的魔法数字卡类型和状态枚举最后变成无人问津的黑匣子现象维护半年后的系统里card_type的 0、1、2、3 代表什么已经没人说得清。新来的开发改代码时把月卡type2判断写成了cardType 1导致月卡用户全部无法入场。原因没有枚举意识建表时用 TINYINT 直接写注释业务代码里也直接写魔法数字。解决前后端都必须做枚举映射。后端用 Java 枚举类统一管理前端用一个全局常量字典禁止在代码里裸写1和2。另外所有对card_type的判断一律走枚举类的方法比如CardTypeEnum.MONTH_CARD.equals(card.getCardType())不要写if (cardType 2)。这种做法短期内似乎多写了一堆代码但半年后你回来维护时会感激自己。7. 数据对账与 Excel 批量导入运营和财务都该有的一个趁手工具游泳馆管理系统的最后一个关键能力是对账。系统里每天有票卡购买、私教课核销、商品售卖泳镜泳帽、寄存柜租赁等多条业务流水月底财务要跟系统对账、跟银行流水对账、跟前台交接单对账。如果系统只提供一张简单的订单列表财务的工作量不会比用 Excel 少。我的技巧是主动做一张每日营收汇总表按日、按支付方式现金/微信/支付宝、按业务类型票卡/商品/储物柜三个维度输出汇总。每个月月底再跑一次月总览生成可以导出的 CSV 文件。这根线一打通财务完全不依赖开发就能完成月度核对。数据导入方面最常见的是批量导入会员和次卡开卡数据。很多游泳馆在开业初期会有大量团购用户一次给你一个 Excel 文件几千行会员和赠送次数要在一个晚上导入系统。如果写个 for 循环逐条调用openCard接口几千行的数据可能要跑 20 分钟中途还可能因为某个手机号格式不对抛异常整个事务回滚白等。public void batchImportCards(MultipartFile file) { ListCardImportRow rows ExcelUtil.parse(file, CardImportRow.class); // 分批提交事务每 200 条一个批次 for (int i 0; i rows.size(); i 200) { ListCardImportRow batch rows.subList(i, Math.min(i 200, rows.size())); cardImportService.batchInsertWithValidation(batch); } }这里我拆了两层逻辑batchInsertWithValidation在事务内先校验手机号格式、会员是否已存在、卡号是否重复再批量插入。每个批次 200 条的目的是避免一条脏数据拖垮三千条有效数据的导入——这一批报错了告诉你第 3 行第 5 列的手机号格式非法你改完以后只重导这一批就行。这是做数据处理的血泪经验永远不要在一个大事务里处理大批量数据拆小批失败隔离快速定位。Excel 导入的字段校验也要前置到位。手机号统一用正则^1[3-9]\d{9}$校验身份证号如果是可选字段但填了就要校验 18 位。我最常看到的问题是 Excel 里的卡号是数字长串被解析成科学计数法比如1.23457E11。解决方式是在 ExcelUtil 解析时对这一列强制转换为字符串并且开头加一个不可见的制表符或直接设置单元格格式为文本。这个坑几乎每个导入功能都会遇到提前在工具类里处理好能省掉无数的客服电话。另一个非常实用的业务技巧是先导入到临时表再合并。正式场景下我会把 Excel 数据先全部导入一张import_temp表字段和正式表一一对应外加一个valid_result字段存放逐行校验信息。然后在前台页面上显示成功 2890 条、失败 37 条的清单用户可以下载失败明细改完重新上传。正式表的数据只在用户点击确认导入后才写入给运营留了一颗后悔药。关于对账我用一个简单 SQL 作为每天早上的晨检报表SELECT business_date, pay_type, COUNT(DISTINCT order_no) AS order_count, SUM(amount) AS total_amount FROM ticket_order WHERE status NOT IN (3, 5, 6) -- 剔除取消/退款/异常单 GROUP BY business_date, pay_type ORDER BY business_date DESC;这条 SQL 跑出来的结果就是财务核对当天现金和移动支付收入的唯一证据。如果前台手动改过订单状态这里的汇总立刻就会露出马脚。我接手过一个游泳馆的烂摊子前台为了平账把几笔大额订单的状态从已支付改成已取消但退款记录没同步导致系统显示营收 2 万而实际银行到账只有 1.2 万。加了这条对账 SQL 之后每天早上十点财务看一眼报表再跟收银交接单核对这个问题就再没出现过。做系统做到最后你会发现技术难点不在高并发不在算法而在信任——运营愿意每天用你这个系统替代 Excel财务愿意相信系统里的数字老板愿意按这份报表做决策全都是靠一个个细节堆起来的。这是我做了三套场馆管理系统后最深的感受。希望帮到你。本文还有配套的精品资源点击获取