
简介这是一份基于SSM框架与Vue.js的航空票务推荐管理系统毕业设计源码面向计算机、软件工程、电子信息等专业学生适合课程设计、期末大作业或毕设参考。项目包含完整的Spring、SpringMVC、MyBatis后端代码以及Vue动态交互界面并配有Mysql数据库脚本和毕业论文文档可直接下载部署运行。压缩包内共789个文件涵盖163个svg图标、159个js脚本、98个java源码、56个vue组件、51个css样式、html页面以及sql数据库脚本等整体大小25.26MB结构清晰便于按需查阅和二次开发。已有116人学习浏览资源内还包含说明文档、启动脚本1-install.bat、2-run.bat、3-build.bat及演示视频能够帮助使用者快速理解项目启动流程、数据库初始化方法和核心业务逻辑适合想通过完整项目提升前后端开发与调试能力的读者参考借鉴。1. 从毕设选题到可用系统SSM 加 Vue 的航空票务推荐该怎么落地航空票务推荐管理系统是典型的“业务有纵深、技术有看点”的选题域对象之间关系复杂订单、航班、舱位、会员都有状态流转同时它又带一个推荐模块天然适合讲协同过滤、规则召回这些算法思路。而搜索里大量出现“ssm框架”“vue安装依赖”“java八股文”这类词说明多数人真正关心的是同一件事这套源码能不能在自己电脑上跑起来能不能在答辩时讲清楚为什么这么设计。我见过太多把这类系统做成“CRUD 大杂烩”的案例前端几十个表单页面后端每个表一套增删改查推荐模块最后变成“查最近航班列表”。这不是管理系统的错而是技术选型时没把层次划清。SSM 负责接口层和业务层Vue 负责页面交互二者通过 REST 接口衔接推荐逻辑单独拆成一个服务模块——这套组合到今天仍然是改造成本最低、招聘市场认知度最高的路线之一前提是你要把它当成一个“能上线”的系统来做而不是当成作业交差。2. SSM 与 Vue 的分层边界先搞清楚谁该干什么2.1 SSM 在这里的真实角色是接口层不是页面层很多人一提到 SSM 就想到 JSP 页面。在票务系统里我更推荐把 SpringMVC 完全当作 REST 接口层不再返回 ModelAndView而是统一返回 JSON。这样做有几个明确的好处Vue 端可以独立开发调试后端接口可以用 Postman 或 Apifox 直接测通后续如果要换前端框架或者增加移动端接口不需要改动。后端按“Controller — Service — Mapper”三层组织但每一层职责要收敛。Controller 只做参数校验和结果包装不写任何业务判断Service 层承载事务、状态流转和推荐策略Mapper 层对应 MyBatis 的 SQL 映射复杂查询走 XML简单操作走注解。一个具体的接口定义是这样的RestController RequestMapping(/api/flight) public class FlightController { Autowired private FlightService flightService; GetMapping(/search) public Result search(RequestParam String depCity, RequestParam String arrCity, RequestParam String depDate, RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size) { PageResultFlightVO pageResult flightService.search(depCity, arrCity, depDate, page, size); return Result.success(pageResult); } }这段代码的关键在于FlightVO它不是数据库实体FlightDO的直接返回而是经过字段裁剪的视图对象。比如数据库里存了舱位 JSON 字符串VO 里就要解析成 List 结构返回避免前端拿到一坨需要自己 parse 的字符串。参数校验放在 Controller 这一层用 JSR 303 注解或者手动判断都可以但空指针异常绝不能在 Service 里才暴露出来。2.2 Vue 侧的页面组织权限和状态得先定好前端我通常会按“视图 — 组件 — API 模块”来组织。视图是路由级别的页面组件是可复用的局部单元API 模块统一封装 axios 请求。票务系统最容易被忽视的是航班列表页和订单页之间的状态同步比如用户选了航班、填了乘机人、提交订单后返回列表页的筛选条件应该保留。Vue 3 的script setup写法下一个航班列表页的核心逻辑是这样组织的script setup import { ref, onMounted } from vue import { searchFlights } from /api/flight import { useRouter } from vue-router const router useRouter() const keyword ref() const flightList ref([]) const loading ref(false) const fetchList async () { loading.value true try { const res await searchFlights(keyword.value) flightList.value res.data.list } finally { loading.value false } } const goBook (flightId) { router.push({ path: /booking, query: { flightId } }) } onMounted(fetchList) /script这里searchFlights不是直接写死 URL而是统一走/api/flight模块里面封装了 axios 实例和拦截器。拦截器统一处理 token 注入401 跳转登录页后端返回code ! 200时统一弹出错误提示。这些逻辑如果散落在每个页面组件里后期一旦改动会很痛苦。路由守卫也要提前想清楚。票务系统的页面分为公开页和登录页航班查询是公开的但下单、退票、订单查询必须登录。这个判断写在router.beforeEach里比在每个页面里判断localStorage.hasToken要干净得多。2.3 数据库设计的这四张表决定系统上限表名关键字段作用与说明flightid, flight_no, dep_city, arr_city, dep_time, arr_time, cabin_info航班基础信息cabin_info 用 JSON 存多个舱位memberid, username, password, phone, level会员表level 字段做差异化折扣和推荐权重orderid, order_no, member_id, flight_id, cabin_type, status, create_time订单表status 字段做状态机流转behaviorid, member_id, flight_id, action_type, create_time行为记录表浏览、收藏、下单都记录cabin_info存 JSON 是一开始就要定的。经济舱、公务舱、头等舱的价格、余票、折扣规则都不一样如果拆成独立表再加外键查询一次航班要 join 三次。JSON 字段在 MySQL 5.7 以上支持索引表达式配合 MyBatis 的typeHandler解析性能和可维护性反而更好。订单号的生成不推荐用自增主键直接暴露。常见的做法是用“日期 随机数 自增ID”拼一个业务单号比如20240512103045123456这样订单量上去了也不会暴露系统真实日单量。3. 航班检索与订单状态机先把核心链路写扎实3.1 多条件航班检索的 SQL 写法索引怎么建才不失效航班检索是票务系统被问最多的接口。常见的筛选条件包括起降城市、日期、舱位类型、价格区间、出发时段。用 MyBatis 的动态 SQL 拼条件是基本功但很多人拼完之后发现全表扫描问题往往出在索引设计上。常规做法是把索引建在dep_city, arr_city, dep_date这三个字段的联合索引上。SQL 里要特别注意一点如果用户没选dep_date这个条件动态 SQL 把这段去掉联合索引还能生效吗答案是只要最左前缀成立索引就可以用。但如果用户只传dep_date而没传dep_city联合索引会直接失效。所以我的习惯是建两个索引idx_city_date(dep_city, arr_city, dep_date)和idx_dep_date(dep_date)。这样无论用户怎么筛至少有一个索引能命中。再加idx_status(flight_status)单列索引后面的构建推荐缓存会用到。下面是 Mapper XML 里的核心查询写法select idsearchByCondition resultTypecom.example.vo.FlightVO SELECT f.id, f.flight_no, f.dep_city, f.arr_city, f.dep_time, f.arr_time, f.cabin_info FROM flight f where if testdepCity ! null and depCity ! AND f.dep_city #{depCity} /if if testarrCity ! null and arrCity ! AND f.arr_city #{arrCity} /if if testdepDate ! null AND DATE(f.dep_time) #{depDate} /if if testmaxPrice ! null AND JSON_UNQUOTE(JSON_EXTRACT(f.cabin_info, $.economy.price)) lt; #{maxPrice} /if /where ORDER BY f.dep_time ASC LIMIT #{offset}, #{limit} /select这段 SQL 里最容易踩坑的是DATE(f.dep_time) #{depDate}。因为dep_time是 datetime 类型对字段包一层DATE()函数会导致索引失效。更好的写法是改成范围查询f.dep_time #{depDate} AND f.dep_time DATE_ADD(#{depDate}, INTERVAL 1 DAY)。这样既保持索引命中逻辑上也对——查的是当天零点到次日零点之间的全部航班。JSON_EXTRACT的价格过滤在数据量小时没问题但数据量大时 JSON 字段的查询性能确实不如普通列。如果后续性能不够可以把经济舱价格单独抽成一个列或者做法是建一张舱位子表把价格和余票放进去。3.2 订单状态机的设计别把状态随便填订单在票务系统里至少要经历这么几个状态待支付、已支付、出票中、已出票、已退票、已过期。如果时间倒推到几年前很多人会在 order 表里直接存一个 status 字段然后用 if-else 判断。这样开发是快但一旦业务规则复杂了代码会到处散落着“为什么这个状态能跳转到那个状态”的魔法判断。我一般会在 Service 层单独建一个状态机校验组件所有状态流转都必须经过它。核心代码如下public class OrderStatusMachine { private static final MapInteger, ListInteger TRANSITIONS new HashMap(); static { TRANSITIONS.put(0, Arrays.asList(1)); TRANSITIONS.put(1, Arrays.asList(2, 4)); TRANSITIONS.put(2, Arrays.asList(3, 4)); TRANSITIONS.put(3, Arrays.asList(4)); } public static boolean canTransit(int from, int to) { ListInteger allowed TRANSITIONS.get(from); return allowed ! null allowed.contains(to); } }这里的状态用数字常量表示0 待支付、1 已支付、2 出票中、3 已出票、4 已退票。每个状态能跳转到哪些状态一目了然。写业务代码的时候就变成了if (!OrderStatusMachine.canTransit(order.getStatus(), targetStatus)) { throw new BizException(该订单当前状态不允许此操作); }这样做的另一个好处是毕业论文里有内容可写。可以直接在论文里画一张状态流转表评审老师一眼就能看出你对业务逻辑有思考而不是机械地“把数据库字段改成 xx 值”。3.3 支付回调的幂等处理这是最容易翻车的地方票务系统当有支付对接时最容易翻车的不是支付接口本身而是回调通知。第三方支付平台会多次发送异步通知每次通知携带同样的订单号和交易号。如果回调处理逻辑没有做幂等第一遍已经更新了订单状态第二遍回调来了又执行一遍就可能出现把“已出票”状态覆盖回“已支付”的问题。常见的做法是用订单号加一个状态条件更新来实现天然幂等比锁表或 Redis 锁都要轻量Transactional public void handlePayNotify(String orderNo, String tradeNo) { int updated orderMapper.updateStatusWhenMatch( orderNo, OrderStatus.PENDING_PAY, // 旧状态 OrderStatus.PAID // 新状态 ); if (updated 0) { log.warn(order {} already processed or not pending, orderNo); return; } // 后续做法扣减库存、生成出票任务、发送通知 inventoryService.decrement(orderNo); ticketIssueService.sendAsync(orderNo); }updateStatusWhenMatch的 SQL 是这样的UPDATE orders SET status ? WHERE order_no ? AND status ?。注意这里更新的影响行数updated只有 1 或 0如果是 0说明这个订单压根不在待支付状态直接跳过后续逻辑。这段代码里最核心的是“先更新状态再执行业务操作”顺序反过来的话有可能业务操作执行完毕了但状态更新失败结果用户付了钱却没有出票。4. 推荐模块从 0 到 1冷启动、召回与排序4.1 冷启动阶段的推荐策略直接用规则召回推荐模块是这套系统里最需要动脑子的地方也是最容易做出“伪智能”的部分。如果用户的浏览和收藏记录很少任何基于协同过滤的算法都算不出什么结果。冷启动阶段要先用规则召回兜底常见的策略是“同航线最近热销 同城市出发 折扣促销优先”。这里不需要为了体现技术难度而去引入 Spark 或 Flink 之类的重型组件体量有限的票务推荐用一个 Spring 注解调度的定时任务就能搞定。Component public class FlightRecommendTask { Autowired private FlightMapper flightMapper; Autowired private RecommendCache recommendCache; Scheduled(cron 0 0 3 * * ?) public void buildHotFlightCache() { ListFlightVO hotList flightMapper.selectHotFlights(50); recommendCache.put(hot_flight_list, hotList); log.info(hot flight cache refreshed, size{}, hotList.size()); } }selectHotFlights的 SQL 按近 7 天订单量排序同时过滤掉已停飞的航班。注意 cron 表达式固定到凌晨 3 点是因为这个时间段业务流量最低。这个定时任务的粒度按天刷新就够了航班列表不像资讯信息那样分钟级变动。4.2 基于用户行为的协同过滤为什么不建议全量算当用户行为数据积累到一定量之后可以做基于物品的协同过滤。所谓基于物品就是“和你浏览过的航班相似的其他航班”。实现上大体分三步构建用户—航班行为矩阵、计算航班之间的相似度、推荐与用户历史行为最相似的航班。第一步的行为矩阵不要用什么稀疏矩阵库直接用 HashMap 就能维护。这里有个关键点行为要加权下单的权重远高于浏览。比如浏览记 1 分、收藏记 3 分、下单记 8 分。因为浏览可能是误点收藏表达明确意向下单是决定性行为。用户对每趟航班的偏好分计算公式是基础写法但也是论文里最能体现逻辑的部分public double calculatePreference(int browseCount, int favoriteCount, int orderCount) { return browseCount * 1.0 favoriteCount * 3.0 orderCount * 8.0; }第二步的物品相似度如果用户量不大可以用余弦相似度实现。这里要注意的是航空票务的“物品”和电商的“物品”有一个显著差异航班具有强时效性昨天的航班今天已经没有任何推荐意义。所以参与相似度计算的航班必须是未来 7 天内仍可预订的。也就是说计算相似度的输入要加上一个时间过滤条件。第三步生成推荐列表时要排重。用户已经下过单的航班不应该再次出现这个过滤逻辑可以放在 SQL 里用NOT IN (SELECT flight_id FROM orders WHERE member_id ?)实现。到这里推荐模块可以拆成两个阶段recall召回和 rank排序。召回阶段负责把候选集从几万收敛到几百排序阶段再根据用户偏好分精确排。工程上recall 的结果可以缓存到 Rediskey 设计成user:{memberId}:recommend过期时间设为 2 小时。用户行为变化了最多 2 小时后推荐列表就会更新不需要实时计算。4.3 推荐结果的存储与接口注意缓存还是实时算有两种做法一种是“定时算好存 Redis”另一种是“用户请求时实时算”。冷启动场景下我推荐实时算因为离线任务算出来的热榜对每个用户都一样谈不上个性化。而实时算的最大问题在于性能。解决办法是限流和降级推荐接口一旦响应超过 300ms直接返回热榜缓存不阻塞主流程下单。接口层面这样设计GetMapping(/recommend) public Result recommend(RequestParam Long memberId) { long start System.currentTimeMillis(); ListFlightVO recList recommendService.recommendForMember(memberId); if (System.currentTimeMillis() - start 300 || recList.isEmpty()) { recList recommendCache.getHotFlightList(); } return Result.success(recList); }这段代码的含义是无论推荐引擎给出什么结果只要耗时长于 300ms 或结果为空都降级到热榜。用户体验的角度热榜虽然不是千人千面但至少页面不会白屏。这种降级策略在答辩时提出来评审老师会认为你考虑过真实场景。5. 源码排错、论文写法与性能避坑的四个关键点5.1 前后端联调时的跨域问题先分清 CORS 和反向代理SSM 后端的端口一般是 8080Vue 开发服务器是 5173Vite或 8081这就引入了跨域问题。常见的解决方案是后端的 CORS 配置前端开发环境使用 Vite 的 proxy 代理。生产部署时由 Nginx 统一转发不存在跨域问题。尽量减少在 Controller 类上直接写CrossOrigin注解的做法因为这种方式对路径和方法控制比较粗糙。更可控的方案是写一个全局的 WebMvcConfigurer 配置类统一设置允许的来源、方法、请求头和凭证。注意allowCredentials(true)的时候allowedOrigins不能是*必须明确指定来源这是浏览器的安全策略限制和代理工具无关。5.2 MySQL 连接参数和连接池默认配置一定会踩坑如果直接依赖 MyBatis 的默认数据源配置联调阶段没问题但一旦部署到服务器上跑几小时后就会开始报“Connection is not available, request timed out”。这多半是连接池参数没有按需调整。在application.yml里按这套基线调参spring: datasource: druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 validation-query: SELECT 1 test-while-idle: truevalidation-query和test-while-idle是必须开启的。数据库会主动断开空闲时间较长的连接MySQL 的wait_timeout默认 8 小时如果连接池不知道这个变化就会把已经失效的连接发出去应用就报错。连接池在取出连接前先验证一下可用性比服务报错了再重试要稳得多。5.3 毕业论文的结构安排要匹配源码的功能清单毕业论文不要等代码写完了再开始动笔而应该边写代码边收集素材。常见论文结构大体有绪论、相关技术介绍、需求分析、系统设计、系统实现、系统测试、总结展望这几章。对应到源码的功能过一遍需求分析章节写用例图和用例描述系统设计章节画架构图、E-R图、数据库表设计系统实现配页面截图和核心代码片段系统测试放功能测试用例和执行结果。实现章节不要只是贴代码要围绕“为什么这么做”来写。比如在“订单状态机设计”这一节先用表格列出状态流转表当前状态、目标状态、操作动作、业务约束。然后写一段代码示例展示状态机校验。最后写一句“此设计避免了订单状态被非法跳转保证了数据的最终一致性”。这样写比贴 50 行代码不解释要充实得多。答辩时最容易被问的问题是推荐模块的效果评估。这里有一个容易回答且不出错的指标推荐列表的点击率。统计用户在推荐位上的曝光次数和点击次数CTR 在 5% 到 15% 之间就算正常。没有真实的在线流量时可以说明这套评估方法和预期目标要诚实区分方案设计和实测数据。5.4 生产部署的验证方式不要只在小端口跑通就算完本地把npm run dev和java -jar两个命令跑起来只能算是联调完成。一个可以交付的源码至少要跑通“前端构建 后端目录归档 Nginx 静态资源代理”这条链路。前端构建之后dist目录里是可部署的静态文件。Nginx 里要把所有非 /api 的路径指向 index.html否则刷新页面就容易 404。有一个快速巡检的思路打开浏览器无痕窗口依次测注册、登录、航班搜索、下单、支付模拟、查看订单、退票这几条路径每完成一步就记录一次时间。这一步可以顺带把功能测试用例表填了论文里也多了数据和截图素材。本文还有配套的精品资源点击获取