微信小程序+Spring Boot景区酒店预约系统实战解析
发布时间:2026/9/26 5:27:42 作者:尧图编辑部 阅读量:1,286

压根儿不用纠结这个题目的可行性。微信小程序做前端Spring Boot做后端景区酒店预约这种业务又是典型的CRUD加状态流转技术栈成熟、业务逻辑清晰、工作量可控不管是用来做课程设计、毕业设计还是给自己攒一个落地项目都是性价比很高的选择。今天我就拿我自己做过的这个微信小程序 Spring Boot景区酒店预定系统为例把从架构选型到核心代码落地的全过程掰开揉碎讲一遍所有踩过的坑、调过的bug、想明白的道理一次说清楚。1. 项目定位与整体架构设计1.1 选题逻辑为什么是小程序 Spring Boot组合先说说选这个组合的理由。小程序端的优势不用多讲微信生态自带流量入口用户不用装App扫码就能用对于景区这种低频但刚需的场景非常合适。后端选Spring Boot一是Java生态成熟二是在校生和企业里认这个技术栈的人最多三是我个人觉得Spring Boot的自动配置和起步依赖能让开发效率直线上升不用像早期Spring那样写一堆XML配置。实际开发的时候我走的还是前后端完全分离的路子小程序只负责页面渲染和用户交互所有业务逻辑、数据存储、权限校验全部丢给后端接口。小程序端通过wx.request发起HTTP请求后端返回统一格式的JSON。这样做的好处很明显前端和后端可以并行开发后端接口写好了直接用Postman测不依赖小程序预览工具。技术选型上我的后端核心依赖清单如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency数据库用的MySQL 8.0缓存用了Redis做热点数据缓存和分布式session后面细说。MyBatis Plus强烈推荐单表CRUD基本不用写SQL分页插件也内置好了能在很大程度上省时间。1.2 系统模块划分与数据流向整个系统我拆成了用户端和管理端两个大模块。用户端就是小程序里能看到的一切景区列表、酒店列表、酒店详情、下单预订、订单管理、个人中心。管理端我做得比较朴素直接用Spring Boot结合Thymeleaf写了一个简单的Web管理页面或者你也可以用Vue再搭一个后台管理本质都是调用同一套Service层的接口我图省事就用了后者这里推荐用若依框架或者自己用Vue3Element Plus快速搭一个后面维护会舒服很多。数据流向大致是这样小程序用户点击某个景区的酒店 - 请求后端查询酒店列表接口 - 后端从MySQL查出该景区下所有在营业状态的酒店 - 组装成JSON返回 - 小程序端渲染列表 - 用户选房型、选日期 - 提交订单 - 后端校验库存 - 生成订单并锁定房间 - 用户在小程序端支付 - 支付回调更新订单状态 - 用户在小程序我的订单里看到已支付状态。这里有个关键点也是我当时反复推敲的库存房间量的扣减绝对不能先查再扣具体往下看第4章。2. 小程序前端核心页面拆解2.1 首页景区展示与酒店列表的联动设计小程序端我用的原生框架Page.json里配置好导航栏标题和颜色没有引入任何第三方UI库实际上引入也行比如Vant Weapp看个人习惯。首页结构分三块顶部搜索框、景区轮播图、下方景区列表。景区列表的数据结构大概是[ { scenicId: 1, scenicName: 西湖风景区, scenicLevel: 5A, coverUrl: https://xxx.com/cover1.jpg, hotelCount: 3 } ]首页推荐逻辑我当时偷了个懒直接按景区的创建时间倒序排没有做热度权重。如果读者想做得精细一点可以在scenic表里加一个view_count字段每被查看一次就1列表按view_count排序效果会好很多。点击景区卡片跳转到酒店列表页时我在跳转URL上带了scenicId参数wx.navigateTo({ url: /pages/hotel/list?scenicId scenicId })酒店列表页在onLoad生命周期里拿到这个scenicId然后立刻请求接口。这里有个体验优化点建议加一个loading态的骨架屏或者loading图标否则在网络慢的时候用户会以为页面卡死了。2.2 酒店详情与房型选择的前端逻辑酒店详情页的信息是从两个接口拼起来的/hotel/detail拿酒店基本信息名称、地址、评分、介绍、联系电话/hotel/rooms拿该酒店下的所有房型大床房、双床房、亲子房之类。房型的数据结构[ { roomId: 1, hotelId: 1, roomName: 标准大床房, roomPrice: 328, totalCount: 10, remainCount: 6, coverUrls: [url1, url2] } ]这里前端有一个选日期 - 显示当天剩余房量的动态逻辑。小程序端我用了一个picker组件选择入住日期和离店日期选完之后动态调后端接口查可用房间数量再把remainCount实时刷新到界面上。这个实时查询可用房量的接口是整个系统里并发压力最大的接口因为用户在支付前会反复切换日期。所以这个接口我做了Redis缓存key是hotelId:roomId:datevalue是剩余房量每次用户查询优先读缓存下单成功后扣缓存。缓存过期时间设置成30分钟避免日期变更后脏数据残留。2.3 小程序登录与Token管理小程序端登录我是按官方推荐的方式做的wx.login拿到code发给后端的/login接口后端拿code去微信的jscode2session接口换openid。PostMapping(/login) public Result login(RequestBody LoginDTO dto) { // 1. 用code换openid String url https://api.weixin.qq.com/sns/jscode2session?appid appId secret appSecret js_code dto.getCode() grant_typeauthorization_code; // 2. openid查数据库没有就注册新用户 // 3. 生成token返回 }token我选用JWT而不是传统的session原因是小程序端是跨端请求JWT的无状态特性让后端可以随意横向扩展。生产环境对JWT密钥的管理要严格不要硬编码在代码里放配置文件并通过环境变量注入防止泄露。小程序端拿到token后统一放在请求头里wx.request({ url: apiUrl /order/create, header: { Authorization: Bearer token }, data: orderData, success: (res) { ... } })后端用拦截器统一校验token白名单放行登录、景区列表、酒店列表这些公开接口。3. Spring Boot后端关键落地3.1 项目结构与分层规范先亮出我的后端目录结构src/main/java/com/example/scenichotel ├── common │ ├── Result.java // 统一返回结构 │ ├── ResultCode.java // 状态码枚举 │ └── GlobalExceptionHandler.java ├── config │ ├── WebMvcConfig.java // 拦截器、跨域配置 │ └── RedisConfig.java ├── controller │ ├── ScenicController.java │ ├── HotelController.java │ ├── RoomController.java │ ├── OrderController.java │ └── UserController.java ├── service │ ├── OrderService.java │ └── impl │ └── OrderServiceImpl.java ├── mapper │ ├── HotelMapper.java │ └── OrderMapper.java ├── entity │ ├── Hotel.java │ ├── Room.java │ ├── Order.java │ └── ... └── utils └── JwtUtil.java分层思路就是经典的Controller-Service-Mapper三层。Controller层只做参数接收和结果包装Service层写业务逻辑Mapper层操作数据库。这套规范的好处是清晰的职责边界出问题好排查。3.2 核心表结构设计数据库我设计了7张主要表核心表结构如下简版用户表CREATE TABLE user ( user_id BIGINT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) UNIQUE NOT NULL, nickname VARCHAR(64), avatar VARCHAR(255), phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );酒店表CREATE TABLE hotel ( hotel_id BIGINT PRIMARY KEY AUTO_INCREMENT, scenic_id BIGINT NOT NULL, hotel_name VARCHAR(128) NOT NULL, address VARCHAR(255), cover_url VARCHAR(255), star_level INT, description TEXT, status TINYINT DEFAULT 1 -- 1正常 0停用 );房型表CREATE TABLE room ( room_id BIGINT PRIMARY KEY AUTO_INCREMENT, hotel_id BIGINT NOT NULL, room_name VARCHAR(64), price DECIMAL(10,2), total_count INT, remain_count INT, cover_urls TEXT, -- JSON数组存多张图 create_time DATETIME );订单表CREATE TABLE orders ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) UNIQUE NOT NULL, user_id BIGINT NOT NULL, hotel_id BIGINT NOT NULL, room_id BIGINT NOT NULL, check_in_date DATE NOT NULL, check_out_date DATE NOT NULL, total_price DECIMAL(10,2), status TINYINT, -- 0待支付 1已支付 2已入住 3已完成 4已取消 create_time DATETIME, pay_time DATETIME );房间的remain_count字段是核心并发控制字段在具体扣减时必须用乐观锁或原子更新后面展开。3.3 API接口设计与统一返回结构所有接口的返回格式我统一用这么一个对象Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.setCode(code); r.setMessage(message); return r; } }接口清单我列几个关键路径方法路径说明鉴权POST/user/login微信登录否GET/scenic/list景区列表否GET/hotel/list按景区查酒店否GET/hotel/detail酒店详情否GET/room/available查某日可用房量是POST/order/create创建订单是POST/order/pay发起支付是GET/order/list我的订单是统一返回结构的好处是前端小程序里可以封装一个通用的请求方法先判断res.data.code是否为200再做后续业务处理不用为每个接口写一套错误处理逻辑。4. 核心业务逻辑与防超卖处理4.1 库存扣减别在并发下裸奔这个模块是整个系统的核心也是我和很多同学聊下来最容易出问题的地方。最典型的错误写法是先查remain_count判断大于0再执行update扣减。在小并发下可能没问题但一旦多人同时预订同一房型就会超卖。正确的做法是用一条带条件的UPDATE原子操作Update(UPDATE room SET remain_count remain_count - 1 WHERE room_id #{roomId} AND remain_count 0) int deductStock(Param(roomId) Long roomId);这个SQL的意图是只有当库存大于0时才允许扣减MySQL的行锁保证并发安全。返回值是影响行数如果返回值等于0说明库存不足下单失败。我当时第一次上线后压测了一下100个并发同时抢同一个房型最后订单入库数量和初始库存完全一致这个问题算是彻底解决了。然后配合一个预占释放机制。用户创建订单时先扣库存订单超时未支付则自动释放库存。释放库存用的是反向操作Update(UPDATE room SET remain_count remain_count 1 WHERE room_id #{roomId}) int releaseStock(Param(roomId) Long roomId);4.2 订单状态机与超时关单订单状态流转我理得很清楚待支付(0) - 已支付(1) - 已入住(2) - 已完成(3) 待支付(0) - 已取消(4) 已支付(1) - 已完成(3) // 无需入住直接完成比如景区门票类商品用户创建订单后如果15分钟不支付订单要自动取消并把库存释放。实现方式我选了延迟消息的思路用Redis的过期key监听或者用简单的定时任务扫表。我当时图省事用的Spring自带Scheduled定时任务每分钟扫一次订单表把超过15分钟仍未支付的订单找出来批量更新状态并释放库存。Scheduled(fixedDelay 60000) public void cancelTimeoutOrders() { Date deadline new Date(System.currentTimeMillis() - 15 * 60 * 1000); ListOrder expiredOrders orderMapper.selectExpiredUnpaid(deadline); for (Order order : expiredOrders) { // 更新状态 orderMapper.updateStatus(order.getOrderId(), 4); // 释放库存 roomMapper.releaseStock(order.getRoomId()); } }后来我把这个方法重构成更优雅的方式扣库存时给Redis写一条带TTL的key再用监听器在key过期时触发订单超时处理。不过定时扫表逻辑简单、不容易出错如果你做课设不必过度设计推荐先用定时任务。4.3 支付回调的幂等处理微信支付回调这里泛指小程序支付注意支付从微信侧发起需要商户号有个核心问题就是回调可能重复推送。订单状态更新操作必须幂等。我的处理方式是在回调处理接口最前面加一个状态判断PostMapping(/pay/callback) public String payCallback(RequestBody MapString, String data) { String orderNo data.get(out_trade_no); Order order orderMapper.selectByOrderNo(orderNo); // 如果订单已经是已支付状态直接返回成功避免重复处理 if (order.getStatus() 1) { return success; } // 校验签名、金额 // 更新订单状态为已支付 orderMapper.updateStatus(order.getOrderId(), 1); return success; }这段逻辑看上去简单实际作用很大。重复回调不会导致状态错乱也不会重复释放库存或发券。5. 常见问题与排查技巧实录5.1 小程序端的坑请求域名和缓存第一个坑也是每个小程序开发者都会碰到的小程序真机上不能直接请求本地IP。开发工具里可以勾选不校验合法域名但预览到手机后请求全部失败。解决办法是后端接口上线后绑定HTTPS域名并在小程序后台配置request合法域名。第二个坑是IOS端偶发的网络请求失败。我排查到最后发现是请求头里带了非法的字符比如中文字段微信在IOS上对UTF-8的请求头解析更严格。后面我把所有从header传的值都做了encodeURIComponent处理问题就消失了。第三个坑是setData的坑。在小程序中频繁setData会影响渲染性能尤其是酒店房型列表一次渲染几十个item的场景。做法是分页加载每次只setData当前页的数据滚动到底部再加载下一页。5.2 Spring Boot端的坑日期序列化与跨域LocalDate序列化是我调试最久的一个问题。前端传2025-06-15这种字符串后端LocalDate字段死活解析不进去报各种JsonMappingException。解决方式是在application.yml里配置spring: jackson: date-format: yyyy-MM-dd time-zone: GMT8另外如果用了Java 8的LocalDate还需要引入jackson-datatype-jsr310模块并在LocalDate字段上加上JsonFormat注解两种方式选一个。我当时图快直接在所有日期字段上加了JsonFormat(pattern yyyy-MM-dd)一了百了。跨域配置也是绕不开的坑。小程序端不存在浏览器同源策略问题但如果你额外做了Web管理端就必须在Spring Boot配置CORS。Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }5.3 调试工具与联调技巧我联调时用的工具组合是Postman做接口单测、Apifox做团队文档、Chrome DevTools看后端日志输出。小程序端的网络面板里能看到请求耗时和响应体排查问题时可以多关注微信开发者工具中的Network面板它会给出请求的完整链路数据。还有一个小技巧后端日志里加一个全局请求日志切面把每个接口的入参、耗时、返回码都打出来定位问题能省一半时间。切面代码不复杂一个Aspect注解的类就够用了。6. 最后再分享一些个人经验做完这个项目我个人在实操中最深的体会是做这类系统难的不是增删改查而是把并发场景下的数据一致性想清楚。很多初学者做预订系统单机测试一切正常一上线多人同时使用就出各种问题。核心原因就是没意识到remain_count这种热点数据天然需要原子操作保证。另外一个建议是如果是为了学技术而做这个项目建议不要一开始就追求大而全先把景区列表 - 酒店列表 - 查房 - 下单 - 支付 - 订单管理这条主链路跑通再逐步加上后台管理、评价系统、优惠券之类的扩展功能。主链路顺了这个系统的骨架就稳了后面加什么功能都只是往骨架上添肉的问题。最后再分享一个小技巧接口联调时后端把统一返回结构里的message字段写得稍微详细一点前端拿到后可以直接toast给用户看这样排查问题的时候可以省掉很多来回沟通的时间。比如该日期已无剩余房间一清二楚比返回一个500状态码有意义得多。如果后续你想扩展我比较推荐这几个方向一是在订单模块接入真实的微信支付商户平台二是给酒店列表加上按价格、评分筛选的条件查询三是用Redis做景区热门排行榜的实时缓存。扩展完成后这个系统无论从技术的深度还是广度都已经很能打了。