ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从建表到避坑:游泳馆管理系统核心设计与实现指南

从建表到避坑:游泳馆管理系统核心设计与实现指南 简介这套游泳馆管理系统是基于Java的数据库课程设计完整项目面向需要完成课设或练习Java数据库开发的读者。系统涵盖会员管理、预约管理、场地管理和收费管理四大核心模块并包含会员等级、预约冲突处理、场地调度及财务报表等细化功能能帮助快速掌握从ER模型设计到实际编码的完整流程。资源压缩包共1286个文件以htm文档、java源码、class编译文件、gif界面截图和sql数据库脚本为主另有少量js/css/jar辅助资源整体仅3.81MB结构清晰便于对照学习。目前已有1533人学习下载。通过阅读源码和sql脚本可理解数据库中会员、预约、场地等实体关系结合界面截图和htm说明能理清Swing/JavaFX界面与后台数据交互逻辑对初学者来说是一份既能巩固数据库原理又能提升Java编程与项目维护能力的实用参考资料。1. 游泳馆管理系统到底在管什么先想清楚业务边界再写第一行代码一个游泳馆老板找我做系统时张口就说“做个能办卡、能预约的管理系统就行”。真坐下来聊需求才发现他要管的不只是会员卡和预约教练排课要不要管培训班学员怎么算课时深水区资格要不要单独核验洗澡柜子租用算不算在订单里这些业务边界不划清楚游泳馆管理系统做到一半就得返工。这类系统本质上是「场馆运营的中台」把会员、卡种、场地、教练、课程、收银、入场核销这些要素串起来让前台不再用Excel记账让老板能实时看到今天卖了多少票、哪个时段泳道空着。适合谁做适合接外包的团队、场馆自己的IT部门以及想靠这套系统切入体育SaaS方向的人。下面我按自己做过的一版方案从选型、建表到核心代码把这条路走一遍。2. 选型与整体架构单体还是微服务为什么我推荐用 Spring Boot Vue 快速交付游泳馆管理系统本质上是一个典型的单体业务系统业务量级取决于场馆规模。绝大多数游泳馆的日订单量在几百到几千笔会员几万人这个数据规模用一台普通服务器跑单体应用完全够。我见过有人把这种项目拆成十几个微服务结果运维成本比开发成本还高。做这种系统先选单体后期真到了并发瓶颈再拆也不迟。2.1 游泳馆这类场馆系统的三个核心诉求会员、场地、计费先别急着建表。任何一个游泳馆管理系统功能再怎么花哨最终都落在三件事上会员怎么管、场地怎么排、钱怎么算。会员管理不只是存储姓名电话它要覆盖会员生命周期开卡、续费、挂失、补卡、退卡、冻结。卡的种类也不统一有次卡、月卡、年卡、储值卡还有晨泳卡、暑期卡这种限时段卡。每类卡的计费规则不同比如次卡是「扣次数」年卡是「看有效期」储值卡是「扣余额」。实际项目里这些规则经常叠加例如暑期卡只能在6月到8月使用且每天限入一次。场地管理要解决的是「哪个泳池、哪条泳道、哪个时段被谁占了」。游泳馆和健身房不同健身房的场地是「工位制」办了卡随时来游泳馆的泳道是「分时制」高峰期必须预约。到了夏季一个泳池同时有训练队、培训班、散客泳道分配错了就会起冲突。计费是整个系统的核心也是最容易出财务纠纷的地方。一次入场到底收多少钱会受会员卡折扣、时段加价、节假日调价、押金退还等多重因素影响。计费规则一旦算错前台对不上账老板第一个找你麻烦。2.2 技术栈选择后端、前端、数据库的落地组合我一般推荐 Spring Boot Vue MySQL 这套组合不是因为新而是因为稳。Spring Boot 3.x 自带事务管理和定时任务处理订单和卡有效期结算很方便Vue 做后台管理界面组件化开发前台收银、会员列表这些页面效率高MySQL 8.x 处理这量级的数据绰绰有余而且在事务、索引、JSON字段方面都很成熟。前端管理端我习惯用 Vue 3 Element Plus因为表格、表单、弹窗这些后台常用组件都有现成的。如果场馆需要会员在小程序里预约可以另做一个小程序端但主管理端用 Vue 就够。后端就按标准的分层结构来但要注意「按业务模块分包」而不是「按技术层分包」。也就是说先建member、venue、order、card这些业务包每个包里有自己的 controller、service、mapper而不是搞一个controller包下面堆几十个类。前者在业务膨胀时能控制复杂度后者开发到后期连找个接口都要翻半天。数据库连接池用 HikariCPSpring Boot 默认就带。缓存可以先用 Caffeine等预约并发真上来了再上 Redis。支付方面游泳馆线下居多微信/支付宝扫码支付为主但不要一上来就对接支付网关先留好支付回调接口后面接入时只需要改一个实现类。2.3 工程结构按业务模块拆分而不是按三层分包以一个最小可运行的骨架为例我通常会这样组织后端工程swim-admin/ ├── pom.xml ├── src/main/java/com/swim/ │ ├── SwimsApplication.java │ ├── common/ // 统一返回、异常处理、工具类 │ ├── config/ // 配置类MyBatis、拦截器、任务调度 │ ├── module/ │ │ ├── member/ // 会员管理 │ │ ├── card/ // 卡种与计费 │ │ ├── venue/ // 泳池、泳道、时段 │ │ ├── booking/ // 预约管理 │ │ ├── order/ // 订单与支付回调 │ │ └── report/ // 报表统计 │ └── security/ // 登录、权限 └── src/main/resources/ ├── application.yml └── mapper/ // MyBatis XML这个结构的好处是新增一个「教练排课」模块时你只需要在module下加一个course包不动其他模块。每个模块内部按 controller、service、mapper 组织业务内聚互不干扰。如果哪天要把某个模块拆出去做独立服务直接把这个包整体移动即可。前端工程我按页面维度组织swim-admin-web/ ├── src/ │ ├── api/ // 按模块封装的请求方法 │ ├── views/ │ │ ├── member/ │ │ ├── card/ │ │ ├── booking/ │ │ ├── order/ │ │ └── report/ │ ├── components/ // 公共组件上传、导入、日期范围 │ ├── router/ │ └── store/这里的坑是很多人喜欢把所有请求都写在同一个api.js里页面一多就变成意大利面。按模块拆文件每个模块一个member.js、card.js维护成本直线下降。工程结构定好了下一步就是库表设计。这是整个游泳馆管理系统最需要提前想清楚的部分后面很多返工都源于表设计没考虑周全。3. 数据库设计是游泳馆系统的命门从会员到泳道的表结构设计我接过一个半途烂尾的游泳馆项目原团队把「卡种」「会员卡」「卡订单」全塞在一张表里导致会员续费时不知道扣的是哪张卡的钱退卡时算不清有效期。最后推倒重来只保留了原来的会员基础信息表。数据库设计如果你先动手建表后面业务逻辑写起来会非常别扭。3.1 核心表会员、卡种、会员卡、订单、预约、场地先给出一套我验证过可落地的核心表结构。不要直接复制要根据场馆具体的定价策略调整字段。-- 会员基础信息表 CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, gender TINYINT COMMENT 0女 1男 2未知, id_card VARCHAR(18) COMMENT 实名信息用于深水证核验, emergency_phone VARCHAR(20), status TINYINT DEFAULT 0 COMMENT 0正常 1挂失 2冻结 3退卡, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT 会员表; -- 卡种定义表描述有哪些卡在卖 CREATE TABLE card_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, kind TINYINT NOT NULL COMMENT 1次卡 2时效卡 3储值卡, total_times INT DEFAULT NULL COMMENT 次卡次数时效卡为空, valid_days INT DEFAULT NULL COMMENT 时效卡有效天数次卡为空, price DECIMAL(10,2) NOT NULL, time_limit VARCHAR(20) COMMENT 限时段如 06:00-09:00 晨泳, date_limit VARCHAR(20) COMMENT 限日期段如 2025-06-01~2025-08-31, status TINYINT DEFAULT 0 ) COMMENT 卡种表; -- 会员持有的卡实例表 CREATE TABLE member_card ( id BIGINT PRIMARY KEY AUTO_INCREMENT, card_type_id BIGINT NOT NULL, member_id BIGINT NOT NULL, remain_times INT DEFAULT NULL COMMENT 剩余次数次卡用, balance DECIMAL(10,2) DEFAULT NULL COMMENT 储值余额储值卡用, start_date DATE NOT NULL, expire_date DATE NOT NULL, status TINYINT DEFAULT 0 COMMENT 0有效 1已过期 2已挂失 3已退, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (member_id) REFERENCES member(id), FOREIGN KEY (card_type_id) REFERENCES card_type(id) ) COMMENT 会员卡实例表; -- 场地表泳池 CREATE TABLE venue_pool ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, lane_count INT NOT NULL COMMENT 泳道数量, is_competition TINYINT DEFAULT 0 COMMENT 是否比赛池 ) COMMENT 泳池表; -- 时段定义表 CREATE TABLE venue_time_slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, pool_id BIGINT NOT NULL, lane_no INT NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, period_type TINYINT COMMENT 1早场 2日场 3晚场, price DECIMAL(10,2) NOT NULL ) COMMENT 泳道时段表;这套表设计的三个关键点卡种与会员卡分离一个会员可以同时持有年卡和次卡互不影响场地与时段分离泳池是固定资源泳道时段是动态可售资源时间字段用DATE和TIME不要用DATETIME存有效期否则计算剩余天数特别容易出边界问题。3.2 一个容易踩坑的点时段与泳道怎么建模很多人第一次做游泳馆系统把「房间预订」的思路搬过来——给每个泳道建立一个预约记录。实际运营中一个时段内一条泳道可以被多人同时使用比如「8:00-9:00 第2泳道」其实是一个可容纳多人的名额池。游泳馆通常不是按「一条泳道只能被一个人订」来卖票的而是按「这个时段这条泳道剩余可容纳人数」来卖的。正确的建模方式是把venue_time_slot当作一个库存概念每个时段关联一个可预约人数上限比如5人预约时对这条记录做库存扣减而不是插入一条「占用泳道」的记录。这样既能支持多人预约同一条泳道又能控制高峰期的人数。库存字段加上去ALTER TABLE venue_time_slot ADD COLUMN max_people INT NOT NULL DEFAULT 5; ALTER TABLE venue_time_slot ADD COLUMN booked_people INT NOT NULL DEFAULT 0;预约表记录会员与时段的关系CREATE TABLE booking ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL, member_card_id BIGINT NOT NULL, slot_id BIGINT NOT NULL, booking_date DATE NOT NULL, status TINYINT COMMENT 0已预约 1已入场 2已取消 3爽约, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_member_slot (member_id, slot_id, booking_date) ) COMMENT 预约记录表;这样设计以后要判断某个时段是否已满只需要比较booked_people和max_people。而唯一键uk_member_slot保证了同一个会员不能在同一天重复预约同一个时段。3.3 表关系与索引设计要点核心表建好后索引设计直接决定了高峰期查询是否卡顿。我见过一个真实案例会员列表页用phone模糊查询表里几万条数据每次查询都要全表扫描页面转圈半天。后来加了phone前缀索引和status普通索引查询从几百毫秒降到十几毫秒。以下几个索引建议优先加上CREATE INDEX idx_member_phone ON member(phone); CREATE INDEX idx_member_card_member ON member_card(member_id); CREATE INDEX idx_member_card_expire ON member_card(expire_date); CREATE INDEX idx_booking_slot_date ON booking(slot_id, booking_date); CREATE INDEX idx_booking_member_date ON booking(member_id, booking_date); CREATE INDEX idx_order_member ON order_info(member_id, created_at);member_card(member_id, expire_date)这个联合索引尤其重要因为每天定时任务要扫描当天到期的会员卡来更新状态如果没有这个索引全表扫一遍几万条卡要几十秒有了索引就是瞬间的事。订单表的索引要按「查询报表」的角度来建。报表通常按日期、卡种、支付方式聚合所以order_info(pay_time, card_type_id)联合索引比单独的pay_time索引更实用。这里不展开订单表结构后面第4章会有。数据库设计是地基地基打好了接下来写核心业务代码才有底气。我们进入最关键的实现环节卡计费、预约并发、核销。4. 核心功能落地会员办卡、预约订场、门票核销的代码实现很多初学后端的人把游泳馆管理系统做成「增删改查全家桶」前台页面能录入会员、能查订单就觉得完事了。但真正决定这个系统能不能用的是三段不那么好写的业务逻辑办卡时按规则计算价格、预约时防止超卖、核销时防止重复入场。下面分别给你一套能跑通的最小实现。4.1 会员卡类型与计费规则用策略模式实现卡计费办卡价格的难点在于规则太多。同一个会员办年卡如果他还额外办了储值卡年卡打个九折暑期卡只在限定期内开卡才卖晨泳卡只允许早上6点到9点入场。这些规则如果写在一个if-else方法里后期加一个「周年庆买一年送一个月」的规则你就要改老代码风险很大。我一般用策略模式解决把每种卡类型的计费规则抽象成一个接口public interface CardPriceCalculator { /** 计算开卡价格rule中携带卡种和会员上下文 */ BigDecimal calcPrice(CardType cardType, Member member, CardPriceContext context); } // 次卡计费固定价格不考虑有效期 Component public class TimesCardCalculator implements CardPriceCalculator { Override public BigDecimal calcPrice(CardType cardType, Member member, CardPriceContext context) { return cardType.getPrice(); } } // 时效卡计费按激活日期计算截止日可以参与满减 Component public class DurationCardCalculator implements CardPriceCalculator { Override public BigDecimal calcPrice(CardType cardType, Member member, CardPriceContext context) { BigDecimal price cardType.getPrice(); if (context.isHaveStoredCard()) { price price.multiply(new BigDecimal(0.9)); } return price; } } // 储值卡计费面额即金额这里可以做赠送金额 Component public class StoredCardCalculator implements CardPriceCalculator { Override public BigDecimal calcPrice(CardType cardType, Member member, CardPriceContext context) { return cardType.getPrice(); } }调用侧用工厂获取对应的计算器Service public class CardService { private final MapInteger, CardPriceCalculator calculatorMap; public CardService(ListCardPriceCalculator calculators) { this.calculatorMap calculators.stream() .collect(Collectors.toMap(c - c.getKind(), c - c)); } Transactional public MemberCard openCard(OpenCardRequest request) { CardType cardType cardTypeMapper.findById(request.getCardTypeId()); Member member memberMapper.findById(request.getMemberId()); CardPriceContext context buildContext(member); BigDecimal realPrice calculatorMap.get(cardType.getKind()) .calcPrice(cardType, member, context); // 生成订单锁定价格 Order order Order.create(cardType, member, realPrice); orderMapper.insert(order); // 生成会员卡实例 MemberCard memberCard MemberCard.create(cardType, member, realPrice); memberCardMapper.insert(memberCard); return memberCard; } }这里有几个关键点。Transactional保证订单和会员卡实例一起成功或一起失败否则会出现「钱收了但卡没生成」的事故。策略实现类用Component注册到 Spring 容器构造函数注入时按kind值构建一个 Map这种写法新增一种卡类型只需要加一个实现类不需要改动CardService。参数说明CardPriceContext是上下文对象里面可以放会员已有的卡列表、当前日期、是否节假日等信息。getKind()返回卡种类1次卡、2时效卡、3储值卡定义在CardType常量里。别把业务上下文参数一个个写进方法里这样加参数会改所有实现类的签名。4.2 预约泳道的并发控制数据库行锁防止超卖预约场景的并发超卖是游泳馆管理系统最容易翻车的地方。夏季高峰期一个时段开放出来几十个会员同时在小程序里点预约。如果把「先查出剩余人数判断是否大于0然后更新」写成三步两个请求同时查出剩余1人都会通过判断结果预约了2人超卖了。解决方案是数据库层面的原子更新不要先查后改。看下面的代码Service public class BookingService { public Booking createBooking(BookingRequest request) { // 先查会员卡是否有效 MemberCard card memberCardMapper .selectByIdForUpdate(request.getMemberCardId()); if (card null || card.getStatus() ! 0) { throw new BizException(会员卡不可用); } if (card.getExpireDate().isBefore(LocalDate.now())) { throw new BizException(会员卡已过期); } // 原子扣减库存只有 booked_people max_people 时才能更新成功 int updated venueTimeSlotMapper.decreaseStock( request.getSlotId(), request.getBookingDate()); if (updated 0) { throw new BizException(该时段已约满); } Booking booking Booking.create(request); bookingMapper.insert(booking); return booking; } }对应的 Mapper XML 里是这个原子更新语句update iddecreaseStock UPDATE venue_time_slot SET booked_people booked_people 1 WHERE id #{slotId} AND booked_people max_people /update注意这段代码不要写错顺序。先扣减库存再插入预约记录。如果插入预约记录失败事务回滚库存扣减也会回滚。这里的关键在于UPDATE ... WHERE booked_people max_people这一句是原子性的数据库行锁保证同一个venue_time_slot记录在同一时间只能被一个事务更新所以不会出现超卖。有个细节selectByIdForUpdate里的FOR UPDATE是行锁只用于锁定会员卡记录防止会员卡被并发操作。如果你用的 MyBatis-Plus可以写Select(select * from member_card where id #{id} for update)来定义这个方法。这里不需要锁整个时段表因为decreaseStock的原子更新已经保证了库存安全。另一个容易忽略的场景是「取消预约」要回补库存。取消预约后booked_people要减回去同时把预约状态改为已取消。这两个操作也要在一个事务里否则会出现库里预约已取消但库存没有恢复导致这个时段永远显示已满的诡异情况。4.3 核销与入场二维码核销的最小流程线下游泳馆常见的入场流程是会员出示二维码前台扫码核销系统校验会员卡有效性并记录入场时间。核销接口的注意点是要做幂等防止前端重复调用或用户反复出示二维码导致重复入场。public CheckInResult checkIn(String ticketCode) { Order order orderMapper.selectByTicketCode(ticketCode); if (order null) { return CheckInResult.fail(无效的二维码); } if (order.getStatus() 1) { return CheckInResult.fail(该二维码已核销请勿重复使用); } if (order.getStatus() 2) { return CheckInResult.fail(该订单已退款); } order.setStatus(1); // 已核销 order.setCheckInTime(LocalDateTime.now()); orderMapper.updateById(order); return CheckInResult.ok(核销成功泳道 order.getLaneNo()); }核销后建议把入场记录写进一张check_in_log表字段包括会员、订单、核销时间、核销设备、操作员。这张表的好处有两个一是老板可以查「今天来了多少人」的准确数据二是出纠纷时能追溯到是哪台设备、哪个操作员核销的。这里的ticketCode可以是一个一次性二维码内容可以是订单ID加随机串。不要直接暴露数据库自增ID防止用户伪造二维码。生成方式ticketCode UUID.randomUUID().toString().replace(-, ).substring(0, 16)存入订单表。二维码本身用前端生成后端只需要返回这个 code。到这里核心功能基本能跑通。但真实项目里的坑远不止并发和事务。下面我把这些年做游泳馆系统踩过的五个典型问题整理出来每个都是「现象 → 原因 → 解决」的结构希望能帮你省掉几天的排查时间。5. 游泳馆管理系统避坑指南五个真实项目里的翻车现场做管理系统最怕的不是功能多而是数据算错。尤其涉及会员卡和钱错一笔就影响信任。下面这五个坑是我在不同项目里遇到过的排序不分先后每一个都值得注意。5.1 现象办卡金额对不上账财务天天拿着Excel来找你原因开卡时订单生成和会员卡实例生成不在同一个事务里。当时为了赶进度用了先插订单再单独调卡服务的方式结果有一次数据库连接池满了订单提交成功但卡实例插入失败前台显示办卡失败钱却进了订单流水。更隐蔽的是有些代码路径里操作了member_card表后抛了异常但异常被吞掉导致卡生成了但扣款没记录。解决把所有写操作包进一个事务并且事务边界放在CardService.openCard()方法上而不是分别放在OrderService.createOrder()和MemberCardService.createCard()上。还要统一异常处理任何一步失败都必须回滚。加一个对账任务每天凌晨统计order表和member_card表的数量差如果某天开了10张卡但订单表只有9条立刻告警。Transactional(rollbackFor Exception.class) public MemberCard openCard(OpenCardRequest request) { // 1. 生成订单 // 2. 生成会员卡 // 3. 如果会员卡生成失败订单也回滚 }rollbackFor Exception.class这个参数别忘了。Spring 默认只在遇到RuntimeException时才回滚如果你抛的是自定义的BizException而它恰好继承了Exception而不是RuntimeException事务不会回滚那又会出现只扣款没卡的问题。我见过不止一个人栽在这里。5.2 现象同一时段同一泳道预约人数超了现场两拨人吵起来原因前面 4.2 节提到的原子更新没有落实到实际代码。有的团队用的是「先 select 再 update」的写法两个线程同时读到了剩余1人都通过判断然后各插入一条预约实际来了2人。还有一种情况是代码里确实用了原子更新但 Mapper XML 里的条件写成了booked_people max_people 0看似没区别实际由于类型转换问题在某些数据库连接下不生效这个属于玄学但确实发生过。解决用 4.2 的UPDATE ... WHERE booked_people max_people写法并且在插入预约记录前用select ... for update锁住会员卡记录保证同一会员不能并发下两单。另外在booking表上保留UNIQUE KEY(member_id, slot_id, booking_date)唯一索引这是最后的兜底防线如果应用层代码有漏洞数据库也会拒绝重复预约。线上验证时可以打开 MySQL 慢查询日志看看哪些预约接口出现了锁等待锁等待时间超过1秒的优先检查索引。5.3 现象月度报表统计越来越慢老板点一次要等半分钟原因报表功能直接在主表上做聚合查询比如统计每天的入场人数直接对check_in_log表按日期GROUP BY。当这张表积累了上百万条数据后全表扫描是必然的。更糟的是报表页面允许自定义日期范围有些查询用不到索引比如DATE_FORMAT(check_in_time, %Y-%m-%d)这类写法会让索引失效。解决先建check_in_log(check_in_time)的索引然后改掉所有对时间字段做函数转换的条件。如果还需要更快加一张日汇总表daily_report每天定时任务按场馆、日期、卡种汇总一次报表页面只查这张表。汇总表的数据量最多是一年365行查询速度不可能慢。CREATE TABLE daily_report ( stat_date DATE NOT NULL, pool_id BIGINT NOT NULL, card_type_id BIGINT NOT NULL, check_in_count INT NOT NULL, revenue DECIMAL(12,2) NOT NULL, PRIMARY KEY (stat_date, pool_id, card_type_id) );这里有个原则报表查询不要实时去扫流水表这是所有管理系统的通用优化方向。做游泳馆系统也一样日汇总表既可以给运营看也可以给财务对账一举两得。5.4 现象年卡到期时间总差一天会员投诉说「我明天还能用」原因有效期计算用的是DateTime而不是LocalDate。比如开卡时间是 2024-01-01 09:00有效期为一年如果直接拿startDateTime.plusYears(1)得到的到期时间是 2025-01-01 09:00那会员在 2025-01-01 早上来系统判断还没过期。更细的问题出在闰年比如 2024-02-29 开卡plusYears(1)在 Java 里会得到 2025-02-28但在 MySQL 里某些日期函数可能给出不同结果前后端各自算一遍就出现偏差。解决统一用LocalDate处理有效期数据库字段用DATE类型。到期日按「含当天」计算即有效期截止到到期日当天 23:59:59。判断是否过期时用expireDate.isBefore(LocalDate.now())而不是!expireDate.isAfter(LocalDate.now())因为后者会把当天到期的也判成过期。可以用一个统一工具方法public static boolean isExpired(LocalDate expireDate, LocalDate today) { return expireDate.isBefore(today); }数据库里的时间字段也统一用DATE不要在 SQL 里写NOW()来算有效期因为NOW()返回的是DATETIME和DATE比较时又会出现边界问题。定时任务扫描当天到期卡时直接WHERE expire_date CURDATE()干净利落。5.5 现象支付回调重复通知导致储值卡余额多充了一倍原因微信或支付宝的支付回调接口为了确保送达会多次通知同一个订单结果。如果回调处理逻辑没有做幂等校验第一次回调给储值卡加了500元第二次回调又加500元会员白捡一倍的钱。我当时在开发环境没注意因为回调只发一次上线后在真实支付环境里立刻暴露。解决在支付回调接口入口处先查订单状态如果已经是「已支付」就直接返回成功不再做余额累加。public PayResult handlePayCallback(PayCallbackRequest request) { Order order orderMapper.selectByOrderNo(request.getOrderNo()); if (order null) { return PayResult.fail(订单不存在); } if (order.getStatus() 1) { return PayResult.success(重复通知已忽略); } if (order.getStatus() 2) { return PayResult.fail(订单已退款); } // 更严谨的做法对订单号加唯一约束插入支付流水时冲突则忽略 try { payRecordMapper.insert(PayRecord.create(order, request)); } catch (DuplicateKeyException e) { return PayResult.success(重复通知已忽略); } order.setStatus(1); orderMapper.updateById(order); // 累加储值卡余额 memberCardMapper.increaseBalance(order.getMemberCardId(), order.getAmount()); return PayResult.success(支付成功); }注意支付流水表上加UNIQUE KEY(order_no)数据库层面的唯一约束是最终兜底。即使代码里忘了判断状态第二次插入相同order_no也会触发唯一键冲突从而被捕获并忽略。这一招在真实项目中救过我很多次。避坑的部分就到这里。接下来是最后一个环节当系统功能都跑通之后怎么验证它真的能扛住真实运营以及有哪些进阶技巧能让它从「能用」变成「好用」。6. 验证与进阶从能跑到稳定的三个实用技巧系统上线前我习惯给自己出三道「压力题」。第一题模拟100个会员同时预约同一个高峰时段看会不会超卖第二题模拟支付回调连续送达三次以上看储值卡余额是否只加一次第三题把系统时间拨到卡到期日的临界点看定时任务和入场核销是否判断正确。这三道题都能通过这个系统才算达到交付标准。工具和做法都不复杂。并发测试可以用 JMeter 或 Postman 的 Runner 功能开 100 个线程并发请求预约接口然后查数据库里booked_people是否等于max_people预约记录数是否超出。回调幂等测试可以手动在数据库里插入一条相同order_no的支付流水看是否报唯一键冲突以及接口是否返回成功。进阶方面我建议做两件事。一是把预约库存提前缓存到本地内存接口先扣内存再异步落库。这个优化有风险但如果高峰期预约 QPS 超过几百数据库行锁会成为瓶颈。可以引入 Redisson 的分布式锁但要注意锁的粒度——按slot_id booking_date加锁而不是锁整个预约服务。二是给卡到期和余次不足增加微信模板消息提醒这是运营层面的刚需。很多游泳馆的会员卡是「买了就想不起来用」系统在卡到期前7天自动提醒能明显提升续卡率。这个功能实现不复杂无非就是定时任务查member_card表把到期时间在7天内的卡捞出来调用消息推送接口。最后说一个我自己的习惯每次改完计费相关代码我都会在本地写一个「计算器对照用例」把手工算好的价格写死在测试里。比如年卡原价2000元会员有储值卡打九折期望结果1800元暑期卡在7月15日开卡有效期按90天计算。这些用例全部跑绿了才敢提交代码。因为我深知这种管理系统线上数据错了就是真金白银测试用例就是我给自己的后悔药。希望这份从建表到避坑的梳理能帮你在做游泳馆管理系统时少走几段弯路。本文还有配套的精品资源点击获取
返回列表