ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

基于Spring Boot的游泳馆管理系统:会员卡扣减、场次售票与核销

基于Spring Boot的游泳馆管理系统:会员卡扣减、场次售票与核销 简介「游泳馆管理系统」是一套面向高校计算机与数据库课程设计的Java项目源码适合正在完成课程作业、练习企业级应用开发的学生参考。系统围绕会员信息、在线预约、泳道与更衣室等场地调度、收费记账及财务报表展开包含会员等级、预约冲突处理、场地状态维护等业务点并配有基于ER模型的数据库设计可帮助理解实体关系、数据一致性与界面实现思路。压缩包共1286个文件、约3.81MB主要由htm页面、class字节码与java源码构成另含jar、sql及mdf/ldf数据库文件覆盖前端页面、程序代码与建库脚本。已有1536人学习读者可借助源码梳理会员、预约、场地与收费模块的调用关系对照SQL与MDF/LDF文件理解建库过程并补全需求分析、系统设计与测试文档积累Java数据库项目经验。1. 一套跑在浏览器里的泳池前台游泳馆管理系统的真实边界上个月有个做场馆运营的朋友拿着 Excel 来找我说暑假一天卖三百多张票晚上对账对了两个小时最后还漏了三张次卡没扣。这种场景下一个用 Java 写的游泳馆管理系统干的事其实很朴素把会员、票卡、场次、泳道、入场核销和日结报表收进一个后台前台扫码放行后台自动记账中间不靠人手抄。它适合两类人一类是做课程设计、想找一个业务闭环完整、表结构不至于太寒酸的 Java 项目另一类是小型场馆想拿一份能跑起来的底子改成自己的。边界也得先说清它不做硬件闸机直连不接真实支付渠道核销和记账是核心报表是加分项剩下的靠二次开发补。2. 技术栈与工程骨架Spring Boot 分层怎么落到目录上2.1 技术选型这套组合为什么成了默认答案这个项目的技术组合基本是 Java 生态里做中小型管理系统的标准配置Spring Boot 做容器和自动装配MyBatis-Plus 做持久层MySQL 8 存业务数据Redis 可选用来放登录 token 和场次余量缓存前端可以是 Vue2 Element UI 的前后端分离也可以是 Thymeleaf 服务端渲染。选 Spring Boot 的理由很直接一个main方法就能把 Tomcat、数据源、事务管理器全带起来不用再去写一堆 XML。选 MyBatis-Plus 而不是 JPA 的理由更实际场馆类系统的报表 SQL 特别多日结、月结、按场次统计人次这些用 JPA 的 Criteria 写出来又长又难调而 MyBatis-Plus 保留了手写 SQL 的自由同时把单表 CRUD 用BaseMapper省掉了。代码生成器跑一遍entity、mapper、service、controller 四层骨架就出来了对课设这种时间紧的场景几乎是救命稻草。前端走分离还是走模板取决于你要不要给闸机侧留接口。分离方案里后台管理页面和前台核销页面共用一套axios封装接口层用 JWT 做无状态鉴权拦截器里解析 token 塞进ThreadLocalService 层随时能拿到当前操作人。模板方案省一个工程但核销页要单独做移动端适配时会很难受我一般还是倾向分离。权限这块项目里通常是sys_user、sys_role、sys_menu、sys_user_role、sys_role_menu五张表登录后查出菜单树渲染侧边栏接口层用自定义注解RequirePerm(member:add)配合 AOP 做校验。不要一上来就上 Spring Security 全套配置量会吃掉你一半的调试时间用一个 HandlerInterceptor 加注解足够撑住这个体量。2.2 目录结构一个包一层看过去单模块工程是这个项目最常见的形态分包按职责切不按业务切。看着朴素但改起来找得到地方。swim-center ├── pom.xml ├── sql/ │ ├── schema.sql # 建表语句 │ └── data.sql # 字典、菜单、管理员初始数据 └── src/main/ ├── resources/ │ ├── application.yml │ ├── application-dev.yml │ └── mapper/ # MyBatis XML复杂 SQL 放这里 └── java/com/example/swim/ ├── SwimApplication.java ├── common/ # Result、BizException、全局异常处理 ├── config/ # MybatisPlus、Jackson、WebMvc、跨域 ├── controller/ # 只做参数接收和结果包装 ├── service/ │ └── impl/ # 业务逻辑和事务边界都在这一层 ├── mapper/ # 继承 BaseMapper 的接口 ├── entity/ # 和表一一对应 ├── dto/ # 入参对象带校验注解 ├── vo/ # 出参对象拼装展示字段 ├── task/ # 定时任务场次关闭、卡过期 └── util/ # 订单号、二维码、导出工具dto和vo分开这件事很多课设项目会偷懒合并成 entity 直接用结果是前端一传参就能改balance字段。分开之后MemberCardDTO里根本不出现余额字段越权修改从入参层面就被堵住了。同样vo用来拼memberName、sessionName这类跨表展示字段避免在 entity 上挂一堆TableField(exist false)。service/impl是事务的唯一入口。Controller 不写TransactionalMapper 也不写事务边界统一收在 Service 的公开方法上异常往上抛全局异常处理器统一转成Result。这套约定听着教条但项目一旦超过二十个接口没有约定就会乱成一锅粥。2.3 把项目跑起来配置、建库、启动四步配置文件里真正需要改的其实只有三处数据源、时区、MyBatis 的 mapper 扫描路径。剩下的照抄即可。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver # serverTimezone 必须显式指定否则 MySQL 8 下时间会整体偏移 url: jdbc:mysql://127.0.0.1:3306/swim_center?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 jackson: # 统一出参时间格式前端不用再自己格式化 date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true # 下划线转驼峰entity 不用写 TableField log-impl: org.apache.ibatis.logging.stdout.StdOutImpl参数含义逐个说serverTimezoneAsia/Shanghai解决的是 JDBC 驱动和 MySQL 服务端时区解释不一致的问题不加它LocalDateTime写进去再读出来可能差 8 小时allowPublicKeyRetrievaltrue是 MySQL 8 默认加密插件在没配 SSL 时的必需项不加会报Public Key Retrieval is not allowedmap-underscore-to-camel-case打开后create_time自动映射到createTime省掉大量TableField注解。启动步骤按顺序走# 1. 建库字符集必须是 utf8mb4否则会员昵称里的表情符号存不进去 mysql -uroot -p -e CREATE DATABASE swim_center DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 导入表结构和初始数据顺序不能反 mysql -uroot -p swim_center sql/schema.sql mysql -uroot -p swim_center sql/data.sql # 3. 编译打包课设阶段没必要跑测试拖时间 mvn clean package -DskipTests # 4. 启动生产环境把 dev 换成 prod日志级别和连接池参数会跟着切 java -jar target/swim-center-1.0.0.jar --spring.profiles.activedev跑起来之后先访问登录接口拿 token再拿 token 去调会员列表能返回数据就说明数据源、MyBatis 映射、鉴权拦截器三关都通了。这一步别急着看前端页面接口通不通是根页面白屏十有八九是跨域或者 token 没带。提示data.sql里一般会预置一个管理员账号和几条卡类型字典。密码如果存的是 MD5第一次登录后记得在后台改掉别直接拿去部署。3. 核心业务拆解从卖一张票到放一个人进场3.1 会员卡与余额扣减先查后改为什么一定会出事余额扣减是这个项目里最容易写错的地方。新手版本的写法通常是先select出卡对象判断balance amount然后setBalance(balance - amount)再update。单机单线程跑起来毫无问题一开并发就翻车两个请求同时读到余额 100各自判断通过各自写回 0 和 0实际扣了两次但账上只少了一次。正确的做法是把判断和更新压到一条 SQL 里由数据库保证原子性影响行数就是唯一判据。Service RequiredArgsConstructor public class MemberCardService { private final MemberCardMapper cardMapper; private final CardFlowMapper flowMapper; /** * 扣减余额 * param cardId 会员卡主键 * param amount 扣减金额单位分 * param bizNo 业务单号用于流水幂等 */ Transactional(rollbackFor Exception.class) public void deductBalance(Long cardId, Long amount, String bizNo) { // 条件更新余额不足、卡冻结、卡过期三种情况都会让 rows 为 0 int rows cardMapper.deductBalance(cardId, amount); if (rows 0) { throw new BizException(卡余额不足或卡状态不可用); } // 流水落库biz_no 上建唯一索引重复提交会直接抛 DuplicateKeyException CardFlow flow new CardFlow(); flow.setCardId(cardId); flow.setAmount(-amount); flow.setBizNo(bizNo); flow.setCreateTime(LocalDateTime.now()); flowMapper.insert(flow); } }对应的 Mapper SQLupdate iddeductBalance UPDATE member_card SET balance balance - #{amount}, update_time NOW() WHERE id #{cardId} AND status 1 AND (expire_time IS NULL OR expire_time gt; NOW()) AND balance gt; #{amount} /update三个参数的作用分别是cardId定位到具体一行InnoDB 会在这行上加排他锁amount是扣减额用减法而不是覆盖赋值天然避免丢失更新bizNo不进这条 SQL但它是流水表的唯一键同一个业务单号重复调用第二次会直接抛唯一键冲突相当于给接口加了一层幂等。为什么不用select ... for update不是不能用而是它把锁的持有时间拉长了中间还要走一遍 Java 判断代码路径变长就多一个出错点。条件更新一条语句解决锁的范围和时长都是最小的。金额单位统一用「分」存bigint这一点后面还会提到它是对账能不能对上的分水岭。3.2 场次容量一条带 where 的 update 挡住超卖游泳馆和普通票务不一样它有时段概念。上午场限 200 人卖满就不能再卖否则池子里站不下。这个「卖满」的控制本质和秒杀扣库存是同一类问题。表里给swim_session放两个字段capacity可售人次sold已售人次。下单时不是先查sold capacity再更新而是直接带着条件去更新。Transactional(rollbackFor Exception.class) public Long buyTicket(Long sessionId, Long memberId, Long ticketTypeId) { // 第一步占坑。只有 sold capacity 时才会 1返回 0 说明已满 int rows sessionMapper.tryOccupy(sessionId); if (rows 0) { throw new BizException(该场次已满请选择其他场次); } // 第二步落订单。订单插入失败会触发整个事务回滚把刚占的坑位吐回去 TicketOrder order new TicketOrder(); order.setOrderNo(OrderNoUtil.next()); order.setMemberId(memberId); order.setSessionId(sessionId); order.setTicketTypeId(ticketTypeId); order.setStatus(1); // 1 待入场 order.setCreateTime(LocalDateTime.now()); orderMapper.insert(order); return order.getId(); }update idtryOccupy UPDATE swim_session SET sold sold 1 WHERE id #{sessionId} AND status 1 AND sold lt; capacity /update这段的关键在于「相对更新」而不是「绝对值更新」。sold sold 1是在数据库当前值基础上加多个事务并发进来InnoDB 的行锁会把它们排成队每个事务看到的都是上一个事务提交后的值sold capacity这个条件在排队过程中逐个判定第 201 个人拿到的rows必然是 0。status 1这个条件也别省。场次被后台手动关闭之后即使没卖满也不该继续放票把它写进 where 里关闭动作和售卖动作天然互斥。注意这里整段必须在同一个事务里。如果占坑和落订单分别提交订单插入失败时坑位不会回滚sold就会越涨越高最后场次明明没人却显示已满这种脏数据排查起来极其费劲。3.3 入场核销扫码扫两次账不能记两条前台核销是这个系统里调用频率最高、也最容易出并发的接口。客人拿着二维码在闸机前晃网络抖一下前端重试一次或者工作人员手快点两下就会出现同一个订单进两次的情况。核销接口必须做到幂等。设计上走状态机订单状态 1 是待入场2 是已入场3 是已退票。核销就是把 1 推到 2推不动就说明已经处理过了。Transactional(rollbackFor Exception.class) public void checkIn(String orderNo, String gateNo) { TicketOrder order orderMapper.selectByOrderNo(orderNo); if (order null) { throw new BizException(订单不存在); } // 带原状态的条件更新只有当前还是 1 才能推到 2 int rows orderMapper.updateStatus(order.getId(), 1, 2); if (rows 0) { // 推不动有两种可能已核销状态 2或已退票状态 3 // 已核销直接静默返回重复扫码不弹红已退票才需要报错 TicketOrder latest orderMapper.selectById(order.getId()); if (latest.getStatus() 2) { return; } throw new BizException(该订单状态不可入场); } EntryRecord record new EntryRecord(); record.setOrderId(order.getId()); record.setMemberId(order.getMemberId()); record.setSessionId(order.getSessionId()); record.setGateNo(gateNo); record.setEntryTime(LocalDateTime.now()); entryRecordMapper.insert(record); }update idupdateStatus UPDATE ticket_order SET status #{toStatus}, update_time NOW() WHERE id #{id} AND status #{fromStatus} /updateupdateStatus的三个参数是订单主键、期望的原状态、目标状态本质是一个 CAS 操作。并发两次请求只有一个能把status 1改成 2另一个影响行数为 0走静默返回分支。数据库层面再加一道保险entry_record表上给order_id建唯一索引。就算哪天有人绕过 Service 直接写 Mapper或者事务传播行为配错导致状态更新和记录插入不在一个事务里唯一索引也会在第二次插入时抛异常把脏数据挡在门外。这种「代码逻辑 数据库约束」双保险的写法在涉及钱和进出的业务里我基本都会加上。3.4 日结报表一条聚合 SQL 加一层 VO场馆老板每天最关心的就三件事今天卖了多少张、收了多少钱、会员占多少。这个需求用一条 SQL 就够了不需要在 Java 里循环累加。SELECT DATE(o.create_time) AS bizDate, COUNT(*) AS orderCnt, SUM(o.amount) AS totalAmount, SUM(CASE WHEN o.member_id IS NOT NULL THEN 1 ELSE 0 END) AS memberCnt, SUM(CASE WHEN o.source 2 THEN 1 ELSE 0 END) AS offlineCnt FROM ticket_order o WHERE o.status IN (1, 2) -- 排除已退票 AND o.create_time gt; #{startTime} AND o.create_time lt; #{endTime} -- 用小于而不是小于等于避开边界重复 GROUP BY DATE(o.create_time) ORDER BY bizDate DESC几个细节值得说。SUM(o.amount)依赖amount是整数分如果是decimal或者double累加几十万条之后尾差会显现出来对账时差一分钱能让人查一下午。create_time start AND create_time end这种左闭右开的写法是为了避免BETWEEN在跨天统计时把 23:59:59 之后的数据漏掉或者重复算。返回的 VO 里totalAmount在序列化前除以 100 转成元用BigDecimal做除法并指定保留两位别直接用double除浮点误差在小数位上会现形。public class DailyReportVO { private String bizDate; private Integer orderCnt; private BigDecimal totalAmount; // 出参已转成元保留两位 private Integer memberCnt; private Integer offlineCnt; }报表接口一般还要支持按场次维度再切一刀那就把GROUP BY DATE(o.create_time)换成GROUP BY o.session_id其余聚合逻辑不变。同一个 Mapper 里放两个selectService 层根据前端传的dimension参数分发比写两套 Controller 干净。4. 数据表与 SQL十几张表撑起一个场馆4.1 核心表清单与字段取舍整个系统的表不算多十四五张能覆盖主要业务。按模块归一下表名作用关键字段备注member会员档案name、phone、status手机号建唯一索引card_type卡类型字典type、price、times、valid_daystype1 储值 2 次卡 3 期限卡member_card会员持卡card_no、balance、remain_times、expire_time金额单位分swim_session场次start_time、capacity、sold、status售卖核心表ticket_order票订单order_no、session_id、amount、status状态机 1/2/3entry_record入场记录order_id、gate_no、entry_timeorder_id唯一card_flow卡流水card_id、amount、biz_nobiz_no唯一幂等用coach教练name、level、phone排课关联course_schedule课程排期coach_id、start_time、capacity二次开发高频改动点locker储物柜no、status、member_id可选模块water_quality水质记录ph、chlorine、check_time应付检查用sys_user/sys_role/sys_menu权限三件套—配关联表字段取舍上有个原则凡是涉及钱和次数的一律用整数存最小单位。balance存分、remain_times存整数次数不用decimal也不用double。看着不优雅但省掉了所有浮点比较的麻烦balance amount直接比大小不用考虑0.1 0.2 ! 0.3这种玄学问题。时间字段统一用datetime而不是timestamp。timestamp有 2038 年上限而且会随时区转换导出报表给别人时容易对不上。datetime存的是字面值存什么读什么配合LocalDateTime用起来最省心。4.2 三张关键表的建表脚本swim_session是全场最热的一张表读写都集中在这。索引除了主键只留一个时间范围查询用的联合索引。CREATE TABLE swim_session ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, name varchar(64) NOT NULL COMMENT 场次名如上午场, start_time datetime NOT NULL COMMENT 开场时间, end_time datetime NOT NULL COMMENT 散场时间, capacity int NOT NULL DEFAULT 0 COMMENT 可售人次, sold int NOT NULL DEFAULT 0 COMMENT 已售人次, status tinyint NOT NULL DEFAULT 1 COMMENT 1开放 0关闭, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_time (start_time, end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT场次表;entry_record的重点在唯一索引它是核销幂等的最后一道防线。CREATE TABLE entry_record ( id bigint NOT NULL AUTO_INCREMENT, order_id bigint NOT NULL COMMENT 票订单主键, member_id bigint DEFAULT NULL, session_id bigint NOT NULL, gate_no varchar(32) DEFAULT NULL COMMENT 闸机编号, entry_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order (order_id), -- 一个订单只能进一次 KEY idx_session_time (session_id, entry_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT入场记录;member_card的card_no唯一索引用来防止重复发卡member_id上的普通索引用来做「查这个人的所有卡」这个高频查询。CREATE TABLE member_card ( id bigint NOT NULL AUTO_INCREMENT, member_id bigint NOT NULL, card_no varchar(32) NOT NULL COMMENT 卡号可扫码, card_type_id bigint NOT NULL, balance bigint NOT NULL DEFAULT 0 COMMENT 余额单位分, remain_times int NOT NULL DEFAULT 0 COMMENT 次卡剩余次数, expire_time datetime DEFAULT NULL, status tinyint NOT NULL DEFAULT 1 COMMENT 1正常 0冻结 2作废, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_card_no (card_no), KEY idx_member (member_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会员卡;ON UPDATE CURRENT_TIMESTAMP这个细节省事任何一次 update 都自动刷新update_time不用在每条 SQL 里手写。但要注意如果哪天做数据同步需要保留原始时间得显式在 update 语句里给update_time赋值否则会被自动覆盖。4.3 慢查询一般出在哪这个体量的系统数据量不上百万基本不会慢。真正出问题的地方集中在两类查询上。第一类是报表的日期范围扫描。ticket_order表只建了主键索引WHERE create_time BETWEEN ...会走全表扫。解决办法是给create_time单独建索引或者建(status, create_time)的联合索引把状态过滤也带上。联合索引的字段顺序按「等值在前、范围在后」排status IN (1,2)是等值create_time是范围顺序不能反。第二类是后台列表的模糊搜索。会员列表按姓名或手机号搜LIKE %张%前置通配符会让索引失效。这个场景数据量一般不大实在要优化就上全文索引或者把手机号改成前缀匹配LIKE 138%。别为了这个去引 ES运维成本远超收益。还有一种隐蔽的慢来自 N1 查询。订单列表里每条记录都要显示会员姓名和场次名称如果循环里逐条查一页 20 条就是 40 次查询。改成 MyBatis 的collection嵌套查询或者直接在 SQL 里LEFT JOIN一次搞定。XML 里写 join 比在 Java 里拼装可读性高得多。4.4 初始化数据与字典data.sql里必须预置的东西有四类管理员账号、卡类型字典、场次模板、菜单权限。-- 管理员密码是 BCrypt 加密后的 123456首次登录必须改 INSERT INTO sys_user (id, username, password, real_name, status) VALUES (1, admin, $2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi, 管理员, 1); -- 卡类型储值卡、次卡、月卡 INSERT INTO card_type (id, name, type, price, times, valid_days) VALUES (1, 储值卡, 1, 0, 0, NULL), (2, 十次卡, 2, 300, 10, 180), (3, 月卡, 3, 500, 0, 30); -- 场次模板日期部分按实际排期改 INSERT INTO swim_session (name, start_time, end_time, capacity, sold, status) VALUES (上午场, 2024-07-01 08:00:00, 2024-07-01 12:00:00, 200, 0, 1), (下午场, 2024-07-01 13:00:00, 2024-07-01 17:00:00, 200, 0, 1), (夜场, 2024-07-01 18:00:00, 2024-07-01 22:00:00, 150, 0, 1);密码用 BCrypt 而不是 MD5是因为 MD5 撞库太容易而且同一个密码每次加密结果一样拖库之后一眼就能看出哪些用户密码相同。BCrypt 自带盐值每次加密结果不同校验时用matches比对。菜单数据插入后登录接口返回的菜单树才有内容前端侧边栏不会空着。这块数据建议直接从 SQL 脚本导入别在代码里硬编码改权限的时候只改数据不改代码。5. 避坑与排查五个坑每一个都能卡住半天5.1 环境和启动阶段的三个坑现象启动报Communications link failure日志里连不上数据库。原因通常是三种MySQL 服务没起、端口不是 3306、或者 8.0 版本必须带allowPublicKeyRetrievaltrue而配置里漏了。 解决顺序是先telnet 127.0.0.1 3306确认端口通再检查 URL 参数。如果是 Docker 起的 MySQL注意容器内外的端口映射本地连127.0.0.1没问题但配置里写成容器名就需要在同一网络里。现象页面上的时间和数据库里存的差 8 小时。原因是时区在三处各管一段JDBC 连接的serverTimezone、Jackson 序列化的time-zone、MySQL 服务端的time_zone。哪一处没对齐都会偏。 解决是把三处统一成Asia/Shanghai并且在实体类上不要再用java.util.Date统一换成LocalDateTime。Date会受 JVM 默认时区影响LocalDateTime是明确的本地时间语义少一层解释就少一个坑。现象前端拿到的id末尾几位变成 0或者详情页查不到数据。原因是雪花算法生成的长整型超过了 JavaScript 的Number.MAX_SAFE_INTEGERJSON 反序列化时精度丢失。 解决有两种后端全局配置 Jackson 把Long序列化成字符串或者给主键字段加JsonSerialize(using ToStringSerializer.class)。我一般选全局配置一次性解决所有实体省得漏加。Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer longToString() { return builder - builder .serializerByType(Long.class, ToStringSerializer.instance) .serializerByType(Long.TYPE, ToStringSerializer.instance); } }这个配置加上之后前端所有id都是字符串路由传参和比较时要注意别用直接比数字血泪经验。5.2 业务和数据层面的两个坑现象对账时总额差几分钱逐条核对又看不出问题。原因八成是金额字段用了double或者float累加过程中产生了浮点误差。0.1 0.2 0.30000000000000004这种事在单条记录上看不出来聚合之后就显形了。 解决是把金额全部改成bigint存分Java 侧用Long只在展示层除以 100 转BigDecimal。改的时候要全表扫一遍凡是decimal(10,2)的金额字段都换成bigintSQL 里的SUM结果再除。这个改造越早做越省事等数据攒了几十万条再改迁移脚本能写到你怀疑人生。现象Service 方法上明明加了Transactional抛异常后数据还是写进去了。原因有四种常见情况同类内部方法自调用代理不生效异常被try-catch吞掉没有重新抛出方法不是public异常类型是受检异常而注解没配rollbackFor。 排查方法是先看调用是不是从外部进来的再看 catch 块里有没有throw最后确认注解写了rollbackFor Exception.class。Spring 默认只对RuntimeException回滚业务里抛的自定义异常如果继承的是Exception不加这个参数就不会回滚这个坑我见过不止一次。// 错误示范内部自调用事务注解形同虚设 public void outer() { this.inner(); // 走的是 this不是代理对象 } Transactional(rollbackFor Exception.class) public void inner() { /* ... */ }正确做法是把inner挪到另一个 Bean 里或者注入自身代理Lazy MemberCardService self再调self.inner()。后者写法简单但有点绕前者结构更清晰我一般选前者。现象核销接口偶发Duplicate entry异常日志里一片红。原因不是 bug恰恰是唯一索引在起作用——同一个订单被并发核销了两次第二次插入被挡下来了。但这个异常如果直接抛到前端工作人员会以为系统坏了。 解决是在 Service 层捕获DuplicateKeyException当成「已核销」处理返回成功即可。同时把状态更新的 CAS 逻辑补上让绝大多数重复请求在第一步就被拦住唯一索引只做兜底。6. 二次开发加一个培训课程报名模块要动哪几层场馆跑顺之后老板十有八九会提一句「顺便把游泳培训班的报名也管起来」。这个需求看着是新增实际是复用改动清单可以列得很清楚。层次改动内容复用点表新增course、course_schedule、course_booking容量字段和swim_session同构实体/Mapper三张表各生成一套MyBatis-Plus 代码生成器直接跑ServiceCourseBookingService.book()直接套 3.2 的tryOccupy模式Controller报名、取消、我的课程三个接口鉴权和统一返回体沿用菜单sys_menu插三条记录绑定角色后台刷新即可见核心的报名扣名额逻辑和票务售卖是同一个套路把表名换掉就行Transactional(rollbackFor Exception.class) public Long book(Long scheduleId, Long memberId) { // 和场次占坑完全一致的 CAS 写法只是换了张表 int rows scheduleMapper.tryBook(scheduleId); if (rows 0) { throw new BizException(该课程已报满); } // 报名记录上给 (schedule_id, member_id) 建唯一索引防止同一个人重复报 CourseBooking booking new CourseBooking(); booking.setScheduleId(scheduleId); booking.setMemberId(memberId); booking.setStatus(1); bookingMapper.insert(booking); return booking.getId(); }验证的时候别只点页面。用ab或者 JMeter 对报名接口打 200 并发抢 50 个名额跑完查SELECT COUNT(*) FROM course_booking WHERE schedule_id ?是不是正好 50再看sold字段和实际记录数对不对得上。这个验证方法比点一百次页面都管用而且能顺带压出连接池配置的问题。还有一个容易忽略的点新模块上线前把course_booking的唯一索引和course_schedule的sold capacity条件一起写进回归清单。后来加功能的人如果只改了 Service 没动 SQL接口层面测不出问题只有并发场景才会暴露。从那以后我每次给这类系统加新模块都强制先跑一遍并发脚本再提测宁可多花二十分钟也不想半夜被电话叫起来查「为什么多卖了八张票」。希望这些拆解能帮你少走点弯路。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表