☰
基于Spring Boot的游泳馆管理系统:会员卡扣减、场次售票与核销
2026/10/29 2:28:30 网站建设 项目流程

简介:「游泳馆管理系统」是一套面向高校计算机与数据库课程设计的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 塞进ThreadLocal,Service 层随时能拿到当前操作人。模板方案省一个工程,但核销页要单独做移动端适配时会很难受,我一般还是倾向分离。

权限这块,项目里通常是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 不写@Transactional,Mapper 也不写,事务边界统一收在 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true 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

参数含义逐个说:serverTimezone=Asia/Shanghai解决的是 JDBC 驱动和 MySQL 服务端时区解释不一致的问题,不加它,LocalDateTime写进去再读出来可能差 8 小时;allowPublicKeyRetrieval=true是 MySQL 8 默认加密插件在没配 SSL 时的必需项,不加会报Public Key Retrieval is not allowed;map-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.active=dev

跑起来之后先访问登录接口拿 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 SQL:

<update id="deductBalance"> 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 id="tryOccupy"> 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 id="updateStatus"> UPDATE ticket_order SET status = #{toStatus}, update_time = NOW() WHERE id = #{id} AND status = #{fromStatus} </update>

updateStatus的三个参数是订单主键、期望的原状态、目标状态,本质是一个 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 里放两个select,Service 层根据前端传的dimension参数分发,比写两套 Controller 干净。

4. 数据表与 SQL:十几张表撑起一个场馆

4.1 核心表清单与字段取舍

整个系统的表不算多,十四五张能覆盖主要业务。按模块归一下:

表名作用关键字段备注
member会员档案name、phone、status手机号建唯一索引
card_type卡类型字典type、price、times、valid_daystype:1 储值 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/3
entry_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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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,运维成本远超收益。

还有一种隐蔽的慢,来自 N+1 查询。订单列表里每条记录都要显示会员姓名和场次名称,如果循环里逐条查,一页 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 版本必须带allowPublicKeyRetrieval=true而配置里漏了。 解决顺序是先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_INTEGER,JSON 反序列化时精度丢失。 解决有两种:后端全局配置 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)的金额字段都换成bigint,SQL 里的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,接口层面测不出问题,只有并发场景才会暴露。

从那以后我每次给这类系统加新模块,都强制先跑一遍并发脚本再提测,宁可多花二十分钟,也不想半夜被电话叫起来查「为什么多卖了八张票」。希望这些拆解能帮你少走点弯路。

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

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

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

立即咨询