ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

旅游门票酒店预订系统:微信小程序+Spring Boot+可视化全栈实践

旅游门票酒店预订系统:微信小程序+Spring Boot+可视化全栈实践 毕业设计选了这个题目做着做着发现它其实是把旅游行业里最经典的三个场景——门票购买、酒店预订、管理端数据看板——全塞进了一个系统里。当时我就意识到光把CRUD写完根本不够门票的库存扣减、酒店的房态管理、订单状态的流转、图表可视化的数据聚合每个环节都有讲究。这篇文章就把这个项目从架构设计到落地实现的完整过程拆开讲清楚包括我踩过的坑和最后总结的经验希望能给正在做类似系统的你一些参考。这个项目表面上是微信小程序 Spring Boot 可视化三个关键词的堆叠实际上是一套完整的业务系统。所以我不打算只讲代码片段而是按一个真实项目的推进顺序从业务分析、数据库设计、后端接口、小程序端交互、可视化图表实现到最后部署上线的常见问题形成一个可以直接复用的完整思路。1. 项目全貌拆解一套系统里的三个身份1.1 业务模块到底包含哪些在做任何代码之前先把这个项目的业务边界画清楚。标题叫旅游门票酒店预订系统核心服务对象是两类人一是普通游客他们通过微信小程序完成景点门票和酒店的查询、下单、支付二是平台运营人员他们通过管理后台维护景点和酒店信息处理订单查看经营数据。从功能维度拆整个系统存在三个截然不同的身份视角。第一是C端用户的微信小程序这是游客直接接触的部分。核心功能包括用户登录与授权微信一键登录、手机号绑定景点门票浏览与搜索按地区、热度、价格筛选门票详情与下单选择日期、数量、游客信息酒店列表与详情房型展示、入住日期、价格日历酒店预订与订单确认订单中心查看全部订单、待支付、已支付、已完成、退款申请个人信息管理第二是B端管理后台运营人员使用功能包括景点库管理新增、编辑、上下架酒店库管理酒店基础信息、房型管理、价格设置订单管理订单查询、确认、取消、退款处理用户管理数据统计与可视化订单量趋势、销售额统计、热门景点排行、酒店入住率第三是系统底层的技术支持组件包括统一认证鉴权、Redis缓存、异常处理、日志记录、微信支付回调处理等。这三个身份之间的数据流向其实很清晰小程序产生业务操作后端通过接口接收请求并处理业务逻辑管理后台读取数据库中的业务数据并通过图表展示。把这个数据流理顺后面的开发就会顺畅很多。1.2 技术选型为什么是这个组合技术栈的选择一定要说明白这不只是跟风用热门技术每个环节都有它存在的理由。后端选择Spring Boot这个几乎没有悬念。Spring Boot在Java生态里就是做微服务和业务系统的标准方案约定优于配置起步依赖很方便内嵌Tomcat让部署变得简单配合MyBatis-Plus操作数据库能省掉大量繁琐的SQL编写。更重要的是Spring Boot的生态系统非常成熟——Spring Security做权限、Redis做缓存、Spring Validation做参数校验这些都是开箱即用。前端选择微信小程序理由更直接旅游场景是强移动端、强社交分享的场景。用户不用下载App微信里搜一下或者扫个码就能用用完即走这种轻量体验非常适合低频的旅游消费决策。而且微信小程序自带支付能力微信支付在旅游业里几乎就是标配。可视化部分管理后台用ECharts是最靠谱的选择。ECharts对中文支持好、文档完善、图表类型丰富做订单趋势、销售统计、景点热度排行都很顺手。如果你做的是大屏版同样可以用ECharts配合可视化大屏组件来搭建。补充一个关键点系统编号里的4_y65c9x2y这种版本号不用太在意它就是项目归档时自动生成的标识重点还是功能本身。1.3 开发数据库设计先行数据库设计是整个项目的定海神针定好了表结构后端接口写起来才会顺畅。这个系统的核心数据表可以分为四组。用户相关用户表user用户id、微信openid、昵称、头像、手机号、注册时间景点相关景点分类表scenic_category分类id、名称、排序景点表scenic景点id、所属分类、名称、简介、详细地址、图片URL、价格、开放时间、库存上限、状态酒店相关酒店表hotel酒店id、名称、地址、星级、简介、图片、联系电话、状态房型表room_type房型id、所属酒店、房型名称、面积、床型、价格、数量、状态订单相关订单表ticket_order订单id、订单编号、用户id、景点id、游玩日期、门票数量、总金额、状态、支付时间、创建时间订单表hotel_order订单id、订单编号、用户id、酒店id、房型id、入住日期、离店日期、入住人数、总金额、状态、支付时间、创建时间特别提醒订单编号不要用数据库自增ID要单独生成格式建议为时间戳 用户id后四位 随机数这样既保证全局唯一还能在排查问题时一眼看出订单生成的大概时间。库存字段的设计是我这次深有体会的地方。景点门票的每日库存和酒店的每日房量是动态变化的不能简单地用一个固定库存字段去递减。我用的方案是增加一张库存日期表库存表scenic_stock针对景点和日期记录当天的可售数量下单时校验并扣减。酒店同理用房型日期作为维度管理房量。这个设计直接避免了国庆节第一天卖光了但第二天还没到却显示无票这种尴尬问题。2. Spring Boot后端订单、库存和支付三个硬骨头2.1 分层架构与项目骨架Spring Boot项目的包结构我习惯按照业务模块划分而不是严格按技术分层。一个典型的包结构长这样com.example.travel ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // 数据访问层 ├── entity // 实体类 ├── vo // 视图对象 ├── dto // 参数传输对象 ├── config // 配置类 ├── common // 公共返回结果、全局异常处理 └── utils // 工具类每个业务模块在service下独立一个目录比如service/ticket、service/hotel、service/order、service/stats实际上读写数据更方便。全局返回结果我用了一个统一的ResultT类包含code、message、data三个字段这样前端拿数据时只用关注code是否为200不用处理各种异常情况。全局异常处理必须写。我见过很多项目没有统一的异常处理导致数据库报错信息直接暴露给前端这既不安全体验也不好。我采用RestControllerAdvice实现全局异常捕获业务层抛出自定义的BizException异常处理器统一封装返回前端能拿到明确的错误提示后端日志里也能看到具体的堆栈信息。2.2 数据库表结构订单状态机是核心以门票订单表为例核心字段我这样设计ticket_order - id BIGINT PRIMARY KEY AUTO_INCREMENT - order_no VARCHAR(64) UNIQUE NOT NULL COMMENT 订单编号 - user_id BIGINT NOT NULL COMMENT 下单用户 - scenic_id BIGINT NOT NULL COMMENT 景点id - play_date DATE NOT NULL COMMENT 游玩日期 - ticket_count INTEGER NOT NULL DEFAULT 1 COMMENT 门票数量 - total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总金额 - status TINYINT NOT NULL DEFAULT 0 COMMENT 订单状态0待支付1已支付2已完成3已取消4退款中5已退款 - name VARCHAR(50) NOT NULL COMMENT 游玩人姓名 - phone VARCHAR(20) NOT NULL COMMENT 游玩人手机号 - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - pay_time DATETIME NULL COMMENT 支付时间订单状态机是整个系统设计的灵魂。它的流转逻辑是创建订单 状态0待支付用户支付 状态1已支付已支付订单到游玩日后自动或定时任务更新为状态2已完成用户主动取消 状态3已取消或状态4退款中这个状态机在后端代码中通常用一个枚举类来定义这样在代码里写OrderStatus.PAID.getStatus()比写魔法数字1清晰得多。管理后台处理退款时会把状态从已支付切换到退款中退款成功后再切换到已退款。这里要注意退款中的订单不能直接变成已完成必须走完退款流程才能做最终状态变更。2.3 门票下单库存扣减与支付回调的处理门票下单是整个系统里并发要求最高的场景。热门景点在节假日可能出现集中抢购如果用先查库存再更新库存的方式很容易超卖。我的解决方案分两步。第一步下单接口先用SELECT ... FOR UPDATE锁定库存记录。这里要注意必须在事务内执行且确保索引命中否则行锁会退化成表锁性能反而下降。伪代码如下Transactional(rollbackFor Exception.class) public OrderResult createTicketOrder(TicketOrderDTO dto) { // 1. 检查用户登录状态 // 2. 查询景点信息确认上架状态 // 3. 查询当日库存锁定库存记录 ScenicStock stock scenicStockMapper.selectForUpdate(dto.getScenicId(), dto.getPlayDate()); if (stock null || stock.getAvailableCount() dto.getTicketCount()) { throw new BizException(当日库存不足请调整游玩日期); } // 4. 扣减库存 scenicStockMapper.decreaseStock(stock.getId(), dto.getTicketCount()); // 5. 生成订单记录状态为待支付 // 6. 返回订单信息 }但是SELECT ... FOR UPDATE有个明显问题它会把锁一直持有到事务结束。如果用户下单后迟迟不支付库存就被锁住其他用户无法购买。所以我做了两件事来规避第一支付超时处理任务。系统设定订单15分钟未支付自动取消使用Spring Quartz或者简单的定时任务扫描超时订单取消订单并回补库存。这道兜底逻辑保证锁不会被无限期占用。第二支付成功后回补确认。微信支付结果是异步回调通知后端所以在用户支付成功后系统才会真正完成状态变更。回调顺序是支付回调改订单状态为已支付 已支付订单的库存不用再回补保持扣减状态即可。酒店预订的流程基本一致区别在于要按入住日期区间扣减库存。比如用户预订7月1日到7月3日的房间那这三天的房量都要扣减。2.4 酒店搜索与价格日历的SQL优化酒店列表页的查询条件是城市 入住日期 离店日期 人数。这里最核心的SQL是根据日期筛选出每一天都有空闲房的酒店。一个比较高效的思路是先查出所有满足城市条件的酒店再通过子查询排除掉在入住日期与离店日期之间房型已满的酒店。这个SQL要在hotel表、room_type表、hotel_stock表按日期记录房量三表之间做JOIN。数据量大了以后要给hotel_stock表的hotel_id date字段建联合索引不然查询会非常慢。价格日历是我觉得这个项目里比较体现产品细节的地方。前端需要展示某个酒店未来30天每天的房价而后端不能每次实时查房型表再动态计算。我的做法是在room_type表里设置基础价格为特定日期节假日、周末单独维护一张价格日历表price_calendar记录特殊日期的加价比例或具体价格。前端请求时后端同时读取基础价格和特殊价格合并返回30天价格数组。这样既有灵活性也避免了每晚定时任务刷价格表的麻烦。3. 微信小程序端从页面到交互的完整实现3.1 开发框架的选择微信小程序开发目前主流有两条路原生开发和uni-app跨端开发。这个项目我选的是原生开发原因是项目只做微信小程序一个端没必要引入跨端框架的编译层原生开发的性能和调试体验都更好。如果你后续有鸿蒙App、安卓/iOS的需求再考虑用uni-app重写也不迟。原生小程序的目录结构按页面划分核心代码文件类型是.wxml页面结构、.wxss样式、.js逻辑、.json页面配置。项目结构大致如下miniprogram/ ├── app.js // 全局逻辑、登录处理 ├── app.json // 全局配置、页面路由 ├── app.wxss // 全局样式 ├── utils/ │ ├── request.js // 请求封装 │ └── util.js // 工具函数 └── pages/ ├── index/ // 首页 ├── ticket-list/ ├── ticket-detail/ ├── hotel-list/ ├── hotel-detail/ ├── order-confirm/ ├── order-list/ └── mine/3.2 请求封装与登录态管理小程序端的请求封装是很多人容易忽略但实际坑最多的地方。我的utils/request.js会统一处理以下事情在请求头中自动携带token从本地缓存读取统一处理不同HTTP状态码401跳转登录页统一处理后端返回的code非200时调用全局toast提示封装GET、POST方法方便业务代码调用const request (options) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json, Authorization: wx.getStorageSync(token) }, success: (res) { if (res.statusCode 200 res.data.code 200) { resolve(res.data.data); } else { wx.showToast({ title: res.data.message || 请求失败, icon: none }); reject(res.data); } }, fail: (err) reject(err) }); }); };登录是整个小程序最关键的身份认证环节。我的做法是用户首次进入页面调用wx.login()获取临时code将code发送给后端后端调用微信的code2Session接口换取openid然后在后端生成自定义token返回给前端。前端把token保存在本地缓存里后续所有请求都带上这个token。这样就不需要用户手动输入用户名密码体验很顺畅。有一个特别容易踩的坑是使用手机号组件获取用户手机号时需要先在微信公众平台申请开通并且要跟已认证的小程序账号绑定。如果没用认证的AppID比如测试号手机号功能就用不了。实测阶段建议先用账号密码模拟登录正式上线前再切换成微信手机号授权。3.3 门票列表页与酒店详情页的关键交互门票列表页的核心是筛选与搜索。顶部放一个搜索框下面可以按照热门排序价格排序地区筛选三个维度切换。这里我用了微信小程序自带的scroll-view实现列表滚动加载更多触底时自动请求下一页数据避免一次性加载全部门票导致页面卡顿。酒店详情页相比门票详情要复杂一些。我设计了三个模块酒店信息区轮播图、名称、地址、星级、评分房型列表区每个房型卡片展示面积、床型、价格点击预订按钮跳转订单确认页入住信息栏入住日期、离店日期、房间数日期选择器基于微信小程序原生picker组件封装发现一个原生的坑picker的日期选择在快速切换时可能会因为bindchange事件触发多次而出现选择日期错乱。解决办法是在切换日期时加一个防抖逻辑或者直接改用第三方日期组件。实测下来用Vant Weapp的日历控件体验更好而且它免费开源小程序里直接npm依赖就能用。3.4 订单确认与微信支付流程订单确认页要同时展示门票或酒店的信息、数量、单价、总价以及游玩人信息填写。这里有个产品细节很实际用户选完游玩日期后还需要填写游玩人姓名和手机号如果每次下单都重新输入会很烦。我在用户首次填写后把它保存在本地缓存和用户表里下次下单自动带出只保留可编辑功能。这种方式减少了用户操作步骤下单转化率明显提升了。微信支付的接入流程分成三步用户点击立即支付按钮前端请求后端创建微信支付统一下单接口后端调用微信支付接口得到payment参数包括时间戳、随机串、package、signType等等前端调用wx.requestPayment拉起微信支付面板用户输入密码完成支付后端接支付回调一定要处理重复通知的问题。微信支付会在一定时间内多次回调同一个支付结果如果每次回调都直接修改订单状态并回补库存就会造成重复扣库存的严重bug。我的处理方式是// 在支付回调处理中增加幂等检查 if (order.getStatus() OrderStatus.PAID.getStatus()) { log.info(订单重复回调orderNo{}, orderNo); return Result.success(); }每次回调先检查订单的状态如果已经是已支付直接返回成功不再重复处理。4. 可视化模块不是图表堆砌是数据决策4.1 为什么管理后台必须做可视化标题里可视化这个关键词很多人第一反应就是图表展示但实际做下来我发现它的本质是给管理人员提供一个用数据做决策的工具。在这个旅游系统里可视化模块的价值主要体现在三个方面一眼看出整体经营情况今日订单数、今日销售额、本月订单趋势识别热门产品哪些景点卖得最好、哪些酒店订得最多发现问题某个景点的销量连续下降、某个酒店的订单集中在某一天所以我在设计后端统计接口时不是简单地返回一堆明细数据让前端自己算而是后端直接聚合好指标前端直接展示。这样既减少前端的计算工作量也避免跨页面的分析逻辑不一致。4.2 后端统计接口的数据聚合逻辑我设计了一个统计模块包含以下几个接口GET /admin/stats/overview返回今日订单数、今日销售额、总订单数、总销售额、用户总数GET /admin/stats/order-trend?days30返回最近30天每天的订单数和销售额GET /admin/stats/hot-scenic?limit10返回销量前十的景点GET /admin/stats/hotel-occupancy?days30返回各酒店的入住率这些接口底层都是SQL聚合。以订单趋势为例最直接的SQL是SELECT DATE(create_time) AS date, COUNT(*) AS order_count, SUM(total_amount) AS amount FROM ticket_order WHERE create_time DATE_SUB(CURDATE(), INTERVAL 29 DAY) GROUP BY DATE(create_time) ORDER BY date;这里有个很容易踩的坑如果某一天没有任何订单这条SQL返回的结果里就看不到那天前端做折线图时就会出现断档。我的解决办法是在Java代码里补全缺失的日期生成一个完整的30天序列没有数据的日期用0填充。这个细节虽然小但很影响图表的美观度和可读性。热门景点排名接口我用的是订单表的聚合加景点表的关联查询SELECT s.name AS scenic_name, COUNT(so.id) AS order_count, SUM(so.ticket_count) AS ticket_count FROM ticket_order so JOIN scenic s ON so.scenic_id s.id WHERE so.status IN (1, 2) GROUP BY s.id, s.name ORDER BY ticket_count DESC LIMIT 10;这里有一个小经验统计时只统计已支付状态status1和已完成状态status2的订单把待支付和已取消的订单排除掉否则数据会虚高管理人员看了容易误判。4.3 ECharts在管理后台的落地管理后台我用的是一个轻量级的Vue Element UI ECharts组合。ECharts的接入方式很成熟在Vue组件里引入echarts包然后在mounted钩子中初始化图表实例通过setOption传入统计数据。订单趋势图的核心配置大概是这样的// 伪代码订单趋势折线图 option { xAxis: { type: category, data: dateList }, yAxis: { type: value, name: 订单数 }, series: [ { name: 订单数, type: line, data: orderCountList, smooth: true }, { name: 销售额, type: line, data: amountList, smooth: true, yAxisIndex: 1 } ] };要注意的是双Y轴处理。订单数和销售额的量级完全不一样订单数可能是几十销售额可能是几万如果放同一个Y轴订单数的曲线几乎会被压平。设置yAxisIndex让两条曲线分别对应左右两侧的Y轴图形看起来就正常多了。可视化大屏版我在后面又扩展了一个页面把核心指标做成了大屏看板展示在办公室的电视上整体用了背景色渐变、数字滚动动画、以及轮播的Top榜单。不需要特别复杂的可视化库ECharts 一点CSS动画就够了。4.4 可视化性能优化的两个小技巧数据可视化模块最容易出性能问题的是两个点第一每天定时刷新的统计接口如果查询范围很大会很慢。比如查询一年订单趋势SQL要扫描一整年的订单表。我的做法是加一层Redis缓存统计接口的结果缓存在Redis中缓存时间为10分钟并且管理后台操作订单后主动清掉缓存。这样管理人员打开看板时响应速度基本是毫秒级。第二前端图表要按需渲染。ECharts的整个包体积不小如果一次性全部引入页面加载会有明显延迟。我直接在管理后台项目里通过echarts/core按需引入LineChart、BarChart、PieChart这些核心组件剩下的按需注册最终打包体积能减少一半以上。这个小改动在低配电脑上访问后台时感知非常明显。5. 部署上线与踩坑总结5.1 环境配置与部署清单开发和测试环境跑通以后部署上线这步看似简单实际上坑很多。我的部署方案是后端打包成jar部署在服务器的Tomcat上也可以用Docker容器方式前端微信开发者工具上传代码到微信公众平台提交审核数据库阿里云RDS MySQL缓存Redis服务文件存储图片和景点介绍等静态资源放在MinIO一个开源的分布式对象存储比直接存数据库的字段里或者服务器本地文件都方便很多这里有个特别容易犯的错小程序正式版要求请求的域名必须备案并且要在微信公众平台配置为request合法域名而且必须用HTTPS协议。开发调试时经常使用本地IP加端口但提交审核前一定要把后端接口域名换成已备案的HTTPS域名。如果漏了这一步小程序线上请求会直接失败而且提示信息很模糊容易排查很久。数据库连接池的配置也不能忽视。生产环境我一般设置initial-size: 10、max-active: 100、min-idle: 5并且开启连接池的检测SQLtest-on-borrow: true。如果没有这些配置高峰期可能出现连接不够或者数据库主动断开时连接池持有了失效连接表现就是偶发的请求超时。5.2 高频踩坑与解决方案在做这个项目时我整理了一份高频坑位清单这里挑几个特别有价值的分享。坑一微信登录openid获取失败。出现这种情况大概率是AppID和密钥配置错了或者后端请求微信接口时参数签名不对。排查方法是先看后端日志看返回的errcode是多少如果是40029说明code无效如果40163说明code已经用过重复使用了。特别注意wx.login()返回的code只能用一次后端必须在有效期内5分钟使用否则会失效。坑二下单支付成功但订单状态没变成已支付。这个问题90%出在微信支付回调的URL配置。我在微信支付平台设置的支付回调URL必须带上/api/pay/wxpay/notify这个完整路径并且这个URL要能被外网访问到。如果回调URL配置成了内网地址或本地IP微信支付回调永远到达不了你的服务器。建议在回调接口里加上日志打印出现问题时先看有没有回调记录。坑三库存数据错乱。我遇到过一次原因是测试时用同一个数据手动改数据库字段导致库存和订单数量对不上。这提醒我库存与订单的扣减一定要在同一事务里不能分两步。如果分两步操作事务A扣库存成功但事务B生成订单失败库存就白白少了。解决方案就是我在第2.3节写的伪代码那样把检查和扣减放在同一个Transactional方法里。坑四小程序端提交订单时重复提交。用户手快点了两次提交订单按钮结果生成了两笔一样的订单。解决方式很简单前端在按钮提交后立刻置为不可点击同时后端在创建订单方法里加一个幂等校验比如用同一个requestId去重这个requestId由前端生成传给后端后端查询是否存在相同requestId的订单存在则直接返回那个订单。坑五耗时统计接口把管理后台卡住。我使用EXPLAIN检查慢SQL时发现热门景点排名这个查询走了全表扫描原因是ticket_order表的scenic_id没有索引。给scenic_id建了普通索引后查询速度提升了大概10倍。做数据可视化统计时给所有聚合查询的WHERE条件涉及的字段都建索引这几乎是必须的。5.3 项目收尾时的体验优化功能做完之后一定要留出专门的时间做体验优化这个阶段花的精力值回票价。推荐几个小而美的优化点小程序端所有列表页都加加载中和没有更多了的提示不然用户会觉得页面卡了。门票详情页和酒店详情页的图片要压缩用WebP格式一张高清图从原来的300KB压到80KB加载速度明显改善。订单列表页显示状态时用彩色标签区分待支付黄色、已支付绿色、已取消灰色一眼就能看清不用点进详情才知道什么状态。后端全局响应增加一个timestamp字段前端可以根据这个字段做简单的超时校验排查网络原因崩溃时很有用。写在最后这个项目做完我最大的体会是旅游预订系统看起来不复杂但把门票、酒店两个业务线融进一套体系再把可视化数据打通细节非常多。最核心的收获有两个一是库存和订单状态管理一定要有清晰的设计思路不然并发场景下很容易出事故二是可视化不等于堆图表要让数据真正服务于运营决策指标的口径和数据的准确性比图表的美观度更重要。如果你也要做类似的项目建议按我上面的顺序推进先拿一天时间把业务和表结构设计透彻再写后端接口然后开发小程序端联调最后做可视化大屏。数据库表设计一定要多花时间后续改表结构是一种巨大的隐性成本。祝你的项目顺利上线。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进