如果你最近在找SpringBoot实战项目练手,或者正在为毕业设计、课程设计头疼,“基于SpringBoot游乐场门票购买平台”这种题目几乎绕不开。我当初接到这个需求时,第一反应也是“这不就是套CRUD模板吗”,直到动工之后才发现,门票购买和普通商品购买有本质区别:门票卖的是“特定日期的稀缺库存”,同一个游乐场同一天可能只放几千张票,一旦碰上节假日促销,瞬时流量能把数据库打到直接崩溃。这篇文章把我从需求分析、数据库设计、核心下单链路到Docker部署的完整过程梳理了一遍,重点放在那些文档里不写、但真实项目一定踩得上的坑上。适合正在做SpringBoot实战项目的开发者和准备拿此类题目做毕设的同学参考。
1. 需求拆解:门票平台最容易被忽视的四个边界条件
很多人在动手写代码之前,对着题目就开始建表,结果做到一半发现需求没想清楚,反复重构。做这种“系统设计类”项目,需求拆解的优先级一定高于技术选型。先把业务边界画清楚,后面写代码才不需要推倒重来。
1.1 用户端:搜索、下单、支付的完整闭环
用户端的核心链路并不复杂:游客进入平台,浏览景区列表,点进景区详情页,看到该景区下的不同票种(成人票、儿童票、家庭套票等),选择入园日期和购买数量,提交订单,然后支付,支付完成后在“我的订单”里查看电子票二维码或核销码,入园时验票。
这里有一个很多人忽略的边界条件:门票的库存是“按日维度”的。普通商品的库存是全局一个数字,卖完就没了;门票则完全不同,同一个票种,7月1日的票可能已经卖光,7月2日的票还有大量剩余。所以票种设计必须考虑“日期”这个维度,库存表不能简单地挂在票种表下面。
再往下细化,用户端还包含注册登录、个人信息维护、历史订单查询、取消订单等基础功能。注册登录这里我建议直接用手机号加密码,不要一上来就搞验证码、第三方登录之类的花活,核心业务才是你展示技术能力的地方,登录做好JWT鉴权就够了。
1.2 管理端:票务、订单、数据的后台支撑
管理端是体现项目完整度的地方。至少要做四块内容:
- 景区管理:景区的新增、修改、上下架,维护景区名称、地址、介绍、封面图。
- 票种管理:维护每个景区下的票种,设置票种名称、原价、售价、每日库存上限、售卖状态。
- 订单管理:查看所有订单、按状态筛选、手动取消异常订单、处理退款申请。
- 数据统计:展示累计销售额、今日订单量、热门景区排行等关键指标。
管理端的实现难度不高,但它是评委和导师最关注的部分。因为用户端是“面子”,管理端是“里子”,一个连后台都没有的平台,不能称之为完整的系统。
1.3 容易被忽视的非功能需求
这类项目在答辩或面试时,拉开差距的往往不是功能做完没有,而是几个非功能需求处理得怎么样:
- 并发下单不超卖。这是门票平台最核心的挑战,库存就那么多,同一时刻可能有上万人抢同一天的票,数据库行锁和乐观锁怎么配合,直接决定系统会不会爆。
- 支付回调必须幂等。支付平台会多次回调同一个订单,如果处理逻辑没做幂等,订单状态会被重复更新,账户流水会重复记录。
- 未支付订单占用的库存要释放。用户下单锁定了库存但迟迟不支付,这部分的票既没卖出去又买不了,必须靠超时机制释放回库存池。
- 接口安全。防止有人恶意刷接口、伪造支付回调,这些在你做项目时可能不会遇到,但面试官一定会问。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型和版本决策:SpringBoot生态的取舍
技术选型没有标准答案,但每一层选择都要能说出理由。很多同学在答辩时被问“你为什么用这个技术”,只会答“大家都在用”,这种回答基本等于送命。这里把我的选型思路和踩过的版本坑一起说一下。
2.1 为什么选SpringBoot + MyBatis-Plus + Redis这套组合
后端框架用SpringBoot,这是题目本身定的,没有悬念。但ORM框架的选择值得聊聊。我见过不少人用Spring Data JPA,做完之后发现复杂的统计SQL写起来非常痛苦;也有人坚持原生MyBatis,虽然灵活,但单表CRUD要写大量XML,项目周期会被拖长。
我最终选了MyBatis-Plus。理由很简单:
- 单表CRUD零SQL,BaseMapper直接提供增删改查,开发效率高一截。
- 内置分页插件、乐观锁插件,这两点在门票库存场景里几乎是开箱即用。
- LambdaQueryWrapper写条件查询很顺手,代码可读性比字符串拼接强很多。
Redis在这个项目里承担三个职责:缓存热点门票数据,降低数据库压力;保存登录token,实现分布式会话状态;在后期的限流方案中充当计数器。虽然有同学说“项目小不用Redis也行”,但既然题目是SpringBoot平台类项目,引入Redis是合理且必要的,热点门票缓存这块也是面试时很好的加分点。
数据库选MySQL,存储引擎必须用InnoDB。这个点一定要记住,MySQL 5.7以后默认就是InnoDB,但如果有人手动改过表引擎,MyISAM不支持事务,你的订单和库存操作会直接出大问题。
社区里有句话叫“SpringBoot的自动装配是它最强大的能力,也是新手最迷惑的地方”。启动类上的@SpringBootApplication是个组合注解,核心是@EnableAutoConfiguration,它通过读取META-INF/spring.factories文件(SpringBoot 3.x是AutoConfiguration.imports)里注册的自动配置类,配合@ConditionalOnClass、@ConditionalOnMissingBean等条件注解,按需加载配置。理解了这个原理,以后排查“为什么我配了Redis连接不生效”“为什么数据源没被自动配置”这类问题会从容得多。
2.2 SpringBoot版本选择:2.7与3.x的差别
版本选择是这类项目第一个暗坑。最近社区里“springboot版本太高”的声音越来越多,主要就是SpringBoot 3.x带来的两个变化:
- 强制要求JDK 17以上,很多学校机房和老服务器还是JDK 8。
- javax.servlet包改名为jakarta.servlet,老教程里的import代码直接编译不过。
很多同学在IDEA里新建项目时顺手选了最新版3.x,结果导入MyBatis-Plus、Swagger等依赖时发现一堆兼容性问题,心态直接崩了。
我的建议是分场景:
- 如果这是毕业设计、课程设计,或者你的开发环境是JDK 8,稳定选择SpringBoot 2.7.18。这是2.x系列的最终维护版本,资料最多,踩坑成本最低,MyBatis-Plus、Springfox等生态完全兼容。
- 如果是新公司的新项目,团队愿意上JDK 17,那可以选3.x,但要接受生态迁移成本。
我这次项目最终用的是SpringBoot 2.7.18 + JDK 8 + MyBatis-Plus 3.5.3。实际开发中,这套组合几乎没有任何兼容性摩擦。
核心依赖如下:
xml复制<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
</dependencies>
2.3 工程结构与模块划分
项目规模不大,没必要上微服务,单体应用加前后端分离就是最合适的架构。好处是部署简单、事务好控制,不用处理分布式事务那一堆麻烦事。
后端工程结构建议按业务分层,包名规范清晰:
text复制com.playground.ticket
├── controller // 控制器层,接收请求
├── service // 业务逻辑层,核心事务都在这里
│ └── impl
├── mapper // MyBatis-Plus的数据访问层
├── entity // 数据库实体类
├── dto // 传输对象,接收前端参数
├── vo // 视图对象,返回给前端的数据
├── config // 配置类(Redis、CORS、MyBatis-Plus分页插件)
├── common // 统一返回结果、异常处理、常量
└── utils // JWT工具、订单号生成工具等
我见过很多项目把所有逻辑塞在Controller里,一个方法几百行代码,测试没法写,后期维护更是灾难。Controller只负责参数接收和结果返回,业务逻辑一律下沉到Service层,这是单体项目最基础的规范。
前端我用的Vue3 + Element Plus,管理端和用户端分开两个页面应用,通过nginx代理转发到后端接口。这种前后端分离的架构在DevOps和部署阶段还能多展示一项技能,何乐而不为。
3. 数据库设计:从购买流程反推表结构和状态机
数据库设计我个人的方法很笨但很有效:把用户从注册到入园全流程走一遍,每一步涉及什么数据,就建什么表。千万不要脑子一热先把表建了,而是把核心的“下单、支付、验票”流程在纸上画清楚。
3.1 六张核心表的设计思路
用户表(user)不细说,就是id、手机号、密码、昵称、头像、创建时间这些常规字段,手机号需要加唯一索引。
景区表(scenic_area)记录景区基础信息,包含景区名称、详细地址、介绍、封面图URL、状态。状态字段用来做上下架。
票种表(ticket_category)字段如下:
sql复制CREATE TABLE ticket_category (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
scenic_id BIGINT NOT NULL COMMENT '所属景区ID',
name VARCHAR(50) NOT NULL COMMENT '票种名称,如成人票/儿童票',
price DECIMAL(10,2) NOT NULL COMMENT '售价',
original_price DECIMAL(10,2) DEFAULT NULL COMMENT '原价',
sale_status TINYINT DEFAULT 1 COMMENT '售卖状态 1上架 0下架',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_scenic_id (scenic_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='票种表';
日库存表(ticket_stock)是容易被忽视的一张关键表。它的作用是记录某个票种在某一天的库存情况。设计成单独的表而不是把总库存放在票种表里,是因为门票是强日期的库存资源,按天维度管理才能支撑查询和扣减。
sql复制CREATE TABLE ticket_stock (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
ticket_id BIGINT NOT NULL COMMENT '票种ID',
stock_date DATE NOT NULL COMMENT '库存日期',
total_stock INT NOT NULL COMMENT '该天总库存',
remain_stock INT NOT NULL COMMENT '剩余库存',
version INT DEFAULT 0 COMMENT '乐观锁版本号',
UNIQUE KEY uk_ticket_date (ticket_id, stock_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='日库存表';
订单表(orders)是核心中的核心。订单号、用户ID、票种ID、景区ID、游玩日期、数量、金额、状态、创建时间、支付时间、过期时间。订单状态是整个系统的状态机枢纽,后面会单独说。
支付流水表(payment_log)记录每笔支付请求和回调信息,订单ID加唯一索引是幂等操作的基础。
3.2 订单状态机与库存扣减时机
订单状态我用数字表示,方便加索引和判断:
text复制0 待支付
1 已支付
2 已取消
3 已退款
4 已使用(核销)
状态流转关系是:待支付可以走到已支付,也可以走到已取消;已支付可以走到已退款,也可以走到已使用。不存在从已取消直接到已支付的非法状态,代码里要加状态校验。
库存扣减时机是这类项目里最容易引发争议的设计点。方案有两种:
| 方案 | 逻辑 | 优点 | 缺点 |
|---|---|---|---|
| 下单时扣库存 | 用户提交订单立即扣减日库存,超时未支付再回滚 | 库存锁定及时,不会超卖 | 需要完善的超时释放机制 |
| 支付时扣库存 | 用户支付成功才扣减库存 | 不会占用库存 | 高并发下可能出现下单成功但支付时库存已无 |
我做的是下单时扣库存。原因很直接:门票和普通商品不一样,节假日热门票本来就是稀缺的,用户下单成功但支付时被提示没票,体验非常糟糕。宁可下单锁库存,超时未支付再释放,这是主流票务平台的通用做法。
有了这个决策,订单表里就需要一个expire_time字段。我设置为15分钟,下单时用创建时间加15分钟赋值,定时任务扫描超时订单时直接比较这个字段,不用做复杂计算。
3.3 索引、唯一约束、事务隔离级别
索引和约束是保证系统稳定性的基础设施,很多新手建表时不加索引,数据量一上来就慢查询。这个项目里的关键索引有:
- orders表:order_no加唯一索引,这是业务幂等的最底层兜底。
- orders表:user_id + create_time建联合索引,支撑“我的订单”分页查询。
- ticket_stock表:ticket_id + stock_date建唯一索引,防止同一票种同一天的库存数据被重复插入。
- payment_log表:order_id加唯一索引,支付回调重复处理时,数据库直接拒绝。
事务隔离级别保持MySQL默认的REPEATABLE READ即可,不需要改全局配置。扣减库存的操作可以依靠行锁和乐观锁来保证精确性,不用把隔离级别调成SERIALIZABLE,那会影响整体并发性能。
4. 核心链路实现:门票详情、下单防超卖、支付幂等
如果说前面的工作是打地基,那这一部分是整栋楼的主体结构。我把用户最核心的“浏览-下单-支付”链路拆开讲,每个环节都有代码级别的细节。
4.1 门票查询:缓存穿透与击穿的兜底
用户端的门票详情页,一天可能被访问几十万次。如果没有缓存,每次请求都去MySQL查景区、票种、库存三张表,高峰期数据库压力非常大。我设计了两个层级的缓存:
- 景区基础信息缓存,key为scenic:info:{id}。
- 票种和当日库存聚合缓存,key为scenic:ticket:{scenicId}:{date},value是对应日期下所有票种的列表及余票数。
缓存策略用的是Cache Aside模式,读取时先查Redis,命中直接返回,未命中查数据库再回填Redis。这里必须考虑缓存穿透和击穿。
缓存穿透是指查询一个根本不存在的票种ID,请求直接打到数据库。我的处理方式是:如果查库结果为空,也在Redis里缓存一个空值,设置较短的过期时间,比如60秒。
缓存击穿是指某个热点票种的缓存恰好过期,大量请求同时打到数据库。解决思路是互斥锁,让一个线程去查库重建缓存,其他线程先等待。代码如下:
java复制public TicketDetailVO getTicketDetail(Long scenicId, LocalDate date) {
String key = "scenic:ticket:" + scenicId + ":" + date;
Object cache = redisTemplate.opsForValue().get(key);
if (cache != null) {
return JSON.parseObject(cache.toString(), TicketDetailVO.class);
}
// 未命中,加互斥锁重建缓存
String lockKey = "lock:ticket:" + scenicId + ":" + date;
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (!locked) {
// 没拿到锁,短暂睡眠后重试
Thread.sleep(50);
return getTicketDetail(scenicId, date);
}
try {
// 二次查缓存,避免自己重建后又查一次数据库
cache = redisTemplate.opsForValue().get(key);
if (cache != null) {
return JSON.parseObject(cache.toString(), TicketDetailVO.class);
}
TicketDetailVO vo = queryFromDb(scenicId, date);
redisTemplate.opsForValue().set(key, JSON.toJSONString(vo), Duration.ofMinutes(10));
return vo;
} finally {
redisTemplate.delete(lockKey);
}
}
这个简单的互斥锁能解决99%的击穿问题,比引入Redisson分布式锁要轻量得多。项目规模小,没必要把技术栈搞复杂。
4.2 下单扣库存:乐观锁与事务失效的坑
下单是并发风险最高的操作。我先说最终版的实现,再反过来复盘踩坑过程。
下单Service的核心方法如下:
java复制@Transactional(rollbackFor = Exception.class)
public OrderVO createOrder(CreateOrderDTO dto) {
// 1. 生成订单号
String orderNo = OrderNoGenerator.generate();
// 2. 基于票种和日期,先查日库存行
TicketStock stock = ticketStockMapper.selectOne(new LambdaQueryWrapper<TicketStock>()
.eq(TicketStock::getTicketId, dto.getTicketId())
.eq(TicketStock::getStockDate, dto.getStockDate()));
if (stock == null || stock.getRemainStock() < dto.getQuantity()) {
throw new BusinessException("库存不足");
}
// 3. 乐观锁扣减库存,条件带 remain_stock >= quantity
int rows = ticketStockMapper.updateRemainStock(stock.getId(), dto.getQuantity(), stock.getVersion());
if (rows == 0) {
throw new BusinessException("库存不足,请重试");
}
// 4. 插入订单记录(状态:待支付),设置过期时间15分钟
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(dto.getUserId());
order.setTicketId(dto.getTicketId());
// ... 其他字段
orderMapper.insert(order);
return OrderVO.from(order);
}
这里的核心是第3步的扣减SQL:
sql复制UPDATE ticket_stock
SET remain_stock = remain_stock - #{quantity},
version = version + 1
WHERE id = #{id}
AND remain_stock >= #{quantity}
AND version = #{version}
这个SQL有两个保险:version乐观锁保证不会出现“读到的库存是100,两个请求同时扣成-1”的情况;remain_stock >= quantity条件保证扣减后库存不为负。实际扣减行数为0时直接抛异常回滚,事务会把前面的操作全部撤销。
这里要注意一个非常经典的坑:事务失效。我在项目开发中就踩过同类调用导致@Transactional不生效的问题。具体来说,如果OrderService里的一个方法内部直接调用createOrder,比如:
java复制public void externalMethod(OrderDTO dto) {
this.createOrder(dto); // 直接通过this调用
}
Spring的事务是通过AOP代理实现的,你的Controller注入的是OrderService的代理对象。但方法内部的this指向的是原生对象,不是代理对象,所以@Transactional注解完全不起作用,事务不会开启。解决办法是注入自身代理,或者把createOrder放到另一个Service里。
事务失效的场景我还遇到过异常被吞的情况:开发者在方法里写try-catch,把异常捕获后打日志,没有继续向上抛。事务认为方法正常返回了,自然就提交了,但业务逻辑其实是失败的。正确做法是@Transactional方法内不要捕获异常,要么抛出去,要么手动使用TransactionTemplate。
还遇到过一个问题:用了MyISAM引擎的表,事务根本不生效。排查了好久才发现测试环境的某张表是MyISAM,被前人改过引擎。所有参与事务的核心表,引擎必须统一是InnoDB。
4.3 支付回调幂等处理
真实项目中对接支付平台是必做项,但平时自己写Demo往往用模拟支付代替。我的做法是先实现一个模拟支付的接口,同时留出对接真实微信支付回调的结构。
支付回调的幂等是必考题。流程如下:
java复制@Transactional(rollbackFor = Exception.class)
public void handlePayCallback(PayCallbackDTO callback) {
// 1. 先查支付流水,如果这个订单已经处理过,直接返回
PaymentLog existed = paymentLogMapper.selectOne(
new LambdaQueryWrapper<PaymentLog>()
.eq(PaymentLog::getOrderId, callback.getOrderId()));
if (existed != null && "SUCCESS".equals(existed.getPayStatus())) {
log.info("订单 {} 已处理过回调,跳过", callback.getOrderId());
return;
}
// 2. 插入支付流水,order_id 有唯一索引,重复插入会报错
try {
paymentLogMapper.insert(buildPaymentLog(callback));
} catch (DuplicateKeyException e) {
// 并发重复回调,数据库唯一索引拦截
return;
}
// 3. 更新订单状态为已支付
orderMapper.updateStatus(callback.getOrderId(), OrderStatus.PAID, OrderStatus.WAIT_PAY);
}
两道幂等防线:第一道是逻辑判断,查到了成功流水直接返回;第二道是数据库唯一索引,并发情况下两条重复回调同时进来,第一个插入成功,第二个插入直接撞唯一索引抛异常,被捕获后安全返回。
支付成功之后还有一个重要动作:更新日库存表中已售数量或者直接计算剩余可售,这个值可以从订单表聚合统计,也可以直接减remain_stock,我选择在支付成功时不动库存,因为下单时已经扣过了。
4.4 订单超时未支付:方案对比与落地
订单超时释放库存的方案,我在项目里做过三种方案的对比:
| 方案 | 实现成本 | 实时性 | 可靠性 | 适用场景 |
|---|---|---|---|---|
| 定时任务轮询 | 低 | 有延迟 | 高 | 项目初期推荐 |
| RabbitMQ延迟队列 | 中 | 高 | 高 | 需要引入MQ中间件 |
| Redis过期key监听 | 低 | 高 | 低 | 不推荐,过期事件可能丢失 |
项目初期我选的是定时任务轮询,用Spring自带的@Scheduled,每分钟扫描一次待支付订单,把超过expire_time的订单状态更新为已取消,并回滚库存。
java复制@Component
public class OrderTimeoutTask {
@Scheduled(fixedDelay = 30000)
public void closeExpiredOrders() {
List<Order> expiredOrders = orderMapper.selectList(new LambdaQueryWrapper<Order>()
.eq(Order::getStatus, OrderStatus.WAIT_PAY)
.lt(Order::getExpireTime, LocalDateTime.now())
.last("LIMIT 500"));
for (Order order : expiredOrders) {
try {
closeOneOrder(order);
} catch (Exception e) {
log.error("关闭超时订单失败,orderNo={}", order.getOrderNo(), e);
}
}
}
@Transactional(rollbackFor = Exception.class)
public void closeOneOrder(Order order) {
// 先尝试更新状态,条件是当前状态仍是待支付,防止重复关闭
int rows = orderMapper.compareAndSetStatus(order.getId(),
OrderStatus.WAIT_PAY, OrderStatus.CANCELED);
if (rows == 0) {
return;
}
// 回滚库存
ticketStockMapper.increaseRemainStock(order.getTicketId(),
order.getStockDate(), order.getQuantity());
}
}
fixedDelay=30000字面意思是处理完上一次任务后30秒再执行下一次,避免任务执行时间较长时产生堆积。limit 500是为了防止一次处理太多数据把数据库拖垮,分批执行更稳妥。
定时任务方案的延迟最多是扫描间隔加任务执行时间,对门票平台这种场景完全够用。如果后续要做秒杀级别的业务,再替换为延迟队列的方案即可。面试官问起来,能说出这个演进路径,本身就能拿分。
5. 管理后台与用户体系:JWT、角色权限、销量统计
管理后台的很多实现和用户端不一样,它更关注权限控制和数据聚合。这一部分我挑三个最有代表性的点来讲。
5.1 JWT登录与接口鉴权
用户登录成功后,签发一个JWT token返回给前端。之后前端每次请求在Header里带Authorization字段,后端通过拦截器统一解析。
JWT的密钥要配置在application.yml里,不要硬编码在代码中。我的配置是:
yaml复制jwt:
secret: your-secret-key-please-change-in-production
expire-hours: 24
拦截器的逻辑很简单:
java复制public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if ("OPTIONS".equals(request.getMethod())) {
return true; // 解决跨域预检请求
}
String token = request.getHeader("Authorization");
if (StringUtils.isBlank(token) || !token.startsWith("Bearer ")) {
throw new BusinessException(401, "未登录");
}
try {
Long userId = JwtUtils.parseToken(token.replace("Bearer ", ""));
UserContext.set(userId);
return true;
} catch (Exception e) {
throw new BusinessException(401, "登录已过期,请重新登录");
}
}
@Override
public void afterCompletion(...) {
UserContext.clear(); // 防止线程池复用导致用户信息串号
}
}
密码存储不要用MD5,直接使用BCrypt加密。Spring Security的crypto包里自带BCryptPasswordEncoder,单独引入也可以,注册时加密存储,登录时matches校验。
5.2 菜单角色权限控制
要做到“菜单角色管理”的完整效果,需要五张表:用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。但日常做课程设计和中小型项目,没必要搞这么复杂,我用的是简化的“单角色字段”方案。
用户表加一个role字段,取值TRAVELER或ADMIN。在拦截器里对/admin/**路径单独做一次管理员校验:
java复制if (request.getRequestURI().startsWith("/admin")) {
Long userId = UserContext.get();
User user = userService.getById(userId);
if (!"ADMIN".equals(user.getRole())) {
throw new BusinessException(403, "无权限访问");
}
}
前端管理端根据登录接口返回的role字段,动态生成菜单。管理员进入后台能看到景区管理、票种管理、订单管理、数据统计;普通用户则看不到这些入口。这套方案虽然简单,但“动态菜单+接口二次校验”的权限模型核心已经具备了,面试时能讲清楚RBAC的扩展方向就够了。
5.3 销量统计的SQL实践
数据统计是管理端展示技术实力的重头戏。我用三个查询覆盖了高频需求:
第一个,最近7天的销售额和订单量:
sql复制SELECT
DATE(create_time) AS day,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
WHERE status IN (1, 4)
AND create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
GROUP BY DATE(create_time)
ORDER BY day DESC;
第二个,热门景区Top5,需要联表查询:
sql复制SELECT
s.name AS scenic_name,
COUNT(DISTINCT o.id) AS order_count,
SUM(o.amount) AS sales_amount
FROM orders o
JOIN ticket_category t ON o.ticket_id = t.id
JOIN scenic_area s ON t.scenic_id = s.id
WHERE o.status IN (1, 4)
GROUP BY s.id, s.name
ORDER BY sales_amount DESC
LIMIT 5;
第三个,已支付订单的核销率:
sql复制SELECT
COUNT(*) AS total_paid_orders,
SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) AS used_orders,
CONCAT(ROUND(SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2), '%') AS usage_rate
FROM orders
WHERE status IN (1, 4);
MyBatis-Plus的@Select注解直接写这些SQL就好,不需要额外配置XML。对管理端报表这种场景,自定义SQL的灵活度超过Wrapper。
6. 部署上线与踩坑实录:从IDEA到Docker
项目写完之后,部署又是一道坎。热搜词里出现了“springboot jdk1.8打包到docker desktop”“docker部署springboot项目”,说明大家伙儿在这一步都遭遇过毒打。我把整个部署路径和遇到的坑完整记录下来。
6.1 项目初始化和配置文件的坑
IDEA里创建SpringBoot项目,如果用的是Spring Initializr,默认会选择一个比较新的SpringBoot版本。网络好的时候这一步很顺畅,网络不好会卡在下载依赖阶段。这里我的建议是:项目创建时可以先把镜像源切到阿里云,maven仓库的mirror也改成阿里云,能少等很长时间。
配置文件的坑比创建项目的坑多得多。我挨个说:
第一个是application.yml的缩进问题。yml对缩进极其敏感,不能用Tab,必须用空格。有一次我把一个key缩进错了,SpringBoot启动时直接报unknown property,排查了整整一下午。项目里可以引入spring-boot-configuration-processor依赖,IDEA对配置文件有更友好的提示。
第二个是MySQL连接URL的时区问题。JDBC连接串必须带serverTimezone参数:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/ticket_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
不指定时区会报Server returns invalid timezone的异常。这个也是社区里高频问题。
第三个是MyBatis-Plus的分页插件配置。如果没有配置PaginationInnerInterceptor,调用分页查询时返回的还是全量数据,只有配了插件才会自动拼LIMIT:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
6.2 Docker部署与时间时区问题
部署方案我用的是Docker Compose,一键启动MySQL、Redis和应用容器。最省事的做法是先把项目打成jar包,再写一个Dockerfile打成应用镜像。
dockerfile复制FROM openjdk:8-jdk-alpine
WORKDIR /app
COPY target/ticket-platform-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENV TZ=Asia/Shanghai
ENTRYPOINT ["java", "-jar", "-Duser.timezone=Asia/Shanghai", "app.jar"]
docker-compose.yml长这样:
yaml复制version: '3'
services:
mysql:
image: mysql:8.0
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=root123
- MYSQL_DATABASE=ticket_platform
command: --default-time-zone=+08:00
volumes:
- ./mysql-data:/var/lib/mysql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
app:
build: .
ports:
- "8080:8080"
depends_on:
- mysql
- redis
environment:
- SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/ticket_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
- SPRING_DATA_REDIS_HOST=redis
这个文件里有几个容易被忽视的细节。第一个,容器之间可以通过服务名互相访问,比如应用容器里连接数据库不用localhost,而是mysql,因为localhost指向的是容器自己。第二个,MySQL容器的--default-time-zone=+08:00参数必须加,不然容器默认的UTC时间会导致订单的时间字段全部差8个小时。第三个,前端部署时通过nginx把/api反向代理到本机8080端口,注意跨域配置。
Docker部署还有内存问题,小服务器上JVM默认堆内存可能占掉一大半。我在启动参数里显式限制:
yaml复制ENTRYPOINT ["java", "-jar", "-Xms256m", "-Xmx512m", "-Duser.timezone=Asia/Shanghai", "app.jar"]
6.3 上线后还会遇到的几个问题
项目上线后有几类问题会涌出来,我记录了自己实际遇过的,也整理了社区里高频出现的。
并发压测暴露超卖。表面上看扣库存SQL已经带了remain_stock >= quantity条件,理论上不可能超卖。但如果测试环境没有开启事务,或者订单表用的是MyISAM引擎,压测时就会出现严重的超卖。解决方案:一是确保InnoDB和事务生效,二是配合日志确认SQL执行情况。
Redis缓存和数据库不一致。后台修改了票种价格或库存后,用户端查询可能还是旧数据。解决思路有两个:修改数据时显式删除对应的Redis缓存,让下次查询回源数据库;或者给缓存设置较短的过期时间如10分钟,作为兜底修正。我两个方案都采用,删除缓存为主,过期时间兜底。
慢SQL问题。数据量上来后,订单列表页和销量统计查询开始变慢。解决方式:给高频查询的字段补索引,统计类查询尽量用离线汇总表,或者直接改造SQL减少联表次数。项目里销量统计只联了两张表,正常情况下性能没问题。
最后提醒一点:上线前一定要把SpringBoot的默认错误页关掉,配置一个全局异常处理器,统一返回JSON结构。不然用户看到一个Whitelabel Error Page,既不美观又泄露技术细节。全局异常处理用@RestControllerAdvice就好,把BusinessException、参数校验异常、兜底Exception分别处理,状态码和提示信息都规划好。
这个项目从需求梳理到部署上线,我前后花了两周左右的时间。最大的感受是:技术栈本身不复杂,复杂的是把业务逻辑想清楚,把并发场景考虑完整。如果你也在做类似的SpringBoot平台类项目,先别急着写代码,把库存怎么扣、订单状态怎么流转、支付回调怎么幂等这三件事想透,后面所有代码都会写得非常顺畅。
