news 2026/9/12 5:32:13

SSM+Vue航空票务推荐系统落地实践:从分层架构到协同过滤

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SSM+Vue航空票务推荐系统落地实践:从分层架构到协同过滤

简介:这是一份基于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) { PageResult<FlightVO> 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 id="searchByCondition" resultType="com.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 test="depCity != null and depCity != ''"> AND f.dep_city = #{depCity} </if> <if test="arrCity != null and arrCity != ''"> AND f.arr_city = #{arrCity} </if> <if test="depDate != null"> AND DATE(f.dep_time) = #{depDate} </if> <if test="maxPrice != 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 Map<Integer, List<Integer>> 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) { List<Integer> 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() { List<FlightVO> 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 的结果可以缓存到 Redis,key 设计成user:{memberId}:recommend,过期时间设为 2 小时。用户行为变化了,最多 2 小时后推荐列表就会更新,不需要实时计算。

4.3 推荐结果的存储与接口,注意缓存还是实时算

有两种做法:一种是“定时算好存 Redis”,另一种是“用户请求时实时算”。冷启动场景下,我推荐实时算,因为离线任务算出来的热榜对每个用户都一样,谈不上个性化。而实时算的最大问题在于性能。解决办法是限流和降级:推荐接口一旦响应超过 300ms,直接返回热榜缓存,不阻塞主流程下单。

接口层面这样设计:

@GetMapping("/recommend") public Result recommend(@RequestParam Long memberId) { long start = System.currentTimeMillis(); List<FlightVO> recList = recommendService.recommendForMember(memberId); if (System.currentTimeMillis() - start > 300 || recList.isEmpty()) { recList = recommendCache.getHotFlightList(); } return Result.success(recList); }

这段代码的含义是:无论推荐引擎给出什么结果,只要耗时长于 300ms 或结果为空,都降级到热榜。用户体验的角度,热榜虽然不是千人千面,但至少页面不会白屏。这种降级策略在答辩时提出来,评审老师会认为你考虑过真实场景。

5. 源码排错、论文写法与性能避坑的四个关键点

5.1 前后端联调时的跨域问题,先分清 CORS 和反向代理

SSM 后端的端口一般是 8080,Vue 开发服务器是 5173(Vite)或 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: true

validation-querytest-while-idle是必须开启的。数据库会主动断开空闲时间较长的连接(MySQL 的wait_timeout默认 8 小时),如果连接池不知道这个变化,就会把已经失效的连接发出去,应用就报错。连接池在取出连接前先验证一下可用性,比服务报错了再重试要稳得多。

5.3 毕业论文的结构安排,要匹配源码的功能清单

毕业论文不要等代码写完了再开始动笔,而应该边写代码边收集素材。常见论文结构大体有绪论、相关技术介绍、需求分析、系统设计、系统实现、系统测试、总结展望这几章。对应到源码的功能过一遍:需求分析章节写用例图和用例描述,系统设计章节画架构图、E-R图、数据库表设计,系统实现配页面截图和核心代码片段,系统测试放功能测试用例和执行结果。

实现章节不要只是贴代码,要围绕“为什么这么做”来写。比如在“订单状态机设计”这一节,先用表格列出状态流转表:当前状态、目标状态、操作动作、业务约束。然后写一段代码示例展示状态机校验。最后写一句“此设计避免了订单状态被非法跳转,保证了数据的最终一致性”。这样写比贴 50 行代码不解释要充实得多。

答辩时最容易被问的问题是推荐模块的效果评估。这里有一个容易回答且不出错的指标:推荐列表的点击率。统计用户在推荐位上的曝光次数和点击次数,CTR 在 5% 到 15% 之间就算正常。没有真实的在线流量时可以说明这套评估方法和预期目标,要诚实区分方案设计和实测数据。

5.4 生产部署的验证方式,不要只在小端口跑通就算完

本地把npm run devjava -jar两个命令跑起来只能算是联调完成。一个可以交付的源码至少要跑通“前端构建 + 后端目录归档 + Nginx 静态资源代理”这条链路。前端构建之后,dist目录里是可部署的静态文件。Nginx 里要把所有非 /api 的路径指向 index.html,否则刷新页面就容易 404。有一个快速巡检的思路:打开浏览器无痕窗口,依次测注册、登录、航班搜索、下单、支付(模拟)、查看订单、退票这几条路径,每完成一步就记录一次时间。这一步可以顺带把功能测试用例表填了,论文里也多了数据和截图素材。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 5:30:49

Lighthouse性能优化实战:从60分到95+的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 5:28:53

Upscayl 批处理实战:一次放大整个文件夹

Upscayl 批处理实战&#xff1a;一次放大整个文件夹 【免费下载链接】upscayl &#x1f199; Upscayl - #1 Free and Open Source AI Image Upscaler for Linux, MacOS and Windows. 项目地址: https://gitcode.com/GitHub_Trending/up/upscayl 周五下午&#xff0c;运营…

作者头像 李华
网站建设 2026/9/12 5:25:08

基于SpringBoot+微信小程序的旅游小程序设计与实现:毕设源码解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 5:24:51

Prompt as Code:工业级提示词工程化实践指南

1. 项目概述&#xff1a;这不是又一个“AI画图工具”&#xff0c;而是一套可版本化、可测试、可部署的提示词基础设施你有没有遇到过这样的场景&#xff1a;在团队里&#xff0c;设计师A写了个“赛博朋克风、霓虹雨夜、低角度仰拍、胶片颗粒感”的提示词&#xff0c;效果惊艳&a…

作者头像 李华