
做毕设那会儿我周围不少同学都扎堆去做网上商城图书管理系统这类选题结果答辩时老师问两句并发控制就卡住了。我当时选了基于JAVA的汽车售票网站理由很简单汽车票务这个场景天然包含车次管理、余票查询、在线购票、订单支付这些业务闭环而且它有一个在管理系统里很难体现的硬核难点——多用户同时抢票时的数据一致性这正好是面试官和答辩老师最爱追问的点。整个项目用SSMSpring SpringMVC MyBatis做后端Vue搭前端前后端分离既覆盖了JavaWeb的核心知识面又有足够的深度可以展开讲。这篇文章就把我完整的实现过程、踩过的坑、以及当初在并发方案上反复折腾的思考路径写出来给正在选这个题或者已经开题的同学一个尽量完整的参考。1. 为什么选汽车售票这个题目业务场景与技术难点分析先说结论汽车售票网站不是另一个增删改查。它在技术难度上恰好卡在一个很舒服的位置——比纯管理后台复杂又没到电商秒杀那种必须上消息队列、Redis预扣库存的级别。用一个最精简的架构也能把核心逻辑讲清楚这非常符合毕设的定位。1.1 汽车票务系统不等于简单增删改查真正复杂的是余票与订单很多人一听汽车售票第一反应是一张车次表加一张订单表加个分页查询就完事了。真做起来你会发现最麻烦的是余票怎么维护。汽车客运和火车不太一样一个班次就是一辆大巴座位总数固定比如49座或55座乘客买票本质就是从这辆车的固定座位集合里占一个位置。这意味着余票不是一个可以随便加减的独立字段它和订单状态强相关。用户下单但未支付时座位算不算被占用如果算那用户超时未支付后怎么释放如果不算那多个人同时下单的瞬间会不会给同一个座位退票和改签会让余票回滚这个回滚过程如果和并发购票同时发生数据怎么保持一致车次还有发车日期、发车时间、票价、车型普通大巴/商务座这些因素叠加后查询余票这个看似简单的操作其实是多条件组合查询。所以我在设计的时候一开始就没打算把余票当成一个纯字段来存而是把它当成一个计算值 冗余字段的组合。这背后其实有个常见误区新手容易直接在bus表里建一个remainSeats每次下单就remainSeats-1看起来简单但一旦涉及下单未支付退票多个订单并发这些现实情况这个字段会变得极其不可信。具体怎么设计后面数据库章节细讲。1.2 这个项目到底需要实现哪些核心功能在动手写代码之前我先把功能边界划清楚了。毕设项目最忌讳的就是什么功能都想加最后什么都做不深。我最终敲定的功能清单是前台用户端注册登录、线路/车次查询、下单购票、订单查询、退票、个人中心。乘客可以按出发城市、到达城市、发车日期来筛车次看到每个车次的余票、票价、发车时间。后台管理端用户管理、线路管理、车次管理发车时间、车型、票价、总座位数、订单管理查看、确认、退票审核、数据统计按日/按线路的售票量。技术侧要求前后端分离后端提供RESTful接口前端用Vue Element UI渲染接口走JSON。数据一致性在购票环节必须有手段保证不能出现显示有票但下单失败或同一座位卖出两次。实际上光是订单状态流转这一件事就够写一篇论文了。我最后在毕业设计说明书里画的用例图、流程图、时序图核心也都围绕这组功能展开。别小看这些图答辩的时候老师第一个翻的就是需求分析和设计文档。2. 技术栈选型复盘为什么是SSMVue以及最新技术的迷思很多同学会纠结明明现在Spring Boot更主流为什么还要用SSM我觉得这个问题要分两层看如果是工作项目那当然能上Spring Boot就上Spring Boot但如果是毕业论文/课程设计SSM反而更容易展示你对框架底层原理的理解。Spring Boot把自动配置、内嵌容器、starter依赖全都封装好了你写起来确实快但答辩时老师一句Spring Boot自动配置的原理是什么就能把你问住。SSM需要手写大量XML或注解配置这个过程本身就是对Spring IOC、AOP、事务管理的深入学习。2.1 SSM框架各层的职责边界以及为什么这个项目用SSM刚刚好SSM是Spring SpringMVC MyBatis的缩写组合起来的分工是Spring负责对象管理IOC容器和事务管理声明式事务。把Service、Mapper这些对象交给容器管理通过Autowired注入代码里就不会到处new对象。事务这块用Transactional在最外层Service方法上声明保证一个业务操作里的多个SQL要么全成功、要么全回滚。SpringMVC负责Web层的请求路由。前端发来的每个URL通过RequestMapping映射到具体的Controller方法请求参数自动绑定到Java对象返回结果自动序列化成JSON。MyBatis负责SQL和数据映射。把SQL写在Mapper XML或者注解里自动把ResultSet转成实体对象。好处是你始终能看到SQL语句本身这对后期优化慢查询、排查数据不一致问题非常方便。结论是SSM组件之间没有强耦合每一层都看得见摸得着。做汽车售票这种中等复杂度的Web应用SSM的手写配置反而成了一个优点——你在论文里可以写好几页配置文件解析这在Spring Boot里根本没法展开。2.2 Vue承担的角色为什么票务网站的前端必须组件化售票网站的页面虽然不算特别多但有很多复用的模块车次卡片车次号、时间、票价、余票数几乎每个页面都有、订单列表、分页组件、表单校验。如果用原生HTML jQuery最常见的做法是写一堆重复的DOM拼接代码项目一改版就四处开花。用Vue组件化之后把车次卡片抽成一个BusCard.vue组件传不同的数据进去就渲染出对应的卡片代码维护成本一下子降了下来。前端构建我选的是Vue 2 Element UI Axios没有上Vuex。为什么不用Vuex因为坦白说汽售票系统的页面间共享状态并不多用户的登录信息放sessionStorage就够用了强行引入Vuex反而增加学习成本代码也更绕。Vuex不是不好是要看场景这一点在答辩时也是可以拿出来讲的——你懂得在适当的场景做减法比什么都往上堆更能体现工程判断力。2.3 不引入Redis和MQ这些中间件边界在哪里做毕设时经常有人建议用Redis存余票吧抢票场景应该用消息队列削峰。想法很好但你要清楚代价Redis和MQ都会引入部署依赖和一致性问题。你的开发机跑一个MySQL Tomcat就够用了再跑一个Redis容器、一个RocketMQ光是环境折腾就可能耗掉你好几天。那并发下的数据安全怎么办我在后面第5章详细讲核心思路是数据库悲观锁唯一索引兜底。应对毕设级别的并发量几十到几百个用户模拟抢票数据库层面的锁已经完全够用。Redis MQ 分布式锁是生产环境的选择你可以在论文的展望章节提一句、知道那个方向的思路是什么但实现层面不必硬上。会做减法且能自圆其说才是成熟的表现。3. 数据库设计用一张车次表订单表撑起所有核心业务数据库是整个系统里我最先动手且思考时间最长的一部分。表设计一旦有问题后面写多少代码都是打补丁。我先把最终的核心表结构贴出来再解释为什么这样做。3.1 核心表结构用户、线路、车次、订单我最后建了6张核心表外加几张辅助表。下面只挑最重要的几张说表名关键字段设计说明userid, username, password(MD5), phone, real_name, id_card购票实名制需要身份证号退票时校验routeid, start_city, end_city, distance, base_price一条线路的基础信息票价根据车型系数浮动busid, bus_number, bus_type(1普通/2商务), seat_count车辆本身座位总数在这里定义scheduleid, route_id, bus_id, depart_date, depart_time, arrive_time, price, remain_seats某天某线路某辆车的具体班次余票是冗余字段seatid, schedule_id, seat_no, status(0空闲/1锁定/2已售)最细粒度的座位状态表ordersid, order_no, user_id, schedule_id, seat_id, price, status(0待支付/1已支付/2已退票/3已过期), create_time订单表关联到具体座位这六张表的关系用大白话描述就是用户选了一条route在某个schedule下买了一张票系统从seat表里分配一个座位生成一条orders记录。很多人会问既然有schedule.remain_seats为什么还要一张seat表直接数订单不就行了我的答案是**seat表的价值在于它让座位变成了可以被锁定和分配的资源而不是一个模糊的数字。**有了seat表你在做并发控制时可以通过SELECT ... FOR UPDATE锁住某一行座位这是数据库层面最稳妥的方案。另外后期如果要加在线选座功能seat表直接就能支持不用再重构数据库。3.2 余票字段remain_seats的取舍冗余字段的利与弊remain_seats在schedule表里每次查询车次列表时直接返回不需要实时COUNT()计算查询性能很好。但它是典型的冗余字段,一旦订单状态变化必须同步维护它。为了保证一致性我不能让余票的增减散落在业务代码各个角落而是把所有会改变座位状态的操作收敛到几个固定SQL里下单未支付用UPDATE schedule SET remain_seats remain_seats - 1 WHERE id ? AND remain_seats 0这种带条件的update天然自带原子性不会超卖。订单超时/用户取消UPDATE schedule SET remain_seats remain_seats 1 WHERE id ?同时更新订单状态。完成支付订单状态从待支付变成已支付但seat状态在付完款之后才改成已售。注意下单时seat是锁定状态不是已售。这样就算有100个人同时锁了座位只有支付成功的人才真正占用座位没有支付的人座位最终会释放。这里有个血泪教训我一开始没建seat表只在orders表里记录买了哪个班次多少张票结果退票和并发场景下完全没法保证一个座位卖两次。后来增加了seat表和乐观锁之后整个逻辑才清晰起来。在动手写Controller之前一定要把表结构想清楚尤其是那些状态流转的字段。3.3 索引设计车次查询和订单查询的SQL才能跑得快就几张表、几千条数据量其实不建索引也能跑但为了体现工程规范、也让答辩时能拿出点东西讲我建了这几个索引schedule表(route_id, depart_date)联合索引因为前台最核心的查询是查某条线路某天有哪些班次。orders表user_id索引个人中心查订单要用order_no唯一索引保证订单号不重复也是幂等操作的兜底。orders表(schedule_id, seat_id)联合索引用于定位某个班次某个座位的订单是否存在防止同一座位被重复下单。索引这个东西原理上无非是B树但实际建的时候要结合查询场景不然就是白占空间。我建索引的原则就一句话按前台页面最常用的WHERE条件来建不是为了建而建。4. 后端分层实现SSM框架里那些零散但致命的细节技术选型和表结构都定了接下来就是往框架里填代码。这部分我不会把完整代码贴出来太长了而是把容易踩坑、容易被答辩老师追问的点摊开讲。4.1 配置文件的坑扫描包、数据源、事务管理器SSM的配置文件分散在web.xml、spring-mvc.xml、spring-mybatis.xml里。最容易出的问题是扫描包重复导致Bean冲突。比如Spring的配置扫描了com.xxx.controllerSpringMVC的配置也扫描了然后你启动Tomcat时可能会看到各种奇怪的BeanDefinitionStoreException。我在第一次整合时就是因为这个浪费了半天。我的做法是分工明确spring-mybatis.xmlSpring父容器负责扫描Service、Dao、实体类配置数据源、SqlSessionFactory、事务管理器。spring-mvc.xmlSpringMVC子容器只扫描Controller。Controller里注入的Service对象来自父容器的Bean父子容器层级关系清晰不会有冲突。还有一个细节是Spring的声明式事务。用tx:annotation-driven transaction-managertransactionManager/开启注解事务后事务会作用在Transactional标注的Service方法上。这一点要特别注意**事务放在Service层不要放在Controller层。**Controller只负责收参数和返回结果真正的业务操作要在Service里完成这样事务的边界才可控。4.2 Controller层开发参数绑定、日期格式化、统一返回类型SpringMVC接收前端JSON参数时如果参数里有日期类型比如departDate默认格式和前端传过来的可能不一致导致400 Bad Request。解决方法是把日期转换器配好比如在spring-mvc.xml里注册一个CustomDateEditor或者在实体类的日期字段上加JsonFormat(pattern yyyy-MM-dd)。这个坑几乎每个人都会遇到提前踩了后面省很多事。另外我强烈建议所有Controller接口统一返回一个ResultT对象里面至少包含code、message、data三个字段。前端Axios拦截响应后先判断code再决定是否取data这样错误处理非常统一不用每个接口单独写一套判断逻辑。public class ResultT { private Integer code; // 200成功500失败 private String message; private T data; }4.3 Service层的事务控制为什么TransactionTemplate是兜底方案前面提到用Transactional注解控制事务但需要注意的是Transactional默认只在抛出RuntimeException时回滚如果你在方法里try-catch吞掉了异常事务是不会回滚的。最常见的错误就是Transactional public void createOrder(...) { try { orderDao.insert(order); scheduleDao.decreaseSeat(scheduleId); } catch (Exception e) { // 这里如果吞掉异常座位扣了但订单没生成数据就错了 } }所以我在关键业务代码里宁可不用这个注解而是直接注入PlatformTransactionManager用TransactionTemplate手动控制。原因很简单**手动事务意味着你自己控制什么时候提交、什么时候回滚异常路径更清晰而且不会被代理没生效异常被吞掉这类问题坑到。**工作中很多人依赖声明式事务但如果你在毕设里能把事务的两种实现方式都讲清楚会显得你对框架底层原理是真理解。4.4 MyBatis映射用动态SQL搞定多条件车次查询车次查询的SQL是动态的出发城市、到达城市、发车日期三个条件用户可能只填一个。这种查询最合适的做法是MyBatis的where标签if判断动态拼接WHERE子句。举例来说select idquerySchedule resultTypeScheduleVO SELECT s.*, r.start_city, r.end_city FROM schedule s LEFT JOIN route r ON s.route_id r.id where if teststartCity ! null and startCity ! AND r.start_city #{startCity} /if if testendCity ! null and endCity ! AND r.end_city #{endCity} /if if testdepartDate ! null AND s.depart_date #{departDate} /if /where /select这里必须用#{startCity}而不是${startCity}。前者是预编译参数占位符能防SQL注入后者是字符串拼接虽然有少数场景要用但传用户输入的值时永远不要用${}。这个知识点答辩时被问到的概率极高。4.5 前端Axios的封装与跨域处理纯前后端分离开发时前端运行在http://localhost:8081Vue默认端口后端跑在http://localhost:8080Tomcat这就必然遇到跨域问题。我的处理方式有两步第一在后端SpringMVC配置中开启CORSConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(Registry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:8081) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true); } }注意我这里的allowedOrigins写的是具体地址而不是*。如果写*再开着allowCredentials(true)浏览器会直接报错因为带凭证的请求不允许使用通配符域名。这点坑我记忆犹新也是未来做生产项目时会遇到的问题。第二在前端封装一个统一的request.js配置axios的baseURL和拦截器。拦截器统一处理登录失效跳转、错误提示这样每个页面调用接口时只需要关心业务数据重复代码少很多。5. 数据一致性实战并发购票时如何保证不超卖、不重卖这一章是全文的重点也是在答辩时最能给你加分的地方。题目虽然是汽车售票网站但它真正考察的技术难点就是并发下的数据安全。我把完整的思考和踩坑过程写出来。5.1 问题引入什么是超卖、重卖、死锁假设一辆车有49个座位remain_seats49。两个用户同时下单都执行了SELECT * FROM schedule WHERE id 1; -- 看到剩余49 UPDATE schedule SET remain_seats 48; -- 两个请求都执行这条最终余票48 INSERT INTO orders ...; -- 但生成了两笔订单最终余票还剩48但卖出了2张票——这就是严重的超卖卖出的票比实际座位多。如果再加一层两个订单同时把同一个座位seat_no 1号写进订单表那就是重卖同一个座位给了两个人。这在汽车客运里是绝对不能接受的事故。5.2 方案一乐观锁用版本号怼掉冲突我的第一版代码用的是乐观锁在schedule表里加了一个version字段更新余票时带上版本条件UPDATE schedule SET remain_seats remain_seats - 1, version version 1 WHERE id #{scheduleId} AND version #{oldVersion}这种方案的好处是性能好不加锁并发量高适合读多写少的场景。但问题也很明显它只能保证余票字段自减不超卖却没法保证订单和座位的绑定唯一。你减掉了余票仍然可能把同一个seat_no分配给两个用户。因为乐观锁保护的资源粒度是schedule表的一行而不是seat表的具体座位行。如果要用乐观锁配合座位分配你就得在seat表也加版本号或唯一索引复杂度一下就上来了。5.3 方案二悲观锁直接锁行后来我改成了悲观锁方案用的是SELECT ... FOR UPDATE。事务里先查座位并上锁Transactional public Order createOrder(User user, Schedule schedule, String seatNo) { // 锁住这个座位的行记录 Seat seat seatMapper.lockSeatForUpdate(schedule.getId(), seatNo); if (seat null || seat.getStatus() ! 0) { throw new RuntimeException(座位已被占用); } // 座位锁定了生成订单 Order order new Order(); order.setUser_id(user.getId()); order.setSchedule_id(schedule.getId()); order.setSeat_id(seat.getId()); // ... 设置订单号、价格、状态0待支付 orderMapper.insert(order); // 座位状态改成锁定 seatMapper.updateStatus(seat.getId(), 1); // 余票减一这里用的是条件UPDATE兜底 scheduleMapper.decreaseRemainSeats(schedule.getId()); return order; }核心是这个SQLSELECT * FROM seat WHERE schedule_id #{scheduleId} AND seat_no #{seatNo} FOR UPDATE;FOR UPDATE会把符合条件的行锁住直到当前事务提交或回滚才释放。也就是说两个用户同时抢同一个座位时第二个请求会一直阻塞等待等第一个请求的事务完成后才读到最新状态——此时座位状态已经变成锁定直接抛出座位已被占用。这从根本上杜绝了同一座位卖两次的问题。代价是性能会下降行锁串行化但汽车售票一个班次的座位本来就那么多一个用户抢一个座位这个场景天然就是串行的所以悲观锁在这里一点不冤枉。5.4 方案三唯一索引的兜底设计即使有了FOR UPDATE我还是加了一道保险在orders表上建一个(schedule_id, seat_id)的唯一索引。意思是数据库层面上同一个班次、同一个座位最多只能存在一条有效订单记录。这样一来如果有任何漏网之鱼通过不同的代码路径往orders表插了两条相同座位的记录数据库自己就会报Duplicate entry错误事务直接回滚。这是最后一道物理防线也是我答辩时重点讲的一个设计。5.5 下单锁定的业务状态待支付与自动释放锁定了座位后订单状态是待支付但如果用户一直不支付怎么办座位会一直被锁着吗我设置了一个30分钟的支付有效期。实现方式有两种一种是在创建订单时记录create_time定时任务每隔一段时间扫描待支付且超时的订单把对应座位状态改回空闲余票1。另一种更简单些用户查询车次列表时如果用到一个接口动态计算有效锁座数那实际上就是把超时释放的逻辑前置到了查询阶段——不过这样不够干净。我采用的是定时任务方案。在Spring里配置一个定时任务每1分钟跑一次找出所有status0且create_time超过30分钟的订单把订单标记成已过期同时释放座位Component public class OrderTimeoutTask { Scheduled(fixedDelay 60000) public void releaseExpiredOrders() { ListOrder expiredOrders orderMapper.selectExpiredOrders(30); for (Order order : expiredOrders) { // 事务内更新订单状态为过期座位状态改为空闲余票1 orderTimeoutService.releaseOrder(order.getId()); } } }这里用到了Spring Task的Scheduled注解需要在spring配置文件里加一行task:annotation-driven/。整个过程逻辑不复杂但很实用也是业务闭环里不可或缺的一环。6. 前端Vue实操路由、组件通信、Axios拦截器在售票场景中的落地后端接口全部跑通之后前端的工作量其实也不小。Vue部分最核心的不是语法而是整个前端项目怎么组织、页面之间怎么通信、怎么和后端联动。6.1 项目目录与路由按照用户端/管理端分模块我的Vue项目是直接用Vue CLI创建的目录结构大概是这样src/views下面分user用户端页面车次列表、下单页、订单列表、登录注册和admin后台管理端页面车次管理、订单管理、数据统计。路由用Vue Router配置了/bus/list、/bus/detail/:id、/order/list这些路径。路由懒加载用component: () import(/views/user/BusList.vue)这样首屏加载快一些打包后的文件也会按路由分包。后台管理页面加了一个路由守卫beforeEach判断本地有没有token没有就跳登录页。这个逻辑很简单但很有效避免用户直接打URL进入后台页面。Vue Router还有一个实用场景是路由传参。从车次列表点进下单页时我用的是params传参传一个scheduleId下单页通过this.$route.params.scheduleId拿到它去调用后端查询详情。如果用拼接查询字符串的方式刷新页面后参数会丢失需要注意。6.2 组件的拆分与通信车次卡片、搜索框、订单状态标签车次列表页我拆了三个组件SearchBar.vue出发城市、到达城市、发车日期的搜索条件通过$emit把查询条件抛给父组件。BusCard.vue展示单个车次的信息卡片接收一个schedule对象作为prop显示发车时间、余票、票价余票少于10张时显示仅剩X张的提示。Pagination.vue分页组件因为列表接口返回了总数前端要分页展示。组件通信这里有个经验**父子组件之间用props和$emit就够了跨页面共享的状态才考虑放Vuex。**我最后没有用Vuex跨页面的刚买的票的订单号这种临时数据我直接放在sessionStorage里下单成功页从sessionStorage读用完就删逻辑很直接。6.3 Axios拦截器请求携带Token 统一错误处理登录接口返回一个token前端存储到localStorage。之后每次发请求都要带上这个token用于后端识别用户身份。我不在每个接口里手动加header而是在Axios请求拦截器里统一处理service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; });响应拦截器的职责是如果后端返回code!200弹出一个统一的错误提示如果某个接口返回了认证失败的状态码比如登录超时就跳回登录页。这样一来页面里所有异步请求的错误展示风格是统一的也不会出现网络报错但前端什么都没提示这种糟糕体验。6.4 Vue播放/展示相关的非核心流程一律砍掉开发时尤其要克制加功能的冲动。我看过有人给毕设售票网站加视频播放、加地图组件、加聊天室这都会严重拖慢项目进度。我当时的参考搜索里出现过vue image能显示pdf吗vue播放m3u8这类词但冷静想想这些和售票核心业务的关系非常弱如果加了就属于自找麻烦。毕设项目功能边界清晰是很重要的加分项老师问你这个功能存在的意义是什么的时候你要能答上来而不是说网上看到有就加了。6.5 打包与部署vue项目怎么放进Spring Boot/Tomcat毕设部署有两种常见方式我都试过方式一前后端分离部署后端打成war包放Tomcat的webapps前端npm run build之后生成的dist目录扔到Nginx里Nginx配置一个proxy_pass把/api开头的请求转发到Tomcat的8080端口。这种方式最接近真实生产环境但答辩现场演示要同时启动Nginx和Tomcat稍麻烦。方式二静态资源并入Tomcat把前端build后的文件复制到后端webapp的root目录下这样只要启动Tomcat一个服务就能同时提供前后端页面。开发大哥们管这叫一体化部署好处是简单但代码层面的API路径要做成相对路径不然接口请求会404。我当时在现场演示用的方式二开发阶段用方式一两不耽误。7. 环境搭建与调试效率开题前把这些配好能省半个月时间最后聊一个很现实的话题。很多时候毕设拖进度不是因为代码不会写而是环境反复出问题。Java环境变量、Maven仓库、MySQL版本、Tomcat版本任何一个对不上都可能导致项目起不来。我把自己整理过的环境清单和几个最典型的报错放这里帮你减少排查时间。7.1 基础环境清单与版本匹配JDK版本1.8。别为了追新装JDK 17SSM框架和Tomcat 8.5在JDK8下最稳。装了高版本容易遇到不支持class版本这种莫名其妙的问题。Maven3.6.3配置好阿里云镜像否则下载依赖会让你怀疑人生。在settings.xml的mirror里加上阿里云的central仓库下载速度至少快十倍。MySQL5.7。注意MySQL 8和SSM整合时驱动名和时区配置不一样。MySQL 8要用com.mysql.cj.jdbc.Driver连接URL要加serverTimezoneAsia/Shanghai这俩忘了任何一个都连不上。Tomcat8.5。配好编码URIEncodingUTF-8否则中文参数可能乱码。7.2 两个高频报错的快速定位思路第一个是org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)。这个报错说白了就是MyBatis找不到Mapper接口对应的SQL。先检查Mapper接口的全限定名和XML文件里的namespace是否一致再检查target/classes里有没有把XML文件打包进去——Maven默认只把resources目录下的XML打包如果你的Mapper XML放在java目录下需要在pom.xml里单独配置resource。第二个是数据库中文乱码。改三处数据库连接URL加characterEncodingutf8、建表时用ENGINEInnoDB DEFAULT CHARSETutf8mb4、前端页面统一UTF-8。这三处都改对了乱码基本就消失了。utf8mb4比utf8多支持一些特殊字符比如emoji而且是MySQL 5.7后的默认趋势直接建表就按utf8mb4写。7.3 开发调试的效率工具组合后端接口调试我用的Postman定义好全局变量环境本地、服务器各一套换环境只需要改一个host变量。SQL调试我推荐在Navicat里先把所有Mapper里的SQL手动跑一遍确定没问题再在代码里调。这样可以把SQL写错和代码写错这两个问题隔离开排错快很多。前端调试主要靠Chrome DevTools的Vue Devtools插件。在Vue Devtools里可以直接查看组件的data和props还可以直接修改组件状态看页面变化排查数据对不对、渲染对不对非常高效。7.4 答辩演示前一定要准备的数据与脚本现场演示最尴尬的事情是打开系统后发现没有数据、点查询出不来结果。我在答辩前专门往数据库里导入了两组演示数据一组是3天内的班次数据覆盖今天有票今天余票紧张剩2张明天无票三种状态方便演示前台余票提示。一组是后台管理的数据统计按线路分组统计出票数生成有说服力的柱状图或表格演示管理端时的效果比空数据好得多。另外我还写了一个init_data.sql脚本里面除了建表语句还带了一部分初始数据。这样的话就算答辩现场用的电脑数据库是全新的也能快速把演示环境恢复出来。这条经验很朴素但关键时刻能救你一命。到这里整个汽车售票网站的设计与实现基本就讲完了。有一点我始终觉得值得拿出来强调做这个项目的收获全都在细节里。配置类的坑让你真正理解了框架的装配过程并发方案的对比让你想明白数据一致性不是一句加锁那么简单前端组件化让你感受到代码复用对后期维护有多大帮助。如果你也选了这个题目不要只盯着把网站跑起来这个最低目标试着把每个关键决策背后的取舍写清楚、讲明白你的毕业设计会更有分量。要是在某些具体环节卡住了比如并发方案选型拿不准、某个报错搞不定也可以带着具体情况再交流我尽量帮你把问题定位到具体代码上去。