开题定调:为什么选“SpringBoot + 购票”这个组合
做毕设选题的时候,大多数人的第一反应是找个“看起来不新不旧、能落地、有东西可写”的方向。我当时定下“基于SpringBoot的黄山旅游景点购票系统设计与实现”,主要就三方面考虑。
第一,购票系统是典型的“业务闭环”项目,从用户注册、景点浏览、下单购票、订单管理到后台数据统计,完整覆盖了一个Web系统从C端到B端的大部分核心流程。这类项目用来做毕设或者项目练手,既不会像“图书管理系统”那样烂大街到答辩老师看到标题就皱眉,也不会像“基于深度学习的疲劳预警系统”那样需要大量算法功底,容易做到一半卡死在数据集和模型效果上。
第二,SpringBoot在当前开发环境里太主流了。企业级项目、外包项目、个人毕设,十有八九都是SpringBoot全家桶。选它意味着你查资料、踩坑、找解决方案的成本极低,而且答辩的时候“为什么用SpringBoot”这个问题,可以讲出一套完整的技术选型逻辑,而不是一句“因为大家都在用”就被打发了。
第三,黄山是一个真实存在的业务场景。景区门票不是简单的一张票卖出去就完事,它有淡旺季、有预约限流、有不同类型门票(成人票、学生票、索道票、联票)、有退改规则。挂上“黄山”这个真实业务背景,系统设计就有了业务约束,做出来的东西不是空中楼阁,而是有实际参考价值的方案。
这个项目适合谁?如果你正在准备毕业设计选题,或者想用一个完整项目来串联SpringBoot、MyBatis-Plus、Redis、Vue这些技术栈,又不想做太抽象的“XX管理系统”,那这个方向值得参考。下面我从方案设计、核心实现到踩坑实录,把整套思路完整拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1. 项目整体设计与思路拆解
1.1 需求分析:购票系统到底在管什么
很多人在开题阶段就栽在需求分析上,要么写得像产品经理的PRD,全是功能罗列;要么写成流水账,看不出系统的核心难点在哪。实际上,一个旅游景点购票系统,本质要解决三件事。
第一件事是“卖票”。用户能注册登录,能按景点查看门票信息,能选日期、选票型、下订单、支付(毕设里一般做模拟支付),然后拿到一个带二维码或订单号的电子凭证。这是系统的基础链路,缺了它整个系统就不成立。
第二件事是“管票”。景区运营方需要知道每天每个景点卖了多少张票,哪些日期余票紧张,某个时间段是否触发了限流阈值。甚至对于黄山这种热门景区,旺季的“预约+限流”是刚需——你必须提前设置每日最大承载量,卖完就停止预约,这正是系统区别于普通商城的关键业务点。
第三件事是“洞察数据”。哪个月份是销售高峰、哪个景点最受欢迎、散客和团队票的比例是多少。这些统计结果不需要做得多复杂,几张趋势图、几个统计报表就够用,但它让系统从“工具”变成了“有决策价值的平台”。
所以我在开题报告里的核心观点是:这不是一个普通的CRUD,而是一个带业务规则和并发压力的交易系统。哪怕毕设简化了支付和短信通知,但“库存扣减”“限流控制”“订单状态流转”这些点必须想清楚。
1.2 技术选型:SpringBoot 3.x还是2.x,这是个问题
技术选型是开题报告里最容易被追问的部分。你写了SpringBoot,老师大概率会问为什么不用SSH、为什么不用SpringCloud、为什么不用Python Flask。这里我给出一套完整的选型逻辑。
后端框架上,选SpringBoot而不是SSH(Spring MVC + Hibernate + Struts),核心原因是SSH的XML配置成本太高,现在早已不是主流,学它等于学一套被淘汰的技术。选SpringBoot而不是SpringCloud,是因为单机单体架构足够承载毕设业务量,微服务的拆分、注册中心、配置中心、服务网关这些东西对一个购票系统来说完全是过度设计。SpringBoot的自动装配机制让我们只需要关注业务代码,而不是花大量时间在基础设施配置上。
版本选择这里要特别提醒一句:SpringBoot 2.x和3.x的区别不只是版本号。3.x基于Jakarta EE规范,javax包全换成了jakarta包,JDK要求从8提升到17,Spring Framework也升到了6.x。如果你的本机JDK是8,那老老实实用SpringBoot 2.7.x;如果已经是JDK 17或21,直接上SpringBoot 3.x。我见过不少人在Eclipse里集成SpringBoot,JDK版本不对,导包全都报错,还以为是框架坏了,实际上就是版本不匹配。这个坑后面专门细讲。
持久层我选的是MyBatis-Plus。理由也简单:JPA虽然写起来省事,但复杂查询、多表关联、SQL调优的时候会比较痛苦;原生MyBatis灵活,但要写大量XML;MyBatis-Plus在两者之间取了平衡,既保留了MyBatis的SQL控制力,又提供了BaseMapper和Wrapper这种东西,单表CRUD一行代码都不用写,多表查询该写SQL就写SQL。对毕设这种进度导向的项目来说,这能省下大量开发时间。
前端方案上,如果想走前后端分离路线,就是SpringBoot + Vue + Axios,这是现在的主流玩法,也更好展示你的知识广度。如果为了简化部署,可以用Thymeleaf服务端渲染,一套SpringBoot应用直接搞定,不用额外启一个Node服务。我个人的建议是无脑选前后端分离,理由很简单:答辩的时候你可以说“前端基于Vue3 + Element Plus,后端采用RESTful API设计”,这句话本身就值不少分。
1.3 系统架构:从浏览器到数据库,一次请求走完的完整链路
架构设计不需要画太复杂的图,但脑子里必须有一条清晰的请求链路。以“用户购买一张黄山风景区成人票”为例,整个流程是这样的。
用户在浏览器里点“立即购买”,Vue组件通过Axios发送POST请求到后端接口,比如/api/order/create。请求先经过SpringBoot的拦截器(Intercepter),拦截器里校验请求头里的Token,确认用户登录状态。Token我选用JWT(JSON Web Token)方案,登录成功后服务端签发一个带过期时间的Token,后续每次请求带上它。用JWT的好处是服务端无需存储会话状态,天然适合前后端分离架构,也方便后续做多端扩展。
请求进入Controller层后,要做参数校验——日期不能是过去的时间,票型ID必须存在,购票数量不能超过限制。校验通过后进入Service层,这里是最核心的业务逻辑:先查Redis里该日期该景点的“当日剩余票数”,如果剩余量大于购买数量,就执行库存扣减,创建订单记录,然后调模拟支付接口。支付回调成功,把订单状态从“待支付”改为“已支付”,同时生成电子凭证数据。如果Redis里显示余票不足,直接返回“今日已约满”的提示。
这条链路里,Controller只做参数接收和结果封装,Service层才是业务逻辑所在地。Repository(DAO)层负责数据库操作,通过MyBatis-Plus的BaseMapper实现对订单表、门票表、用户表的增删改查。数据库我用MySQL 8.x,缓存用Redis,这两者配合才能处理购票场景下的并发压力。
2. 核心细节解析与实操要点
2.1 数据库设计:五张核心表撑起整个系统
数据表设计是开题报告里必写的内容,也是后期开发最影响返工率的部分。我最终沉淀下来五张核心表,再加一张可选的数据统计表。
用户表(user)的字段包括:id、username、password(BCrypt加密存储)、real_name、id_card、phone、create_time。注意手机号和身份证号是购票后验票时需要比对的,所以必须留。密码加密是硬性要求,明文存密码在答辩时会被直接质疑安全问题。
景点表(scenic_spot)的字段包括:id、name、description、address、open_time、close_time、cover_image、price、daily_limit、status。这里的daily_limit就是限流的基础数据,对应黄山每天可预约的最大人数。旺季把某个景点的daily_limit调低,淡季调高,运营人员可以在后台操作。
票型表(ticket_type)的字段包括:id、spot_id、name(成人票/学生票/老人票)、price、type_code、remark。为什么景点表里已经有price,还要单独建票型表?因为一个景点通常有多个票种,成人票和缆车联票价格不一样,如果把价格直接冗余在景点表里,后续扩展会很被动。把票型拆成独立表是一种很标准的“一对多”设计,也方便后续增加“亲子票”“两日联票”这种新票种。
订单表(orders)字段包括:id、order_no、user_id、ticket_type_id、visit_date、quantity、total_amount、status、pay_time、create_time。order_no需要保证唯一性,我用的是“时间戳+随机数”的组合方式;status字段的取值建议为:0=待支付,1=已支付,2=待退款,3=已退款。这张表和票型表是关联查询最频繁的,要建立联合索引。
支付记录表(payment_record)字段包括:id、order_id、pay_no、amount、pay_method、status、callback_time。虽然是模拟支付,但支付记录和订单状态是分开的,这样即便支付回调失败,也可以用支付记录去反查订单状态。
统计维度上,如果后续要做数据报表,可以用订单表的聚合查询动态生成“月度销量统计”“景点热度排行”,不需要专门建统计表。
2.2 接口设计:RESTful风格的URL与状态码约定
接口设计直接决定前后端联调的效率。我作为后端开发,最怕的就是接口路径混乱、返回格式不统一。所以项目一开始就用统一的返回体,所有接口返回结果都遵循下面这个JSON格式。
java复制public class Result<T> {
private Integer code; // 200成功,400参数错误,401未登录,500服务器异常
private String message; // 提示信息
private T data; // 业务数据
}
具体接口按业务域来分组。用户模块:POST /api/user/register,POST /api/user/login,GET /api/user/info。景点模块:GET /api/spot/list,GET /api/spot/{id},GET /api/spot/{id}/tickets。订单模块:POST /api/order/create(创建订单),POST /api/order/pay(模拟支付),GET /api/order/list(我的订单),POST /api/order/refund(退款申请)。后台管理模块单独放admin前缀,例如GET /api/admin/order/page、GET /api/admin/stats/trend,后台接口需要校验管理员角色。
设计接口时有几个细节值得注意。第一,查询列表接口必须做分页,用MyBatis-Plus的Page对象接收pageNum和pageSize参数,返回总记录数和当前页数据。第二,创建订单接口用了POST而非GET,因为它是写操作,有副作用。第三,所有需要登录的接口,后端通过拦截器统一校验Token,而不是在每个Controller里重复写判断逻辑。
2.3 安全设计:登录态、权限控制与数据校验
毕设系统的安全做到什么程度合适?我的答案是:三个点必须做扎实,其他的可以简化。
登录态用JWT。JWT的结构是Header.Payload.Signature三部分,用户登录成功后,服务端生成一个有效期2小时的Token,里面可以带userId和username。前端拿到Token后存在localStorage里,每次请求在Authorization请求头里带上。拦截器里解析Token,如果能解析出来,就把userId放到ThreadLocal里,方便后续业务代码获取当前登录用户。
权限控制用拦截器+角色字段。用户表里设计一个role字段,0=普通用户,1=管理员。写一个AdminInterceptor,专门拦截/api/admin/**路径,校验当前用户的角色是否为管理员。用户登录接口放行,景点查询接口放行,订单相关接口必须登录。
参数校验要养成习惯。前端虽然做了表单校验,但后端必须再做一次。我用了SpringBoot自带的@Valid注解加上@NotBlank、@NotNull、@Min这些校验注解,在Controller层参数上标上就生效,避免手写一堆if判断。比如创建订单接口中,visit_date不能为空、quantity必须在1到5之间,这些都能通过注解快速搞定。
3. 实操过程与核心环节实现
3.1 项目初始化:父工程、依赖与配置文件
初始化项目这一步,很多人跟着网上的教程敲,但版本不匹配导致各种坑。我用SpringBoot 2.7.x + JDK 8做了毕设,这个组合兼容性最好,如果你用JDK 17以上,再换3.x版本。下面是pom.xml里的核心父工程配置。
xml复制<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
依赖上我引入了spring-boot-starter-web(Web核心)、mybatis-plus-boot-starter(持久层)、mysql-connector-java(数据库驱动)、spring-boot-starter-data-redis(缓存)、jjwt-api和jjwt-impl(JWT工具)、lombok(简化实体类)。注意mybatis-plus和springboot的版本要匹配,MyBatis-Plus 3.5.x对应SpringBoot 2.x没问题,但如果你是SpringBoot 3.x,那要用专门的适配版本,否则启动时会报一堆兼容性错误。
配置文件我用了application.yml,里面最核心的是三块:数据源配置、Redis配置、MyBatis-Plus配置。
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/huangshan_ticket?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
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: assign_id
这里有个经典坑:serverTimezone必须设置为Asia/Shanghai,否则MySQL 8.x的驱动在连接时会报时区错误。另外id-type: assign_id用于让MyBatis-Plus生成全局唯一ID,适合分布式场景的雪花算法。
3.2 核心代码实现:登录逻辑与JWT签发
登录接口是最基础也最常被问到的代码。下面是我实际项目里的实现,值得说明的是BCryptPasswordEncoder的使用。
java复制@Service
public class UserServiceImpl implements UserService {
@Resource
private UserMapper userMapper;
@Resource
private StringRedisTemplate stringRedisTemplate;
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
@Override
public Result login(String username, String password) {
// 1. 根据用户名查询用户
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, username);
User user = userMapper.selectOne(wrapper);
// 2. 用户不存在或者密码不匹配,统一提示,防止暴力枚举用户名
if (user == null || !encoder.matches(password, user.getPassword())) {
return Result.error(400, "用户名或密码错误");
}
// 3. 生成JWT Token
String token = JwtUtil.createToken(user.getId(), user.getUsername(), user.getRole());
// 4. 返回登录结果
Map<String, Object> data = new HashMap<>();
data.put("token", token);
data.put("userInfo", user);
return Result.success(data);
}
}
这一步有几个细节。第一,密码校验一定用encoder.matches()方法,因为BCrypt每次加密同一个明文产生的密文都不一样,不能直接比较密文相等。第二,登录失败统一提示“用户名或密码错误”,不要区分“用户不存在”和“密码错误”,这是安全常识。第三,JWT工具类负责生成Token和解析Token,使用起来就是调用两个静态方法。解析时如果JWT过期或签名异常,直接抛出异常,由全局异常处理器统一捕获并返回401。
3.3 购票核心链路:库存扣减与订单创建
购票是整个系统最核心的链路,也是并发压力最大的地方。这里单说设计思路:一个游客购买“黄山风景区成人票”一张,后端要执行哪些步骤。
第一步,校验。先校验用户是否登录,再校验票型是否存在,校验游玩日期是否在今天之后,校验购买数量是否在合理区间内。
第二步,查余票。这里用到Redis。以stock:spotId:date为key,存储某个日期某个景点的剩余票数。每天凌晨定时任务把当日库存初始化到Redis。当用户发起购票请求时,先判断当前Redis中的剩余量是否充足。
第三步,扣减库存。扣库存不能直接“先查再减”两步走,这中间存在并发问题。正确做法是使用Redis的decrement操作,这个操作是原子的。下面是我项目中库存扣减的实际代码。
java复制public synchronized Result createOrder(OrderCreateDTO dto) {
// 1. 查询票型信息
TicketType ticketType = ticketTypeMapper.selectById(dto.getTicketTypeId());
if (ticketType == null) {
return Result.error(400, "票型不存在");
}
// 2. 计算库存key并校验
String stockKey = "stock:" + ticketType.getSpotId() + ":" + dto.getVisitDate();
String stock = stringRedisTemplate.opsForValue().get(stockKey);
if (stock == null) {
return Result.error(400, "该日期暂未开放预约");
}
// 3. 原子扣减库存
Long remain = stringRedisTemplate.opsForValue().decrement(stockKey, dto.getQuantity());
if (remain == null || remain < 0) {
// 扣减失败,恢复库存,返回已售罄
stringRedisTemplate.opsForValue().increment(stockKey, dto.getQuantity());
return Result.error(400, "今日余票不足,请更换日期或票型");
}
// 4. 创建订单
Order order = new Order();
order.setOrderNo(generateOrderNo());
order.setUserId(CurrentUserHolder.getUserId());
order.setTicketTypeId(dto.getTicketTypeId());
order.setVisitDate(dto.getVisitDate());
order.setQuantity(dto.getQuantity());
order.setTotalAmount(ticketType.getPrice() * dto.getQuantity());
order.setStatus(0); // 待支付
orderMapper.insert(order);
return Result.success(order);
}
上面代码里synchronized关键字只是示例级别的并发控制,真实的高并发场景要引入分布式锁(Redisson),或者直接依赖Redis的原子操作。这里使用decrement本身是原子的,多个请求同时到达时,它们对同一个key的扣减操作是串行化的。如果扣减后变成负数,说明超卖,补回库存并返回失败提示。
第五步,支付。毕设里一般做模拟支付,用户点击“去支付”后调一个/api/order/pay接口,后端直接把订单状态从0改成1,同时生成支付记录。如果要更贴近真实业务,可以接入支付宝沙箱环境,文档完善度会更高,也能在答辩中多讲点内容。
3.4 后台统计报表:订单数据的多维度分析
后台统计是本系统的一大加分项。我实现了三个统计接口:门票销售趋势、景点热度排行、用户购票行为统计。
门票销售趋势的核心SQL是按天分组统计订单金额和订单数。用MyBatis-Plus的QueryWrapper没法做复杂的GROUP BY,所以这里直接写了一个XML里的自定义SQL。mapper层的写法是List<Map<String, Object>> selectSalesTrend(@Param("startDate") String startDate, @Param("endDate") String endDate),在XML里写对应的select语句。
景点热度排行就是左连接订单表和票型表、景点表,按景点分组统计销量。这个查询不复杂,但是对于报表展示来说,图形化是很有说服力的。我用ECharts在Vue前端做了折线图和柱状图,答辩演示的时候直接截两张图放在论文里,效果比纯文字的“系统实现了数据统计功能”强得多。
4. 常见问题与排查技巧实录
4.1 项目启动失败:端口被占用与Bean循环依赖
实际开发中遇到的第一类问题就是启动不起来。最经典的情况是端口被占用。SpringBoot默认端口8080,如果你的电脑上有其他Java进程占用了它,启动会直接失败,报错信息类似于Port 8080 was already in use。排查思路很简单:命令行执行netstat -ano | findstr 8080(Windows)或者lsof -i:8080(Mac/Linux)找到占用端口的进程PID,然后干掉它。或者干脆在application.yml里把端口改成8081,省事。
第二类启动问题是Bean循环依赖。如果你写了A服务注入B,B服务注入A,SpringBoot 2.6及以上版本默认禁止循环依赖,启动直接报错。解决办法有两种:第一种是重构代码,把互相关联的逻辑抽到第三个服务里(推荐);第二种是在配置里临时放开循环依赖,即spring.main.allow-circular-references=true(不推荐)。毕设阶段如果你写着写着发现Service之间互相new来new去,多半就是设计问题,应该停下来重新划分职责。
4.2 MyBatis-Plus使用中的经典报错
MyBatis-Plus有个高频错误:实体类字段映射不上。比如MySQL表字段是visit_date,实体类属性是visitDate,如果没开启驼峰映射,查询结果就是null。解决办法是在application.yml里配置map-underscore-to-camel-case: true(MyBatis-Plus里默认开启)。还有一种情况是你自定义了SQL查询,返回结果用Map<String, Object>接收,那么数据库列名会原样返回,比如total_amount不会自动转成totalAmount,取值时注意key的大小写。
还有Eclipse用户集成MyBatis-Plus时会一直卡在“downloading maven dependencies”这一步。这不是代码问题,是Eclipse自带的Maven仓库下载依赖太慢,或者网络无法访问中央仓库。解决的办法是把Maven的镜像源换成阿里云镜像,在settings.xml里的mirrors节点加入阿里云仓库地址,重启Eclipse后重新update project。这个操作在IDEA里同理,首次import项目时卡在下载依赖,换镜像就好了。
4.3 前端Vue项目空白页的排查
如果使用了前后端分离,Vue项目打包后部署到Nginx,访问是空白页,大概率是publicPath配置问题。Vue CLI打包默认的资源路径是根路径/,如果你的应用部署在子目录/admin下,那要把vue.config.js里的publicPath改为/admin/。另外,路由如果用的是history模式,Nginx需要加try_files配置,否则刷新页面就会出现404。如果懒得折腾,直接把路由改成hash模式,就不会有这个问题。
4.4 版本兼容及IDEA配置问题速查
我把常见问题整理成一个速查表,方便排查。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
SpringBoot启动报ClassNotFoundException: javax.servlet |
SpringBoot 3.x与JDK 8不兼容 | 降级为SpringBoot 2.7.x,或升级JDK 17并用jakarta包 |
| MyBatis-Plus查询返回所有字段为null | 实体类与数据库表驼峰映射未生效 | 检查map-underscore-to-camel-case配置 |
登录时BCryptPasswordEncoder.matches返回false |
注册时加密和登录时加密的强度不一致 | 统一使用同一个BCryptPasswordEncoder实例 |
| Redis连接超时 | 本机Redis未启动或端口不对 | 启动Redis服务,检查application.yml中host/port |
| IDEA中application.yml不提示配置项 | 未添加Spring Boot配置处理器依赖 | 在pom.xml中加入spring-boot-configuration-processor依赖,重启IDEA |
| 前端请求接口报CORS跨域错误 | 前后端端口不同,未配置跨域 | 后端写一个CorsFilter配置类,放行所有来源 |
4.5 数据库连接池与事务失效问题
连接池方面,SpringBoot默认使用HikariCP连接池,一般不需要额外调整。但如果你的MySQL连接超过8小时没活动,可能会报“Connection is not available, request timed out”。解决方法是把连接池的max-lifetime改成和MySQL的wait_timeout一致,或者在连接池配置里加connection-test-query: SELECT 1。
事务失效是个隐蔽问题,特别容易出现在“同一个类内部调用”的场景。比如OrderServiceImpl里有一个createOrderAndPay()方法,它内部又调用了this.createOrder()和this.payOrder(),这两个方法上都加了@Transactional,但事务根本没生效。原因很简单:Spring的事务是通过动态代理实现的,this.xxx()走的是当前对象而不是代理对象,所以注解被忽略了。解决办法是把需要事务的方法拆到独立的类里,注入代理对象后再调用,或者使用AopContext.currentProxy()。我在写购票下单时特意把“扣库存+创建订单+生成支付记录”放在同一个事务方法里,就是为了保证数据一致性。
复盘与实用扩展建议
这个项目从选题到答辩,我前前后后花了大概三周。真正让我觉得有价值的,不是“能跑起来”这个结果,而是把并发控制、事务边界、接口设计、缓存使用这些东西从理论落地成了实际代码。你在做的时候,如果也选类似方向,我建议在基础功能完成后给自己加两道附加题:第一道是用JMeter或Postman模拟100个并发请求同时抢购同一天的票,看看Redis扣减库存是否存在超卖;第二道是引入支付宝沙箱支付或者微信支付H5测试,把支付回调的验签流程走通。这两道题做完,你的项目在深度上已经完全超出普通毕设的平均水平了。
最后再提醒一点:开题报告里写“预期成果”的时候,不要只写“实现一个购票系统”,而是写清楚“实现了一个基于SpringBoot+Vue前后端分离的景区购票系统,支持JWT登录鉴权、Redis库存限流、订单状态机管理和多维度数据统计”。这句话才是你整个项目真正的技术含金量。
