首页 / 资讯中心 / 文章详情

Spring Boot+Vue前后端分离旅游订票系统实战:从库存防超卖到订单状态机

Spring Boot+Vue前后端分离旅游订票系统实战:从库存防超卖到订单状态机 ★ FEATURED ARTICLE
上个季度我完整做了一个“旅游线路展示 在线订票”的前后端分离项目Spring Boot 做后端接口Vue 做前端页面整个系统包含线路浏览、景点详情、日期团期选择、订单提交、支付状态回跳、后台线路维护这些核心环节。项目不大但业务链路很典型从商品展示到库存扣减再到订单流转几乎把电商类系统的基础骨架都过了一遍。如果你正准备做类似的订票平台或者正在学 Spring Boot Vue 怎么配合落地这篇内容能帮你少踩几个我实际踩过的坑。1. 项目整体设计与技术选型考量1.1 为什么是 Spring Boot Vue 这一套组合选型阶段后端其实想过用 Node.js 或者 Python 系框架但最终定了 Spring Boot理由很实在团队对 Java 最熟Spring Boot 对事务、数据库访问、定时任务、权限框架的整合都非常成熟网上能查到的资料也足够多。订票平台最核心的是订单和库存必须依赖数据库事务保证一致性Spring 的Transactional用起来简单直接出了问题也好排查。前端选 Vue 而不是 React主要是项目复杂度没有高到需要 React 那套复杂生态。Vue 的中文资料多、上手曲线缓组件拆分思路对“页面多、交互重复”的管理后台特别友好。后面代码写起来也确实顺手路由、状态管理、接口封装都有现成方案不需要自己折腾太多工程化配置。这套组合还有一个隐性好处前后端招聘市场都认接手的人多项目后面不管交给自己人维护还是外包出去都不会变成“孤儿项目”。对中小型系统来说技术选型不是越新越好而是团队和业务成本匹配才是最好。1.2 业务模块拆解与用户旅程把业务拆开看这个平台其实只有两条主链路用户订票链路和运营维护链路。用户链路是这样的进入首页看到线路推荐点击某条线路进入详情页查看景点介绍、行程安排、出发日期和剩余库存选定日期填写游客信息提交订单支付收到包含取票凭证的订单详情到景区后凭凭证核销。运营链路则是管理员登录后台维护线路基本信息、上传封面图、设置出发日期和库存数量、管理景点内容、查看订单列表、处理退款或改签。这两个链路互相独立、又共享同一套数据库。前端通过不同路由区分用户端和管理端后端通过角色权限控制接口访问范围。这种结构不复杂但骨架很清楚后续扩展分销、优惠券、多商家入驻都能在这个基础上继续加。1.3 项目目录结构与工程分层后端我按常见的分包方式来组织controller只管接收请求和返回结果service里放业务逻辑mapper里放数据库操作entity对应表结构dto是接口出入参。刚开始没按这个分全写在 Controller 里结果订单逻辑一复杂就完全没法维护后来重新拆了一层才舒坦。前端用 Vite 创建 Vue 3 工程router配路由store放全局用户信息api统一封装 axios 请求views放页面级组件components放复用性较强的局部组件。目录结构前期理顺了后面开发效率明显高很多接口路径、命名风格都要统一不然联调的时候光对字段就能对一下午。2. 后端核心功能落地2.1 数据库表结构设计要点订票系统的表结构说简单也简单说复杂也复杂关键看库存怎么设计。我这边核心表基本就是这几张表名业务含义关键字段route旅游线路主表id、线路名称、封面图、出发城市、行程天数、状态route_schedule线路团期与库存id、线路id、出发日期、结束日期、总库存、剩余库存、售价scenic_spot景点表id、景点名称、所在城市、图文介绍、封面图route_spot_relation线路与景点关联线路id、景点id、第几天、排序号member_user用户表id、手机号、昵称、密码哈希、角色类型order_main订单主表id、订单号、用户id、团期id、线路id、人数、金额、状态这里最重要的设计思路是库存不放在线路表里而是单独放在route_schedule团期表里。原因是每条线路在不同出发日期下的库存和价格完全可能不一样国庆节的价格和淡季价格不一样库存量也不一样。如果把库存存在线路表最后一定演变成一个字段搞不定、到处拼接查询的灾难现场。订单表里冗余了线路名称和封面图等字段不用每次下单都 JOIN 线路表才能展示订单列表。冗余字段虽然违背狭义上的数据库范式但在实际业务中能减少跨表查询提高列表页响应速度付出的代价只是更新线路信息时要处理数据同步。2.2 线路与景点的查询性能优化详情页第一版实现时直接在循环里查景点一条线路带十个景点就要查十一次数据库接口响应经常在八百毫秒以上。后来优化成按线路 ID 一次性查出景点关联关系再批量查询景点详情最后在内存里按天数分组响应时间降到一百毫秒左右。列表页也是同样的思路。线路列表需要展示封面、标题、最低价、出发城市分页查线路表就够了。价格因为不同团期价格不同我采用了查询最低价格子查询的方式确保列表页不出现多条重复线路。后来并发上来之后把线路详情接口加了 Redis 缓存线路和景点基础信息基本不怎么变化缓存过期时间设置为三十分钟数据库压力小了很多。这里要强调一个细节加了缓存之后后台上架下架、修改线路价格时必须主动删除对应缓存否则用户端看到的还是旧数据。我一开始忘了处理这个联动导致测试时改完价格前端没变化排查了十分钟才发现是缓存问题。2.3 下单接口与库存防超卖订票平台最怕的就是超卖用户明明看到有票下单成功却出不了票这种体验特别劝退。防止超卖我在设计和实现上用了最简单也最可靠的方式数据库条件更新。下单的第一步不是先插入订单而是先扣减库存。扣减语句类似这样update route_schedule set stock stock - #{count} where id #{scheduleId} and status 1 and stock - #{count} 0这句 SQL 的关键在stock - #{count} 0这个条件。数据库的行级锁会保证同一条 schedule 记录只有一个事务能更新成功更新失败说明库存不足直接提示用户换日期即可。对应的 Service 方法代码如下Transactional(rollbackFor Exception.class) public CreateOrderResult createOrder(CreateOrderRequest req) { // 1. 校验线路和团期状态 RouteSchedule schedule scheduleMapper.selectById(req.getScheduleId()); if (schedule null || schedule.getStatus() ! 1) { throw new BizException(该团期已下线); } // 2. 条件扣减库存影响行数为0说明库存不足 int updated scheduleMapper.decreaseStock(req.getScheduleId(), req.getCount()); if (updated 0) { throw new BizException(所选团期库存不足); } // 3. 生成订单并落库 OrderMain order OrderMain.builder() .orderNo(generateOrderNo()) .userId(req.getUserId()) .scheduleId(req.getScheduleId()) .routeId(req.getRouteId()) .count(req.getCount()) .totalAmount(schedule.getPrice().multiply(BigDecimal.valueOf(req.getCount()))) .status(OrderStatus.UNPAID) .build(); orderMapper.insert(order); return new CreateOrderResult(order.getOrderNo()); }整个方法加事务扣扣库存成功但后续订单插入失败时事务回滚库存会自动恢复。很多人一上来就提 Redis 分布式锁、乐观锁、消息队列削峰我觉得对中小型旅游平台来说有点过度设计。数据库的原子条件更新在这种并发量级下完全够用代码简单理解成本低排查问题也容易。真到了双十一那种流量再换 Redis 方案也不迟业务系统最忌为了技术炫技把简单链路搞复杂。2.4 订单状态机与超时取消订单状态我定义了五个待支付、已支付、已出票、已取消、已完成。用户下单后如果一直不支付库存会一直占着对其他人不公平所以加了一个定时任务每两分钟扫描一次创建时间超过三十分钟且处于待支付状态的订单把它们改为已取消同时释放对应团期的库存。释放库存时要注意只能加回来一次用条件更新避免重复释放比如where status 已取消 and stock_released 0保证定时任务即使重复执行也不会把库存加爆。支付回调这里我用的模拟支付接口支付网关会异步通知后端。回调处理要求幂等根据订单号先查订单状态如果已经是已支付直接返回成功不再重复处理。这一步能避免网络重试导致同一笔订单被处理两次。2.5 登录鉴权与接口权限控制用户端和管理端共用同一张用户表但需要通过角色字段区分。登录接口校验手机号和密码密码用 BCrypt 加密存储前端拿到 JWT Token 后存到 localStorage后续每个请求在 axios 拦截器里带上请求头。后端用一个拦截器统一解析 Token把用户 ID 放到请求上下文里Controller 里直接通过上下文拿当前用户。管理接口额外加了一个管理员角色校验的注解普通用户即使知道接口地址也无法调用。JWT 本身是无状态的这种方式对单机部署完全够用。服务要横向扩展成多实例也简单只要所有实例共享同一个签名密钥就行。唯一要注意的是 Token 过期时间不要设太长我设置的是二十四小时刷新一次既保证体验也避免安全问题。3. Vue 前端实现3.1 页面路由与整体布局前端页面分为三块C 端用户页面、支付回调页面、B 端管理后台。C 端路由懒加载按需加载组件首次打开首页时不用加载整个后台代码体感更快。路由规划大致是这样路径页面说明/首页线路推荐、热门景区、搜索入口/route/detail/:id线路详情景点图文、行程安排、团期选择/order/confirm确认订单游客信息、价格明细/order/list订单列表查看已购和待支付订单/admin/route线路管理新增、编辑、上下架/admin/order订单管理查看订单、退款处理每个页面都由一个主组件加若干子组件拼装完成。前端最忌讳把一个页面所有东西塞进一个几百行的组件里后面改需求时牵一发而动全身非常痛苦。3.2 旅游线路展示组件设计首页的重点是展示效果和信息效率。线路卡片组件需要展示封面图、线路名称、出发城市、天数、价格封面图横排轮播卡片用响应式栅格布局。线路详情页比较重要包含三块内容顶部图片画廊、中间行程安排、底部团期预订框。画廊用图片切换组件展示景点照片行程安排按“第几天”分组用时间轴样式逐条展示游览景点和活动安排。组件设计上要拆分清楚图片画廊组件、行程时间轴组件、团期选择组件各自独立团期选择组件关注的是“选日期、看库存、看价格”这个交互闭环它接收线路 ID 作为 props内部自己请求团期数据把选择结果通过事件抛给父组件。这样详情页和下单页都能复用这个组件。3.3 选日期、库存联动与价格计算订票交互里最核心的是选日期。用户点击某一天页面要立刻显示当天剩余库存和价格如果库存不足则日期置灰不可选。这个功能用 Vue 的watch监听选中的日期调用后端接口获取对应团期信息前端代码如下const selectedDate ref() const scheduleInfo ref({ stock: 0, price: 0, scheduleId: null }) watch(selectedDate, async (date) { if (!date) { scheduleInfo.value { stock: 0, price: 0, scheduleId: null } return } const res await getScheduleByDate(routeId.value, date) scheduleInfo.value res.data })用户点击“立即预订”后把scheduleId、人数、单价传到下单确认页。下单确认页显示订单明细用户填写游玩人手机号和身份证信息提交后创建订单跳转到支付页面。价格计算全在后端完成前端只做展示避免前端浮点运算导致金额出现 0.1 0.2 不等于 0.3 的尴尬问题。前端把总价日历控件格式化展示即可。3.4 管理后台页面实现管理后台用了现成的后台框架组件表格、表单、弹窗、上传组件直接复用。线路管理页的核心操作是新增线路、编辑详情、设置团期库存、上下架线路。表格每一行展示线路封面缩略图、名称、出发城市、天数、价格区间、上架状态操作列放“编辑”“上下架”“设置团期”。上下架操作做成一个 switch 开关调接口时除了更新状态还要清理后端缓存这个逻辑后端已经处理好了前端只需要在成功后刷新列表。团期设置是一个单独弹窗内部枚举未来九十天的日期每一天单独设置库存和价格。这里如果做成逐行手工录入运营同学会很想打人所以我在前端做了“批量生成团期”功能设置起始日期、结束日期、默认库存、默认价格一键生成后还能再逐日微调。4. 联调、安全与问题排查实录4.1 跨域问题前后端分开部署必然遇到跨域。开发环境最简单的方式是前端配置 Vite proxy把/api开头的请求代理到后端地址浏览器完全感知不到跨域。但生产环境如果前后端仍分开部署就需要后端开放 CORS。我后端 CORS 配置写的比较保守只开放指定前缀的地址不许*因为订单和用户信息涉及隐私不可能允许任意网站跨域调用。Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOrigin(https://admin.example.com); config.addAllowedOrigin(https://www.example.com); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/api/**, config); return new CorsFilter(source); } }4.2 LocalDateTime 序列化问题项目里团期、订单创建时间等字段都用了LocalDateTime如果不对 Jackson 做配置前端拿到的可能是形如2023-05-01T10:00:00的格式不是我们习惯的2023-05-01 10:00:00。我统一在配置里定义了全局时间格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8同时实体类的日期字段直接加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)双保险。前后端约定好所有时间字段都用字符串传递MySQL 存 datetimeJava 用 LocalDateTime前端展示用 dayjs 格式化基本不会再出乱子。4.3 金额精度问题金额如果用过double早晚会有坑。比如行程里计算 0 元景点和折扣价格浮点运算出现 0.999999 这种结果展示数据和实际入库数据不一致用户看了很莫名其妙。我全项目的金额字段都用了BigDecimal数据库字段用decimal(10,2)接口 DTO 也用BigDecimal前端只是展示字符串不做运算。API 返回金额时也统一返回数据库里的字符串形式避免 JSON 序列化时把 BigDecimal 转成科学计数法。4.4 重复提交与订单号幂等用户在订单确认页快速点击两次提交按钮可能出现两条订单。前端可以禁用按钮但真正可靠的是后端幂等控制。我的处理方式是在创建订单的请求里加了一个requestId字段前端每次进入下单页生成一个 UUID提交订单时带上。数据库为订单表的request_id字段加了唯一索引重复请求直接插入失败并捕获异常后返回“订单已提交请勿重复操作”。这个方案既简单又有效不需要引入 Redis 做分布式锁对中小系统足够。5. 部署上线与项目落地经验5.1 打包构建与 Nginx 配置后端打包用mvn clean package生成一个可执行的 jar 包放到服务器上通过java -jar启动即可。前端执行npm run build生成静态资源目录部署到 Nginx 的html目录下。Nginx 配置里最核心的是将/api开头的请求反向代理到后端服务端口其余路径直接返回前端静态页面。前端路由如果是history模式还需要配置try_files否则刷新页面会出现 404。server { listen 80; server_name www.example.com; root /opt/travel-web/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }5.2 配置分离与多环境切换本地开发、测试服务器、生产服务器的数据库地址、Redis 地址都不一样不能每次部署都手动改一遍配置。Spring Boot 使用多 Profile 管理主配置只放公共内容application-dev.yml放本地数据库和调试参数application-prod.yml放生产库地址启动时通过--spring.profiles.activeprod指定环境。前端环境变量也是一样处理开发环境用.env.development配置接口代理地址生产环境构建时用.env.production配置真实域名避免把测试地址带到线上。5.3 容器化部署与后续扩展当前项目部署用的是 Docker Compose把 jar 包、前端静态资源、Nginx、MySQL、Redis 都编排在一起。后端镜像基于一个比较精简的 JDK 基础镜像前端镜像直接复用 Nginx 官方镜像把 build 产物 COPY 进去。这样做的好处是环境一致性好在任何一台装好 Docker 的服务器上都能一键启动整套服务。后面如果要扩展容量后端可以拆成多个容器实例前面加一层负载均衡数据库升级为主从模式整个架构能平滑升级。6. 项目做完后的一些体会这个项目前后花了将近两个月真正让我印象深刻的不是代码量而是业务细节中的一致性处理。库存扣减、订单状态、支付回调、缓存更新这些点光靠堆代码解决不了问题需要先把数据流向想清楚。我一开始觉得订票系统简单结果做到订单超时释放库存时发现如果没有条件更新的保护定时任务跑一次就多送一次库存那还不亏死。如果你也想上手做一个类似平台我建议先不要纠结用什么框架、要不要微服务先把“用户下单”这一条核心链路跑通把数据库表和事务逻辑打磨稳当后面再慢慢加景点内容、后台管理、支付网关这些丰富功能。所有技术都是为业务服务的链路稳定住了这个项目就成功了八分。
阅读完成 · 觉得有帮助?
咨询建站