1. 项目背景与整体思路拆解
1.1 “南互动马戏团秀场”到底是什么
先说这个项目是怎么来的。当时运营方提了一个需求:他们想做一个线下的马戏团演出品牌——“南互动马戏团”——的线上展示与互动平台,核心就是“秀场”的概念。观众不只是在线下看演出,线上也需要有一个地方能查看节目单、了解演出亮点、提前选座购票,甚至演出结束后还能参与话题讨论、给节目打分。所以“南互动马戏团秀场”本质上是一个基于Spring Boot的Web应用项目,它把“演出信息展示、场次票务管理、互动功能区”三件事揉在了一起。
很多人看到标题第一反应是“一个简单的展示型网站”,其实不是。如果只做静态展示,用Vue或者纯HTML就够了,根本不需要Spring Boot。这个项目真正有价值的地方在于:它涉及到一套完整的业务闭环——管理员维护演出节目、排期和场地座位;用户浏览演出、选座下单、支付模拟订单;演出结束后用户在互动区发帖评论、点赞;后台统计互动数据辅助运营决策。这些串联起来,才是“秀场”二字的全部含义。
项目名里“南互动”三个字,听起来像某个区域的品牌代号,但实际上它更像是一个业务方向的标识——强调“南方区域的互动型演出平台”。在开发中,我们直接把它作为项目名和包名的前缀(比如com.nanhd.shows),这样整个代码结构一上来就清楚:这是围绕“南互动”品牌线下的马戏团演出业务来做的。
1.2 这套系统解决了什么问题
在没有这套系统之前,马戏团演出运营基本靠线下售票、人工排期,用户在购票前看不到详细的节目介绍,演出结束后也没有渠道反馈体验。问题非常明显:信息不透明、票务库存容易超卖、用户与运营方之间没有互动沉淀。
Spring Boot版本的“南互动马戏团秀场”解决了三个核心痛点:
第一,演出信息的集中化展示。每一场节目都有独立的详情页,包含演出简介、节目亮点、演职员表、演出时长、票价档位和场次安排,用户不用再打电话问客服。
第二,票务库存的实时准确性。通过数据库行锁或乐观锁机制控制座位和票档库存,避免线下人工记账导致的超卖。这一点是这个项目里比较能体现后端功底的部分,后面我会专门展开讲。
第三,互动数据的沉淀。用户登录后可以对演出打分、发表评论、点赞感兴趣的节目,运营后台可以按时间维度去看哪些节目热度最高、观众评价如何,为后续排期调整提供数据依据。
项目适合谁参考?如果你正在学Spring Boot,想找一个功能完整、有业务逻辑而不是CRUD空壳的练手项目;或者你需要给一个文艺演出、展览活动、俱乐部活动之类的业务场景做线上化管理系统;再或者你是面试前想做项目复盘、梳理Spring Boot核心知识点——这个项目的源码拆解和实操过程都值得一看。
1.3 技术选型背后的为什么
选型这件事,很多新手容易忽略,但资深开发都知道:技术选型的核心不是“谁新用谁”,而是“谁合适用谁”。Spring Boot在这个项目里的优势非常契合业务场景。
首先是开发效率。马戏团秀场的业务模块比较多,用户端、管理端、互动端三大块,如果从零搭SSH(Spring MVC + Hibernate + Struts)那套老框架,光XML配置就够写一天。Spring Boot的自动配置把大量样板配置收编了,项目中只需要在application.yml里写数据源、Redis、日志等必要配置,剩下的交给starter机制去处理。我当时估算过,同样一套业务,用Spring Boot搭骨架的时间大概只有传统SSH的三分之一。
其次是生态整合能力。秀场项目里需要用到MyBatis做持久层、Redis缓存演出热门数据、Spring Security做登录鉴权、Validation做参数校验,这些在Spring Boot里都有对应的starter,依赖引入之后基本零配置接入。尤其对于搜索热词里频繁出现的“springboot整合activemq”这种场景,Spring Boot的自动配置机制让消息中间件的接入成本降到了最低,后面在优化互动通知时我们会用到。
第三是部署友好性。项目最终要交付给运营方部署,他们的服务器环境可能只有一个裸机的Linux系统,没有专门的运维人员。Spring Boot项目打成可执行JAR包,扔到服务器上java -jar就能跑,不需要单独装Tomcat。这对中小型业务系统来说是非常实际的优势。
2. 核心技术细节解析
2.1 Spring Boot自动装配原理与项目依赖管理
这个项目里用到Spring Boot的核心机制时,最有必要讲清楚的是自动装配(Auto Configuration)。热搜词里“springboot自动装配原理”出现频率很高,我在面试中也经常被问到,这里结合项目场景说一下。
自动装配的起点是@SpringBootApplication这个组合注解,它包含了@SpringBootConfiguration、@EnableAutoConfiguration和@ComponentScan。其中@EnableAutoConfiguration是核心,它会通过AutoConfigurationImportSelector去读取META-INF/spring.factories文件里配置的自动配置类列表。
但注意一个细节:Spring Boot不是把所有的自动配置类全部生效,而是根据项目classpath下的依赖和配置属性做条件化装配。比如项目pom.xml里引入了spring-boot-starter-data-redis,那么RedisAutoConfiguration就能生效;如果没引入,这个自动配置类就会被@ConditionalOnClass条件判断过滤掉。这就是为什么Spring Boot能做到“按需装配”,而不像传统Spring项目那样引入了一堆用不到的Bean。
在我们的秀场项目中,pom.xml的核心依赖清单是这样设计的:
- spring-boot-starter-web:提供内嵌Tomcat、Spring MVC和Jackson等Web基础能力
- spring-boot-starter-validation:参数校验,用户注册、评论提交时用
- mybatis-plus-boot-starter:持久层增强,简化单表CRUD操作
- mysql-connector-java:MySQL驱动
- spring-boot-starter-data-redis:缓存热点演出数据和轮播图
- spring-boot-starter-security:登录认证与权限控制
- lombok:减少实体类和DTO的样板代码
- spring-boot-starter-test:单元测试和集成测试
关于版本选择多说一句:搜索热词里“springboot版本太高”是个高频痛点。我踩过这个坑——最开始用Spring Boot 3.0.5搭配MyBatis-Plus 3.4.3,启动直接报错,原因是Spring Boot 3基于Jakarta EE 9规范,javax.包名全部改成了jakarta.,而老版本MyBatis-Plus走的还是javax。后来把MyBatis-Plus升级到3.5.3.1才正常运行。所以如果项目要跑在Spring Boot 3.x上,一定要确认所有第三方依赖都已经适配Jakarta规范。如果不想折腾,稳妥方案是Spring Boot 2.7.x,生态兼容性最成熟。
2.2 数据库设计:马戏团业务的表结构拆解
数据库设计是“秀场”项目里最值得复盘的一部分。为什么?因为业务表之间不是简单的单表独立,而是有真实业务关联的。我按业务模块把核心表拆成四组:
演出资源模块:
- show_info:演出基本信息表,字段包括演出名称、海报URL、演职员表、介绍、时长、状态(筹备中/售票中/已结束)
- show_program:节目单表,一个演出对应多个节目,每个节目有名称、类型、顺序、演员、亮点描述
- show_session:场次表,一个演出对应多个场次,包含演出日期、开始时间、场地ID
票务交易模块:
- ticket_sku:票档表,属于某个场次,包含票档名称(如VIP区、A区、B区)、单价、总库存、剩余库存
- ticket_order:订单表,记录用户、场次、总金额、状态(待支付/已支付/已取消/已使用)
- ticket_order_item:订单明细表,一个订单包含多张票,记录座位区域和座位号
用户互动模块:
- user_account:用户表,包含手机号、密码(BCrypt加密)、昵称、头像、角色(USER/ADMIN)
- show_comment:演出评论表,评论内容、所属演出、用户、评分(1-5星)、点赞数
- interact_topic:互动话题表,用户可发起相关话题,绑定演出ID
- interact_reply:话题回复表,记录对话题的回复
运营管理模块:
- banner_config:首页轮播图配置
- sys_operation_log:操作日志表
这里的关键设计思想是“把商品、场次、库存分离”。很多人做演出系统会把“演出”直接当商品来处理,下单直接扣演出的库存。但实际业务里一个演出有多个场次,不同场次不同票档,如果库存只挂在演出级别,用户选座和场次管理就无法实现。所以我们抽象出show_session(场次)和ticket_sku(票档)两层,库存精确到“某个场次下某个票档还剩多少张”。这样设计后,运营可以针对不同场次做差异化定价,比如周末场VIP票卖280元,工作日场VIP票卖220元,而不需要复制多份演出数据。
2.3 分层架构与包结构设计
项目代码的组织方式采用经典的四层架构:Controller、Service、Mapper、Entity。包结构如下:
com.nanhd.shows ├── controller // 控制层,接收请求,返回统一响应 ├── service // 业务层,处理核心业务逻辑 │ └── impl ├── mapper // 数据访问层,MyBatis-Plus的Mapper接口 ├── entity // 数据库实体类 ├── dto // 数据传输对象,用于接口入参和出参 ├── vo // 视图对象,定制化返回前端的数据结构 ├── config // 配置类:Security、Redis、MyBatis-Plus分页 ├── common // 统一结果封装、异常处理、常量定义 └── util // 工具类:JWT生成解析、日期处理等这里有个实战经验:Entity和VO一定要分离,不要偷懒直接拿实体类返回给前端。比如show_info表里有status字段(0筹备、1售票中、2已结束),但前端展示时需要的是状态的中文文本“售票中”,还有距离演出开始的天数。这些派生字段如果塞进Entity会非常脏,所以我们定义了ShowDetailVO来承接展示数据,Service层做装配。这样的好处是数据库表结构调整不会直接波及接口响应,接口稳定性有保障。
统一返回结构这块也值得提一下。项目里定义了一个Result类,格式是:
{ "code": 200, "message": "success", "data": {} }所有Controller层的接口都返回这个结构,配合全局异常处理器@RestControllerAdvice,业务异常和系统异常都能转换成统一的JSON格式返回给前端。这样前端只需要在axios拦截器里判断code字段,就不用每个接口单独做异常处理了。
3. 实操过程与核心功能实现
3.1 项目初始化与环境准备
先把环境列出来,方便你直接对照:
- JDK 1.8(项目基于JDK 8开发,如果想用JDK 17也问题不大,但要同步升级Spring Boot版本)
- Maven 3.6+
- MySQL 5.7+(推荐8.0)
- Redis 5.0+
- IDEA 2021.3+
创建项目的第一步,我推荐使用Spring Initializr而不是手动建Maven工程。直接在start.spring.io上勾选需要的依赖:Spring Web、Spring Security、Validation、MyBatis Framework、MySQL Driver、Redis。生成之后解压导入IDEA,再把MyBatis-Plus的依赖手动加进去。
为什么用MyBatis-Plus?因为秀场项目的表结构不算特别复杂,MyBatis-Plus的BaseMapper内置了selectById、insert、updateById、deleteById这些常用方法,单表操作完全不需要写XML。只有在一些多表关联查询时才需要自定义SQL,比如“查询某个演出下的所有场次和票档库存”这种场景,我们自己写XML的ResultMap来映射。这样既保证了开发效率,又保留了SQL的灵活控制力。
application.yml里的关键配置:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/nanhd_show?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 mybatis-plus: mapper-locations: classpath:mapper/**/*.xml type-aliases-package: com.nanhd.shows.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl注意map-underscore-to-camel-case这个配置一定要开启,否则数据库的show_name字段映射不到实体的showName属性上。这个看似不起眼的配置,能省掉大量的@TableField注解。
3.2 演出信息模块的完整实现
演出信息模块是整个秀场系统的“门面”,首页轮播推荐、演出列表、演出详情都归属于这个模块。我以“获取演出详情”这个接口为例,把完整的实现链路走一遍。
首先是Controller层:
@RestController @RequestMapping("/api/show") public class ShowController { @Resource private ShowService showService; @GetMapping("/detail/{showId}") public Result<ShowDetailVO> getShowDetail(@PathVariable Long showId) { return Result.success(showService.getShowDetail(showId)); } }Service层做了几件核心事:
@Service public class ShowServiceImpl implements ShowService { @Resource private ShowInfoMapper showInfoMapper; @Resource private ShowProgramMapper showProgramMapper; @Resource private ShowSessionMapper showSessionMapper; @Resource private RedisTemplate<String, Object> redisTemplate; @Override public ShowDetailVO getShowDetail(Long showId) { // 1. 优先从Redis缓存读取 String cacheKey = "show:detail:" + showId; Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return (ShowDetailVO) cached; } // 2. 缓存未命中,查询数据库 ShowInfo showInfo = showInfoMapper.selectById(showId); if (showInfo == null) { throw new BusinessException("演出不存在"); } // 3. 查询节目单 List<ShowProgram> programs = showProgramMapper.selectList( new LambdaQueryWrapper<ShowProgram>() .eq(ShowProgram::getShowId, showId) .orderByAsc(ShowProgram::getSortOrder) ); // 4. 查询场次和票档 List<ShowSessionVO> sessions = showSessionMapper.selectSessionsWithSku(showId); // 5. 组装VO ShowDetailVO vo = new ShowDetailVO(); BeanUtils.copyProperties(showInfo, vo); vo.setPrograms(programs); vo.setSessions(sessions); // 6. 写入缓存,设置过期时间10分钟 redisTemplate.opsForValue().set(cacheKey, vo, 10, TimeUnit.MINUTES); return vo; } }这个接口看起来简单,但有几个细节值得说明。
第一,为什么用Redis缓存而不是本地缓存?因为用户端首页和详情页的访问量远高于管理端,同一个演出的详情会被大量用户重复查看。如果用本地Caffeine缓存,多个实例部署时会产生缓存不一致的问题;Redis是独立的外部存储,天然解决这个问题。10分钟的过期时间是为了保证运营修改演出信息后,最迟10分钟内能反映到用户端。
第二,LambdaQueryWrapper的用法解决了字段名硬编码的问题。传统写法用QueryWrapper传入字符串"show_id",一旦数据库字段改名,编译器完全检查不出来,运行期才报错。LambdaQueryWrapper用方法引用ShowProgram::getShowId,编译期就能验证字段存在性,重构时也安全。这是MyBatis-Plus比较推荐的一种方式。
第三,为什么要把“票档库存”也查出来?因为用户进详情页不只是看演出时间,他还要做决策——这个场次的票紧张不紧张?还有没有VIP区?selectSessionsWithSku是一个自定义多表查询,把show_session和ticket_sku做关联,返回每个场次下每个票档的剩余库存。这样前端可以把“仅剩3张”这种稀缺性提示直接展示出来,刺激转化。
3.3 票务预订与库存扣减实战
票务预订是整个项目里并发压力最大的环节。春节档演出一开票,瞬间可能有几百人同时抢购同一个场次,如果库存扣减逻辑设计不好,就会出现超卖。这里我重点讲讲库存扣减的实现方案。
先看一段最初的“问题代码”——很多新手会这么写:
// 反例:先查库存,再判断,再扣减 TicketSku sku = ticketSkuMapper.selectById(skuId); if (sku.getStock() < buyCount) { throw new BusinessException("库存不足"); } sku.setStock(sku.getStock() - buyCount); ticketSkuMapper.updateById(sku);这段代码在高并发下必然出错。两个请求同时查到库存还剩5张,都判断“5 > 1”成立,都执行扣减,最终库存可能变成3而不是4。这就是典型的超卖问题。
项目最终采用了“条件更新”的方式来解决:
// 正例:使用条件更新,原子扣减库存 int updatedRows = ticketSkuMapper.update(null, new LambdaUpdateWrapper<TicketSku>() .setSql("stock = stock - {0}", buyCount) .eq(TicketSku::getId, skuId) .ge(TicketSku::getStock, buyCount) ); if (updatedRows == 0) { throw new BusinessException("库存不足,请选择其他票档"); }这个写法的核心是:把判断库存是否充足和扣减库存合并成一条UPDATE语句。MySQL的UPDATE是行级原子操作,当多个请求同时执行这条SQL时,数据库会串行化处理同一行的更新。假设库存还剩5张,A买了3张,B买了3张,两条SQL在数据库层面的执行效果是:第一条把5改成2,影响行数为1;第二条因stock=2不满足stock>=3的条件,影响行数为0,判断为库存不足。从根上杜绝了超卖。
但条件更新只解决了库存扣减的问题,订单创建和库存扣减之间还需要事务保证。简单说就是:不是“先扣库存再创建订单”,或者“先创建订单再扣库存”,而是要把这两个动作放进同一个事务里,要么都成功,要么都失败。技术实现用的是@Transactional注解。
我当时在这个环节踩过一个值得分享的坑:订单表和库存扣减在同一个事务里没问题,但事务提交之后,用户可能一直不支付,库存就被长时间占用。比如用户锁定了5张VIP票,30分钟不支付,这5张票就卖不出去。解决办法是引入“订单超时自动关闭”机制,最简单的实现是用定时任务扫描超过30分钟未支付且状态为“待支付”的订单,把状态改为“已取消”,同时回补库存:
@Scheduled(cron = "0 */5 * * * *") public void closeExpiredOrders() { // 1. 查询所有超过30分钟未支付的待支付订单 // 2. 逐单更新状态为已取消 // 3. 将该订单涉及到的票档库存回补 }这里的核心就是引入一个定时任务轮询扫描,虽然不一定是最优雅的方案(更高效的做法是用延迟消息队列),但对于演示级项目来说足够稳定,而且逻辑清晰,容易理解和维护。
3.4 用户登录、权限控制与互动区
用户端不是所有接口都能匿名访问的。看演出列表可以不用登录,但提交评论、发起话题、下单购票必须登录。项目里用的是Spring Security + JWT的方案,而不是传统的Session。原因有两个:一是前后端分离结构下,Session天然不太友好,涉及跨域共享Session的问题;二是JWT无状态,后端只需要校验token本身是否有效,不依赖服务端存储。
登录流程:用户提交手机号和密码,UserServiceImpl里先用BCryptPasswordEncoder的matches方法校验密码(数据库中存储的是BCrypt加密后的密文,绝对不允许明文),校验通过后生成JWT返回给前端。前端把token存到localStorage,通过axios拦截器在请求头里加Authorization: Bearer 。
权限控制上,通过自定义一个SecurityConfig继承了WebSecurityConfigurerAdapter,核心配置:
@Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/auth/login", "/api/auth/register", "/api/show/**").permitAll() .antMatchers("/api/comment/**", "/api/order/**").authenticated() .antMatchers("/api/admin/**").hasRole("ADMIN") .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); }互动区是这个项目的“灵魂”。马戏团演出结束后,如果观众体验好,他们会在评论区和话题区分享感受,这些内容又会成为下一批用户购票的决策参考。评论接口实现时,需要在@AuthenticationPrincipal注解注入的当前用户上下文里拿userId,再校验用户是否至少购买过该演出的一张票,防止有人没看过演出就乱刷评论。校验通过后,评论数据写入show_comment表。为了提高列表查询效率,评论列表按演出ID分页查询,并用Redis缓存评论总数,避免每次都要COUNT。
互动趋势功能很有意思——运营后台能看到每个演出的评论量按天变化的曲线。实现方式是每天凌晨用定时任务统计前一天每个演出的新增评论数,写入一张独立的趋势表。这样既方便运营决策,又不会因为实时统计拖垮评论主表。
3.5 后台管理模块的要点
后台管理模块只允许ADMIN角色访问,核心功能包括:演出信息管理、节目单编排、场次和票档设置、订单管理、互动内容审核。
管理端和用户端的接口设计上有一个明显的差别:管理端接口返回的数据更“素”,不需要拼装VO,直接返回实体类加一些扩展字段即可;用户端接口则要花更多心思做展示层的适配。所以Controller层根据使用方不同做了拆分,用户端叫/api/show/,管理端叫/api/admin/show/,两套入口互不干扰。
节目单编排这个功能,我实现的是拖拽排序+批量保存。前端把节目单的展示顺序通过拖拽调整好后,一次性提交一个包含所有节目ID和对应排序值的数据结构,后端批量更新sort_order字段。很多人在后台系统里做排序功能时,习惯只做上移下移,实际上拖拽排序的体验要好很多,而且实现起来并不复杂——后端只需要定义一个UpdateSortDTO,接收list里面每个item的id和sortOrder即可。
4. 常见问题与排查技巧实录
4.1 Spring Boot版本过高导致的启动报错
这个问题我在项目开发中期遇到过。当时想着新项目直接用最新稳定版,选了Spring Boot 3.0.5,结果踩了一串坑。最典型的是MyBatis-Plus和Spring Boot 3不兼容的问题——启动时直接报UnsupportedOperationException,或者Mapper扫描不到。
排查过程这样的:控制台报错信息显示MyBatis-Plus在初始化时无法识别Spring Boot 3的自动配置类,因为Spring Boot 3把javax.servlet改成了jakarta.servlet,而MyBatis-Plus老版本是在javax基础上编译的,链路完全对不上。
解决方案有两个:一是把MyBatis-Plus升级到3.5.3.1以上,这个版本官方支持了Spring Boot 3;二是如果项目里有其他依赖也无法适配Spring Boot 3,就统一降级回Spring Boot 2.7.x。在这个秀场项目里,我选择的是升级MyBatis-Plus,因为Spring Boot 3本身带来的性能提升和Jakarta规范是未来方向,没必要回头。
给新手的建议:不要盲目追求最新版本,尤其Spring Boot这种生态型框架,第三方的适配速度往往慢半拍。在使用新版本大版本(比如3.x)之前,先去Maven仓库看关键依赖是否已发布兼容版本。
4.2 循环依赖与Bean注入失败的排查
项目里有一次启动报错:The dependencies of some of the beans in the application context form a cycle。定位后发现是ShowServiceImpl依赖了OrderServiceImpl,而OrderServiceImpl又依赖了ShowServiceImpl,二者形成了循环依赖。
Spring Boot 2.6之后默认禁止循环依赖,这是好事,逼着开发者把代码结构理清楚。我的解决思路是提取一个ShowQueryService或者把依赖方向改成单向。具体场景里,OrderServiceImpl需要调用ShowService查询演出名称,ShowServiceImpl又需要调用OrderService去查询某个用户是否购买过该演出。重构后,我在两个Service里抽出了共享的查询方法放到一个独立的QueryService中,这样原来的循环引用就变成了双向依赖QueryService的单向依赖,清爽很多。
这里想多说一句:网上很多老教程还在推荐用@Lazy注解来跳过循环依赖,我的看法是不到万不得已不要用。循环依赖是设计问题的信号,如果项目里出现了,优先想怎么拆,而不是怎么绕。
4.3 MyBatis-Plus的分页失效问题
分页查询是评论列表和管理端列表里的常用功能。MyBatis-Plus想要分页生效,必须要先注册PaginationInnerInterceptor这个分页插件,不然调用selectPage方法时,虽然不报错,但查出来的数据是全量,limit条件根本没拼进去。
项目里的分页配置:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个插件注册之后,selectPage就会自动拼接limit,同时还会自动执行一条COUNT查询,返回总数。如果你发现分页查出来数据异常,第一反应先确认这个拦截器是否注册,这是Caused by MyBatis-Plus分页不起作用最常见的原因。
另外分页插件有个容易被忽略的点:如果查询语句里有自定义多表JOIN,COUNT查询可能会因为JOIN产生重复计数,导致总数偏大。解决办法是重写这类的count查询,或者对JOIN表加上DISTINCT。
4.4 前后端联调时的跨域与Token问题
本地开发时前端Vue跑在8081端口,后端跑在8080端口,前后端联调必然面临跨域。项目里推荐通过配置类处理:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }联调时还遇到过一个问题:有些接口明明已经登录了,请求却一直返回401未认证。排查后发现是axios拦截器里没有携带Authorization头,或者说携带的key和Spring Security期望的不一致。记住一个约定:前端设置的请求头名必须是Authorization,值必须是"Bearer " + token,中间有一个空格。如果设置成token=xxx或者把Bearer漏掉,Spring Security的SecurityContextHolder就拿不到有效的Authentication对象。
4.5 单元测试与接口自测
项目里为关键的Service方法写了单元测试。比如票务下单这个方法,单测覆盖了几个核心场景:正常下单成功、库存不足抛异常、重复提交同一场次订单被限制、未登录用户无法评论。Spring Boot的测试写法如下:
@SpringBootTest @Transactional class OrderServiceTest { @Resource private OrderService orderService; @Test void createOrder_success() { // 构造下单参数 OrderCreateDTO dto = new OrderCreateDTO(); dto.setSessionId(1L); dto.setSkuId(1L); dto.setBuyCount(2); // 断言订单创建成功 Assertions.assertNotNull(orderService.createOrder(dto)); } @Test void createOrder_stockNotEnough() { OrderCreateDTO dto = new OrderCreateDTO(); dto.setSkuId(999L); dto.setBuyCount(1000); Assertions.assertThrows(BusinessException.class, () -> orderService.createOrder(dto)); } }@Transactional加在测试类上,可以让每个测试用例在结束后自动回滚,不会污染数据库,这是一个非常实用的小技巧。注意:如果你的测试方法里手动开了事务线程,这种回滚方式可能失效,这也是一个隐藏的天坑。
5. Docker部署与上线实操
5.1 打包可执行JAR
部署的第一步是把项目打成可执行JAR包。在项目根目录执行:
mvn clean package -DskipTests打包完成后target目录下会生成shows-0.0.1-SNAPSHOT.jar。如果pom.xml里没有配置Spring Boot Maven插件,打出来的jar包是不能通过java -jar直接运行的,因为缺少内嵌的Tomcat和依赖jar。确保pom里有这一段:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>5.2 编写Dockerfile
项目上线选择Docker容器化部署,Dockerfile内容如下:
FROM openjdk:8-jdk-alpine LABEL maintainer="nanhd" WORKDIR /app COPY shows-0.0.1-SNAPSHOT.jar /app/app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "-Xms512m", "-Xmx512m", "-Duser.timezone=Asia/Shanghai", "app.jar"]这里有几点说明:
第一,基础镜像选择openjdk:8-jdk-alpine,体积小,适合生产环境。如果项目升级到JDK 17或21,就换成对应的镜像,比如eclipse-temurin:17-jdk-alpine。
第二,JVM参数里设置-Xms和-Xmx为512m,这是为了防止堆内存无限扩张把容器内存打满。在容器环境里不设最大堆内存,可能出现容器被系统OOM Killer杀掉的情况。如果你的服务器内存充裕,可以适当调大。
第三,-Duser.timezone=Asia/Shanghai这个参数容易被忽略,但非常关键。如果不设置,Java进程默认使用宿主机的时区。服务器时区如果恰好是UTC,那么MySQL连接字符串里的serverTimezone=Asia/Shanghai和JVM时区不一致,会导致日期时间字段差8个小时,排期显示全乱。
5.3 Docker Compose一键启动
项目依赖MySQL和Redis,用Docker Compose一次性拉起来最省事:
version: "3.8" services: mysql: image: mysql:8.0 container_name: nanhd-mysql environment: MYSQL_ROOT_PASSWORD: 123456 MYSQL_DATABASE: nanhd_show ports: - "3306:3306" volumes: - ./mysql-data:/var/lib/mysql redis: image: redis:7.0 container_name: nanhd-redis ports: - "6379:6379" app: build: . container_name: nanhd-show-app depends_on: - mysql - redis ports: - "8080:8080" environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/nanhd_show?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: 123456 SPRING_REDIS_HOST: redis SPRING_REDIS_PORT: 6379这套Compose配置的关键点是:应用容器内的数据库地址不能写localhost,而要写服务名mysql,Docker Compose会自动创建内部网络,让容器之间通过服务名互相访问。如果你直接在容器里写localhost,那访问的就是容器自己,肯定会报连接拒绝。
5.4 常见部署问题排查
部署阶段遇到一个比较典型的问题是:镜像构建成功,但容器启动几秒后自动退出。排查方法是先看日志:
docker logs nanhd-show-app日志里显示的通常不是“进程被杀死”,而是Spring Boot启动失败抛出的异常。最常见的是数据库连接不上,表现为Communications link failure。解决思路是确认MySQL容器是否正常运行、账号密码是否匹配、SPRING_DATASOURCE_URL里面指向的主机名是否写对。还有一种情况是MySQL初始化比较慢,应用容器启动太早,数据库还没ready。Compose配置里加healthcheck可以解决:
mysql: healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p123456"] interval: 5s timeout: 3s retries: 20然后在app服务的depends_on下加condition: service_healthy,确保数据库完全就绪后再启动应用。这个坑在本地模拟时不容易暴露,因为本地IDE启动时MySQL早就跑起来了;但在容器化环境一冷启就翻车。这也是“本地好好的,一到Docker就挂”的一类经典原因。
6. 项目复盘与扩展方向
最后说说我个人在完成这个“南互动马戏团秀场”项目过程中的几个体会。
第一个体会是:一个项目最难的往往不是某个技术点不会,而是业务逻辑的完整性和一致性。拿票务库存来说,表面看是一个简单的扣减操作,但真正深入之后,你会发现要处理库存原子性、订单超时释放、部分退款回补、同一用户重复购买限制等等。这些细节堆在一起才是系统的真实复杂度。Spring Boot只是让“搭骨架”变得容易,真正决定项目质量的是业务设计。
第二个体会是:代码里的坑,绝大多数都是自己埋的。比如数据库设计时没有预留扩展字段,导致后期要改表结构;比如Service层之间纠缠不清,出现循环依赖被迫重构;比如测试没跟上,改了一个功能把另一个功能改崩了。踩过这些坑之后再回头看,项目脚手架的搭建其实只占20%的精力,剩下80%都在和业务逻辑、异常边界、并发场景搏斗。
第三个实际建议:如果你要拿这个项目当学习参考,不要只盯着怎么把代码跑起来,一定要动手改几个地方——比如把定时关闭订单改成用消息队列延迟消费来实现,把评论列表改成Elasticsearch搜索,把下单流程引入分布式锁。改造的过程中,你对Spring Boot生态的理解会远比看源码来得深刻。
搜索引擎的热搜词里“springboot面试题”“springboot自动装配原理”“springboot循环依赖”这些频繁出现,恰恰说明一个问题:很多人在背面试题,但真正到实践中遇到“版本过高导致启动失败”“分页失效”“循环依赖报错”这些问题时,如果没亲手解决过,光靠背知识点是反应不过来的。这个项目里暴露出来的问题,其实就是面试官最爱问的那几个场景的实战版。
如果你决定照着这个思路做一个类似的演出/活动管理系统,建议按这个顺序推进:先把数据库表结构和ER图设计好,再搭Spring Boot骨架,然后做用户认证,再做核心的票务下单,最后补互动区和后台管理。每一块做完都要跑通再进下一块,千万别想着全部写完再统一调试,那种模式下遇到问题会非常难定位。
最后再分享一个小技巧:项目里所有对外接口的参数校验,一定要在Controller层用@Validated + @NotBlank、@NotNull注解做掉,不要信任前端传来的任何数据。比如创建订单时buyCount必须大于0,这是个看似简单但绝不允许偷懒的校验。我见过太多线上事故,就是没校验参数导致的。把这层做好,系统稳定性会提升一个档次。