ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot + 微信小程序麻将馆预约系统完整实战

2026/8/30 17:34:03 拓冰建站 浏览量
Spring Boot + 微信小程序麻将馆预约系统完整实战 简介时间资源预约是本地生活服务中的常见场景。通过Spring Boot和微信小程序构建预约系统可以解决传统电话登记造成的撞车、放鸽子和对账难等管理痛点。其核心原理是将预约视为不可冲突的资源占用借助MySQL唯一索引和事务机制保证并发场景下同一时段只能被一个用户锁定再配合状态机设计管理订单流转。这类系统的技术价值在于低代码、高复用后端接口清晰前端交互流畅可快速适配多种按时间出租空间的业务。无论是棋牌室、剧本杀店还是台球厅只要将包间替换为对应资源就能复用整套预约闭环。本文以麻将馆为例完整拆解了数据库表设计、Spring Boot核心接口实现、小程序前端交互和部署上线流程是一份可直接落地的工程参考。1. 为什么麻将馆需要预约小程序一张登记表引发的管理痛点先说个我实际遇到的场景。去年帮一个朋友做社区棋牌室的数字化改造他的麻将馆一共8个包间生意好时晚上和周末基本满桌。管理方式还是最传统的那种一个厚本子谁打电话来订就用笔写下来几点到几点、哪个包间、留个手机号。听起来没什么问题但实际运营中全是坑。电话订台经常撞车。两个熟客同时打电话要同一间包间前台只能凭记忆和手写记录判断稍不注意就重复预订客人到店发现没位置当场翻脸。还有放鸽子问题有人打电话订了晚上7点到10点的大包间结果9点才来甚至干脆不来空台就是纯亏损。更头疼的是高峰期前台一边接电话一边招呼到店的客人手忙脚乱漏记错记是家常便饭。月底对账更痛苦翻本子、对照微信转账记录一天天核对费时费力还容易出错。这些痛点其实不只是麻将馆有剧本杀店、棋牌室、台球厅、茶室包间凡是按时间段出租空间的场景都是同一套逻辑。我当时给他的方案就是一个基于Spring Boot的微信小程序预约系统。小程序很适合这类本地生活服务用户不用下载App微信里搜一下就能用商家端和用户端数据实时同步预约状态一目了然。这套系统的核心价值就三件事预约不撞车、取消有记录、营收能统计。技术上用的都是Java生态里非常成熟的方案Spring Boot做后端接口MySQL存业务数据微信小程序做前端展示和交互。整个项目源码包含了前端小程序、后端Java代码和SQL数据库脚本拿到之后直接导入数据库、启动后端、小程序开发者工具里跑起来就能完成一套完整的预约闭环。下面我把这个项目的设计思路和实现过程完整拆开讲。无论你是想自己做一个类似的预约系统还是想学习Spring Boot 小程序的完整项目开发流程这篇内容都可以作为一份可以照着做的实战参考。2. 系统整体架构与核心功能拆解2.1 技术选型为什么是Spring Boot而不是别的后端框架选Spring Boot不是因为跟风而是这个场景下它确实最合适。先看项目规模。麻将馆预约系统属于典型的中小型业务系统用户量以门店周边几公里内的常客为主并发量低但业务逻辑的复杂度并不低——要处理用户身份认证、包间时段管理、预约状态流转、支付回调等多个环节。Spring Boot在这类项目上是标准答案起步快、生态成熟、招人容易。对比一下其他方案。用Node.js或Python Flask也能做但在国内Java技术栈的社区生态、面试认可度、后续维护便利性上Spring Boot有明显优势。用PHP的话开发确实快但到后期要扩展微信支付、接入消息推送这类能力时Java的第三方SDK和文档完整度更高。不用Spring Cloud这种微服务框架原因更简单——单店场景一台服务器就够微服务的服务注册、配置中心、链路追踪全是负担。单体应用加合理分层部署维护最简单出了问题也好排查。版本上我用的Spring Boot 2.7.x因为要兼容小程序端的接口调用方式同时2.7还在常规维护期内。JDK用的1.8说实话这个选择是出于稳妥考虑很多生产环境的服务器还是8项目要给别人拿去部署的JDK版本设太高容易在环境准备阶段就劝退一波人。ORM层选了MyBatis-Plus相比原生MyBatis它提供的BaseMapper内置CRUD和分页插件能省掉大量重复的XML编写工作同时保留了MyBatis的灵活性。数据库用MySQL 5.7或8.0都行SQL脚本里做了兼容处理。前端小程序部分用的是微信原生开发方式没有引入uni-app或Taro这类跨端框架。原因有两点第一这个项目只需要服务微信小程序一个端完全不需要跨端能力第二原生小程序的组件和API调用最直接出了问题查官方文档就能解决加一层框架反而多了一道排查成本。2.2 功能模块划分用户端和商家端各管什么整个系统的功能围绕一条主线展开用户在小程序里选包间、选时段、提交预约商家在后台管理包间、查看订单、处理变更。拆开来看主要分为三个端。用户端小程序内微信授权登录获取用户微信昵称和头像自动创建本地账号包间列表展示按包间类型小包/中包/大包/豪华包和可容纳人数筛选支持查看包间图片和每小时价格时段选择按日期展示当天可预约的时间段已被预约的时段置灰不可选创建预约选择包间、日期、时段、填写联系电话和备注提交后生成预约单我的预约查看历史预约和待消费预约支持在线取消订单状态跟踪已支付、待使用、已完成、已取消状态变更清晰可见商家端管理后台包间管理增删改查包间信息配置包间类型、可容纳人数、每小时价格预约管理查看所有预约订单按日期和状态筛选支持手动确认或取消预约营业统计按日/周/月维度统计订单数量、营业额、包间使用率时段设置开放/关闭某个时间段的预约能力2.3 核心业务流程预约状态的一次完整流转预约系统的核心是状态机设计。我把预约单状态设计为四种待支付、已确认、已完成、已取消。用户在小程序里提交预约信息后系统生成一条待支付状态的预约单同时锁定对应包间和时段。用户完成支付后状态变为已确认商家可以在后台看到这笔订单并提前准备包间。用户到店消费后商家在后台点击确认完成订单状态变为已完成整个交易闭环结束。任何一方在消费前都可以发起取消但已确认状态的取消需要商家在后台操作避免用户随意取消导致包间空置影响营收。这里有一个关键设计创建预约时就要锁定包间时段。用户在支付前那段时间这个时段不能被其他人预订否则会出现两个人同时创建订单、先支付的人反而没位置的极端情况。我用数据库唯一索引加事务的方式保证这一点后面章节会详细讲。整个系统大概50多个接口覆盖登录认证、包间查询、预约下单、支付回调、订单管理等。代码结构上按标准的Controller-Service-Mapper三层来组织包名按模块划分保证代码可读性和可维护性。3. 数据库设计预约系统的地基怎么打3.1 核心表结构与字段设计思路数据库是预约系统的地基设计得好不好直接决定了后面业务逻辑怎么写、并发问题多不多、统计报表怎么做。先看核心表的整体结构。用户表userCREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, openid VARCHAR(64) NOT NULL COMMENT 微信openid, nickname VARCHAR(64) DEFAULT COMMENT 昵称, avatar_url VARCHAR(255) DEFAULT COMMENT 头像地址, phone VARCHAR(20) DEFAULT COMMENT 手机号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;openid是用户在微信生态里的唯一标识设为唯一键。用户第一次通过小程序授权登录时用openid查不到记录就自动创建账号查得到就直接返回用户信息。用openid做用户标识的好处是不用自己管账号密码体系微信已经帮我们完成了身份认证。包间表roomCREATE TABLE room ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, name VARCHAR(50) NOT NULL COMMENT 包间名称, type TINYINT NOT NULL DEFAULT 1 COMMENT 包间类型 1小包 2中包 3大包 4豪华包, capacity INT NOT NULL DEFAULT 4 COMMENT 可容纳人数, price_per_hour DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 每小时价格, cover_image VARCHAR(255) DEFAULT COMMENT 包间图片, description VARCHAR(500) DEFAULT COMMENT 包间描述, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态 1启用 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT包间表;这里有个经验不要用包间号做业务主键。有些设计图省事直接用101102这种房间号作为主键但后期如果换包间、改编号关联的数据都要跟着改非常麻烦。自增ID做主键房间号只是展示字段业务和存储解耦。时段表time_slotCREATE TABLE time_slot ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, start_time TIME NOT NULL COMMENT 开始时间, end_time TIME NOT NULL COMMENT 结束时间, sort_order INT DEFAULT 0 COMMENT 排序, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT可预约时段表;时段表存的是通用时段模板比如10:00-12:00、13:00-15:00、19:00-21:00、21:00-23:00。这里要特别说一下设计取舍为什么不直接在预约订单里存开始时间和结束时间还要单独搞一张时段表原因有两个。第一麻将馆的预约是按固定时段走的不是用户随便选一个起止时间用时段表可以统一管理每天开放哪些时段想调整直接在后台改一条记录就行。第二时段表配合包间表和日期可以提前生成未来N天的可预约时段列表用户查询时秒开不用实时去算哪些时段可用。预约订单表reservation_orderCREATE TABLE reservation_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, order_no VARCHAR(32) NOT NULL COMMENT 订单编号, user_id BIGINT NOT NULL COMMENT 用户ID, room_id BIGINT NOT NULL COMMENT 包间ID, slot_id BIGINT NOT NULL COMMENT 时段ID, reservation_date DATE NOT NULL COMMENT 预约日期, amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 订单金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 订单状态 0待支付 1已确认 2已完成 3已取消, contact_phone VARCHAR(20) DEFAULT COMMENT 联系电话, remark VARCHAR(255) DEFAULT COMMENT 备注, pay_time DATETIME DEFAULT NULL COMMENT 支付时间, cancel_time DATETIME DEFAULT NULL COMMENT 取消时间, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_room_slot_date (room_id, slot_id, reservation_date), KEY idx_user_id (user_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约订单表;这个表的重点是那个联合唯一索引uk_room_slot_date它同时包含包间ID、时段ID和预约日期三个字段。这个索引是整个预约系统防止超卖的核心保障。想象一下这个场景用户A和用户B同时提交同一个包间在同一日期同一时段的预约请求。如果没有唯一索引两个请求都会通过查询检查发现当前没有冲突记录然后都插入成功这就造成了重复预约。加了唯一索引后数据库层面会直接拒绝第二个插入操作保证数据不可能出现冲突。有人可能会问能不能不加唯一索引只靠代码里加锁或事务来控制答案是可以但没必要。数据库唯一索引是最后的防线也是性能最好的控制手段代码层面再配合逻辑判断做第一道拦截双保险。支付流水表payment_recordCREATE TABLE payment_record ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, order_no VARCHAR(32) NOT NULL COMMENT 订单编号, transaction_id VARCHAR(64) DEFAULT COMMENT 微信支付交易号, amount DECIMAL(10,2) NOT NULL COMMENT 支付金额, pay_type TINYINT DEFAULT 1 COMMENT 支付方式 1微信支付 2线下支付, status TINYINT DEFAULT 0 COMMENT 支付状态 0未支付 1已支付 2已退款, pay_time DATETIME DEFAULT NULL COMMENT 支付时间, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT支付流水表;支付流水表是为了对账用的。每笔订单关联一条或多条支付流水记录第三方支付平台返回的交易号后续如果涉及退款或财务审计这张表是关键凭证。3.2 预约日期数据怎么处理一张日期表解决时间判断问题预约系统涉及日期的筛选判断时间相关的计算逻辑容易埋坑。我的做法是单独设计一张日期维度表预先生成未来30天的所有日期和每一天对应的星期几。这样做的实际好处有三个第一用户在小程序端按日期选择包间时后端可以直接通过联表查询判断某天某时段是否可约SQL写起来清晰明了第二商家后台可以一眼看到未来每天各时段的预约情况方便排班和包间维护第三如果要做节假日加价、特殊日期闭店这类运营策略直接在这张表上维护字段就行不需要改订单逻辑。当时也纠结过是不是用定时任务每天自动生成7天内的可预约数据后来想想还是预生成30天的方案简单可靠——数据量撑死了几百条完全不存在性能问题还省去了定时任务的额外复杂度。3.3 SQL初始化脚本里预置了什么SQL脚本里除了建表语句我还预置了一批初始化数据包括管理员账号用户名admin密码BCrypt加密后的密文4种不同类型的包间样例数据覆盖常见配置一天6个时段的模板数据未来30天的日期维度数据这样做的目的是拿到源码后不用自己造数据就能先把整个流程跑通。很多开源项目给的SQL脚本里只有表结构没有数据启动后页面空空如也得自己一样样录入体验很差。我在脚本里把基础数据和样例数据都准备好了导入即可用。4. Spring Boot 后端实现核心接口与高并发预约逻辑4.1 项目结构一览后端项目采用标准的Maven多模块结构虽然只有一个模块但包名按功能划分清晰com.majiang.reservation ├── controller // 接口层 │ ├── UserController.java // 用户相关 │ ├── RoomController.java // 包间相关 │ ├── ReservationController.java // 预约相关 │ └── AdminController.java // 后台管理 ├── service // 业务逻辑层 │ ├── UserService.java │ ├── RoomService.java │ ├── ReservationService.java │ └── PaymentService.java ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体类 ├── dto // 数据传输对象接收前端参数 ├── vo // 视图对象返回前端数据 ├── config // 配置类拦截器、跨域、微信配置等 ├── common // 通用类统一返回结果、异常处理、工具类 └── ReservationApplication.java // 启动类4.2 统一返回结果与全局异常处理前后端分离的项目接口返回格式必须统一否则前端处理起来非常痛苦。我定义了一个通用的ResultT类所有接口都返回这个结构Data public class ResultT { private Integer code; // 200成功其他为失败 private String message; // 提示信息 private T data; // 业务数据 public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }配合全局异常处理器业务代码里如果遇到参数校验失败、预约冲突、包间不存在等情况直接抛出业务异常由全局处理器统一转换为Result.error()返回。这样Controller层就不用写一堆try-catch代码干净许多。4.3 微信登录接口的实现小程序端通过wx.login()拿到临时code传给后端后端拿这个code去微信接口换取openid。这个流程是固定的PostMapping(/api/user/login) public ResultUserVO 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; String result restTemplate.getForObject(url, String.class); JSONObject json JSON.parseObject(result); String openid json.getString(openid); // 2. 根据openid查找用户不存在则创建 User user userMapper.selectOne( new LambdaQueryWrapperUser().eq(User::getOpenid, openid) ); if (user null) { user new User(); user.setOpenid(openid); user.setNickname(dto.getNickname()); user.setAvatarUrl(dto.getAvatarUrl()); userMapper.insert(user); } // 3. 生成自定义登录态token返回给前端 String token UUID.randomUUID().toString().replaceAll(-, ); // token存入Redis设置过期时间 redisTemplate.opsForValue().set(token: token, user.getId().toString(), 7, TimeUnit.DAYS); UserVO vo new UserVO(); vo.setToken(token); vo.setUserInfo(user); return Result.success(vo); }这里有个安全细节不要直接把openid返回给前端当身份凭证。openid相当于用户在系统中的唯一身份标识泄露后虽然不能直接造成损害但会增加被恶意构造请求的风险。正确做法是后端生成一个随机token作为登录态前端后续请求只要带上这个token后端通过拦截器解析出用户身份。我用Redis存token设置7天过期用户不需要频繁重新登录。4.4 查询可预约时段一次联表查出完整状态用户在小程序首页选择一个日期后点击某个包间需要看到这个包间在这一天各时段的可预约状态。这个接口我设计成一次查询返回所有时段的状态而不是逐个时段去数据库查GetMapping(/api/room/available-slots) public ResultListSlotStatusVO getAvailableSlots(RequestParam Long roomId, RequestParam String date) { // 1. 查询该包间当天的所有有效预约 ListReservationOrder orders reservationMapper.selectList( new LambdaQueryWrapperReservationOrder() .eq(ReservationOrder::getRoomId, roomId) .eq(ReservationOrder::getReservationDate, date) .in(ReservationOrder::getStatus, Arrays.asList(0, 1)) // 待支付和已确认都算占用 ); // 2. 查出所有时段的模板 ListTimeSlot allSlots timeSlotMapper.selectList(null); // 3. 将已预约的时段标记为不可选 ListLong bookedSlotIds orders.stream() .map(ReservationOrder::getSlotId) .collect(Collectors.toList()); ListSlotStatusVO result allSlots.stream().map(slot - { SlotStatusVO vo new SlotStatusVO(); vo.setSlotId(slot.getId()); vo.setStartTime(slot.getStartTime()); vo.setEndTime(slot.getEndTime()); vo.setBooked(bookedSlotIds.contains(slot.getId())); return vo; }).collect(Collectors.toList()); return Result.success(result); }待支付和已确认状态都算作占用这是为了避免用户提交订单后不付款导致其他用户看到这个时段可约但实际无法提交。如果用户在15分钟内没有完成支付后台定时任务会自动释放订单占用的时段。这个小细节在大流量场景下很重要——否则会出现大量无效占位真正想订的用户订不到。4.5 创建预约事务唯一索引双保险创建预约是系统最核心的接口涉及多张表的写入和并发控制Transactional(rollbackFor Exception.class) PostMapping(/api/reservation/create) public ResultReservationOrderVO create(RequestBody CreateReservationDTO dto, RequestHeader(token) String token) { // 1. 从token解析用户ID Long userId getUserIdFromToken(token); // 2. 校验包间是否存在且启用 Room room roomMapper.selectById(dto.getRoomId()); if (room null || room.getStatus() ! 1) { throw new BusinessException(包间不存在或已停用); } // 3. 检查时段和日期是否合法 TimeSlot slot timeSlotMapper.selectById(dto.getSlotId()); if (slot null) { throw new BusinessException(时段不存在); } // 4. 检查未来3天之外不能预约 LocalDate today LocalDate.now(); LocalDate maxDate today.plusDays(3); if (dto.getReservationDate().isAfter(maxDate)) { throw new BusinessException(仅支持未来3天内的预约); } // 5. 检查该时段是否已被预约业务层第一道拦截 Long count reservationMapper.selectCount( new LambdaQueryWrapperReservationOrder() .eq(ReservationOrder::getRoomId, dto.getRoomId()) .eq(ReservationOrder::getSlotId, dto.getSlotId()) .eq(ReservationOrder::getReservationDate, dto.getReservationDate()) .in(ReservationOrder::getStatus, Arrays.asList(0, 1)) ); if (count 0) { throw new BusinessException(该时段已被预约请选择其他时段); } // 6. 生成订单编号写入订单表 String orderNo generateOrderNo(); // 日期随机数 ReservationOrder order new ReservationOrder(); order.setOrderNo(orderNo); order.setUserId(userId); order.setRoomId(dto.getRoomId()); order.setSlotId(dto.getSlotId()); order.setReservationDate(dto.getReservationDate()); order.setAmount(calculateAmount(room, slot)); order.setStatus(0); // 待支付 order.setContactPhone(dto.getContactPhone()); order.setRemark(dto.getRemark()); reservationMapper.insert(order); ReservationOrderVO vo new ReservationOrderVO(); vo.setOrderNo(orderNo); vo.setAmount(order.getAmount()); return Result.success(vo); }注意第5步的业务层检查和数据库唯一索引是双重防线。业务层检查负责给出友好的错误提示该时段已被预约唯一索引负责在极端并发情况下兜底。如果两个请求几乎同时通过了第5步的检查都进入第6步执行insert数据库的唯一索引会让第二个事务直接抛出DuplicateKeyException事务回滚后层转换为业务异常返回给前端。4.6 取消预约退款与释放时段的处理取消预约要处理两件事释放时段、退款如果是已支付状态。Transactional(rollbackFor Exception.class) PostMapping(/api/reservation/cancel) public ResultVoid cancel(RequestBody CancelDTO dto, RequestHeader(token) String token) { Long userId getUserIdFromToken(token); ReservationOrder order reservationMapper.selectById(dto.getOrderId()); // 校验订单归属 if (order null || !order.getUserId().equals(userId)) { throw new BusinessException(订单不存在); } // 已完成的订单不能取消 if (order.getStatus() 2) { throw new BusinessException(订单已完成无法取消); } // 已取消的订单不能重复取消 if (order.getStatus() 3) { throw new BusinessException(订单已取消请勿重复操作); } // 如果已支付走退款逻辑这里简化为本地标记实际对接微信退款接口 if (order.getStatus() 1) { // 调用微信支付退款接口... paymentService.refund(order.getOrderNo(), order.getAmount()); } order.setStatus(3); order.setCancelTime(LocalDateTime.now()); reservationMapper.updateById(order); return Result.success(null); }这里有一个业务规则的细化分状态处理取消。待支付和已确认的订单可以取消已完成的订单不能取消。已支付订单取消后要触发退款。但退款金额要不要全额退这是个业务问题——如果用户提前一天取消全额退没问题如果预约当天取消商家可能已经准备了茶水等服务这时候是否扣手续费需要商家在后台配置。这部分我做成可配置项默认全退商家可以在系统设置里调整为当天取消收取50%费用。4.7 商家后台统计报表的数据链路商家后台的营业统计是另一个实用模块。按日/周/月统计营业额和订单量核心SQL用MySQL的日期函数实现// 按天统计近7天营业额 SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM reservation_order WHERE status 2 -- 已完成的订单才算收入 AND create_time DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY day;注意这里的统计口径只有已完成的订单才计入营业收入。待支付订单没有真实收款已取消订单更是不能算进去。如果统计错了口径商家看到账面上赚了很多实际手里没收到钱会严重影响信任。包间使用率统计则通过预约时段数除以总时段数来算SELECT room_id, COUNT(DISTINCT CONCAT(reservation_date, -, slot_id)) AS booked_slots, (SELECT COUNT(*) FROM time_slot) AS total_slots_per_day, COUNT(DISTINCT reservation_date) AS booked_days FROM reservation_order WHERE status IN (1, 2) GROUP BY room_id;5. 小程序前端用户预约全流程的实现细节5.1 前端项目结构与页面设计小程序端是原生开发页面不多但每页都有实际内容。项目结构如下miniprogram/ ├── pages/ │ ├── index/ // 首页包间列表 │ ├── room-detail/ // 包间详情时段选择与预约 │ ├── order-create/ // 确认预约填写联系方式和备注 │ ├── order-list/ // 我的预约历史与待消费订单 │ ├── order-detail/ // 订单详情 │ └── profile/ // 个人中心 ├── utils/ │ ├── request.js // 封装wx.request统一处理token │ └── util.js // 日期格式化等工具函数 ├── app.js // 小程序入口全局数据 ├── app.json // 全局配置 └── app.wxss // 全局样式页面设计遵循一个原则核心操作不出三级页面。用户从首页看到包间列表点击一个包间进详情页选完时段直接进入确认页三步完成预约。中间不做多余的引导弹窗、不做强制注册表单最大程度降低操作成本。传统电话预约要打电话、等接通、报需求、核对信息至少两分钟小程序里点几下10秒搞定。5.2 请求封装与登录态管理小程序的网络请求必须封装否则每个页面都写一遍wx.request代码要爆炸。我在utils/request.js里统一处理了baseURL、token附加和错误提示const BASE_URL https://your-domain.com/api function request(url, method, data) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL url, method: method, data: data, header: { Content-Type: application/json, token: wx.getStorageSync(token) }, success: (res) { const result res.data if (result.code 200) { resolve(result.data) } else if (result.code 401) { // token过期或未登录静默重新登录 wx.removeStorageSync(token) login().then(() { // 登录成功后重新发起原请求 request(url, method, data).then(resolve).catch(reject) }) } else { wx.showToast({ title: result.message, icon: none }) reject(result) } }, fail: (err) { wx.showToast({ title: 网络异常请稍后重试, icon: none }) reject(err) } }) }) }登录态处理是整个前端最需要注意的坑。小程序的wx.login()拿到的code是一次性的5分钟内有效且只能换一次openid。有些同学在app.js里登录一次拿到token后就什么都不管了结果token过期后所有接口全部401。我这里的处理是听到401就自动重新登录然后重新执行刚才失败的请求用户完全无感知。5.3 包间详情页时段选择的交互设计包间详情页的核心是一个时段选择器。从后端拿到当天所有时段的状态后前端根据是否被预定来渲染两种样式可选高亮边框点击选中、已被预订置灰不可点击。// room-detail.js Page({ data: { room: null, date: , slots: [], selectedDate: , selectedSlotId: null, canSubmit: false }, onLoad(options) { const roomId options.id // 默认选中今天 this.setData({ date: this.getToday(), selectedDate: this.getToday() }) this.loadRoomInfo(roomId) this.loadSlots(roomId, this.data.date) }, selectDate(e) { const date e.currentTarget.dataset.date this.setData({ selectedDate: date, selectedSlotId: null, canSubmit: false }) this.loadSlots(this.data.room.id, date) }, selectSlot(e) { const slotId e.currentTarget.dataset.id const booked e.currentTarget.dataset.booked if (booked) return this.setData({ selectedSlotId: slotId, canSubmit: true }) }, loadSlots(roomId, date) { request(/room/available-slots?roomId${roomId}date${date}, GET) .then(data { this.setData({ slots: data }) }) }, goCreateOrder() { if (!this.data.canSubmit) { wx.showToast({ title: 请选择预约时段, icon: none }) return } wx.navigateTo({ url: /pages/order-create/order-create?roomId${this.data.room.id}slotId${this.data.selectedSlotId}date${this.data.selectedDate} }) } })日期选择这块我用的是一个自定义的横向滚动日期条展示未来3天的日期和星期。这个小功能如果直接用picker组件其实也能做但横向日期条在棋牌室、美容院这类预约场景里是主流交互用户看着更直观点起来也更顺手。5.4 支付流程与Mock方案支付是预约流程的临门一脚。接入真实微信支付需要商户号、API证书、回调域名等一系列资质和配置很多学习项目卡在这步。我提供一个两层方案。如果你有微信支付商户号后端已经写好了统一下单和回调接口的对接代码配置好商户号和密钥就可以直接跑真实支付。如果只是学习练手或者演示用可以在后端配置一个开关开启模拟支付模式——用户点击确认支付后前端直接调用后端的模拟支付接口接口返回支付成功并更新订单状态为已确认。这个设计很实用。很多培训机构和外包项目在交付时也用类似方案先让客户体验完整流程等资质办下来再接真实支付。代码里预留好了对接位置后面切换只需要改配置。5.5 我的预约列表状态展示与操作按钮订单列表页按状态分Tab展示全部、待支付、待使用、已完成、已取消。待支付Tab里订单卡片上有去支付按钮点击跳转支付流程超过15分钟未支付会显示订单已超时关闭此时后端定时任务已经将时段释放用户可以重新预约。待使用Tab里显示取消预约按钮和到店核销码商家可以在后台或商家端小程序里输入核销码确认消费。核销码这里多说一句。我在每笔已支付订单上生成一个6位数的随机码用户到店后报给前台前台在商家后台输入这6位数完成核销订单状态从待使用变为已完成。这个设计比直接让商家手动标记完成更规范也能防止用户和商家之间的纠纷——有核销记录证明用户确实到店消费了。6. 部署上线与数据库初始化从源码到可用的完整步骤6.1 拿到源码后的第一步本地环境准备整套系统跑起来需要的前置环境如下版本号是我验证过的组合组件版本说明JDK1.8后端运行环境Maven3.6依赖管理MySQL5.7或8.0业务数据库Redis5.x登录态存储微信开发者工具最新稳定版小程序前端开发调试Node.js不需要原生小程序不需要Node构建环境准备中最容易踩坑的是JDK和Maven版本不匹配。建议用JDK 8配Maven 3.6.x这个组合兼容性最好。用JDK 11以上跑Spring Boot 2.7.x也没有问题但部分老版本Maven插件会报错。6.2 数据库导入与配置拿到源码后进入sql目录会看到init.sql文件。导入方式有两种第一种命令行导入mysql -u root -p init.sql第二种通过Navicat或DBeaver等图形化工具新建数据库后在运行SQL文件里选择init.sql执行。导入后注意看控制台日志确认没有报错。如果报错通常是字符集问题检查数据库字符集是否为utf8mb4。导入完成后修改后端配置文件application.yml中的数据源连接信息spring: datasource: url: jdbc:mysql://localhost:3306/majiang_reservation?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: your_password redis: host: localhost port: 6379 database: 0 wx: appid: your_miniprogram_appid secret: your_miniprogram_secret pay: mock-enabled: true # 先用模拟支付跑通流程 appid: your_pay_appid mch-id: your_merchant_id api-key: your_api_key这里有个经常让人卡壳的配置项serverTimezone。不设置这个参数连接MySQL 8.0时经常会报Server returns invalid timezone错误。我写死为Asia/Shanghai避免因为服务器默认时区问题导致时间和数据库不一致。6.3 后端启动与验证在项目根目录执行mvn clean package -DskipTests java -jar target/majiang-reservation-1.0.0.jar看到Started ReservationApplication in x seconds日志说明启动成功。此时用浏览器访问http://localhost:8080/api/room/list如果返回JSON格式的包间列表数据整个后端链路已经打通。6.4 小程序前端连接后端在微信开发者工具中导入miniprogram目录将utils/request.js中的BASE_URL改为后端实际地址。本地开发时如果小程序勾选了不校验合法域名可以直接填http://localhost:8080真机预览时必须填HTTPS域名且要在小程序后台配置合法请求域名。这步是很多新手卡住的地方。小程序生产环境要求请求地址必须是HTTPS且域名必须在小程序管理后台加入白名单。用IP地址或者HTTP协议在真机上都会直接报request:fail。我的建议是本地调试用开发者工具不校验域名等要上线了再买服务器、配HTTPS证书、挂Nginx反向代理。Nginx反向代理的配置也一并附上这一步在部署时少不了server { listen 443 ssl; server_name your-domain.com; ssl_certificate /etc/nginx/ssl/your-domain.pem; ssl_certificate_key /etc/nginx/ssl/your-domain.key; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }6.5 数据初始化与演示账号数据库导入后系统里已经预置了演示数据管理员账号admin / admin123密码已加密存储8个包间数据覆盖4种类型未来30天的日期数据6个默认预约时段用管理员账号登录商家后台可以看到所有包间信息和预约订单。用小程序端微信授权登录后就能正常走预约流程。7. 开发过程中遇到的真实问题与排查记录7.1 并发预约怎么避免超卖从代码BUG到数据库约束这个问题的排查过程可以独立写一篇博客。最开始我并没有加联合唯一索引只靠Service层先查再插的逻辑控制并发。当时自测没有任何问题直到用JMeter模拟两个用户同时提交同一个包间同一时段的预约请求很快复现了重复预约。问题的根因是经典的并发竞态条件两个请求同时通过SELECT查询发现没有冲突记录然后都执行INSERT插入成功。Java层面的同步锁在这个场景下根本无效因为请求会被负载均衡分发到不同的应用实例即使单实例部署锁的范围也可能超出预期。解决思路分两层。数据库层加联合唯一索引兜底这是最可靠的方案应用层用分布式锁或数据库悲观锁做第一道拦截。我实际采用的方案是数据库唯一索引 应用层乐观锁校验。对大多数中小型预约系统来说数据库唯一索引这一层已经足够不需要引入Redisson这类分布式锁框架过度设计反而增加复杂度。7.2 微信小程序请求后端超时问题开发过程中遇到一个奇怪的问题小程序端请求后端接口偶尔会报超时而且集中在首次访问时。排查后发现是微信开发者工具的域名校验机制在作怪——本地开发时请求http://localhost:8080开发者工具默认会对不合法域名做拦截虽然勾选了不校验合法域名选项但在某些版本的工具中首次请求还是会先做一次DNS解析解析失败直接判定超时。解决方法是把本地后端地址从localhost改成127.0.0.1同时在开发者工具中关闭自动预览和校验HTTPS证书选项。类似问题的另一诱因是后端接口处理时间过长小程序默认超时时间是60秒如果接口在首次启动时需要初始化数据库连接池或加载大量缓存容易卡在超时边缘。我习惯在Controller上设置显式超时时间同时在关键接口上打印耗时日志方便定位。7.3 数据库时间与服务器时区不一致导致预约日期错乱上线前联调时发现一个诡异现象用户在小程序端选择明天的日期创建订单后后台订单表里存的时间比实际提前了8小时。排查发现是JDBC连接串中没有设置serverTimezone参数MySQL连接的默认时区与服务器所在时区不一致导致时间在写入数据库时被转换了一次。这问题的隐蔽之处在于本地开发时因为MySQL和本地环境时区都是默认的Asia/Shanghai没有暴露部署到云服务器后云数据库的time_zone参数可能设置为UTC于是时间偏移8小时。排查过程花了不少时间先怀疑是代码问题打印日志发现后端接收的时间正确最后对比MySQL的global.time_zone参数才定位到根因。处理方式就是刚才提到的连接串里显式指定serverTimezoneAsia/Shanghai。7.4 小程序登录态token在Android端偶发失效测试阶段发现部分Android真机上用户登录后当天还能正常使用第二天再打开小程序就出现登录失效需要重新登录。iOS端没有这个问题。排查后发现是我的token刷新策略有问题iOS的wx.login()每次调用都会返回新的code可以静默重新登录但Android端在某些情况下wx.login()返回的code相同后端拿这个code去微信换openid时会报invalid code错误。原因是微信的code使用一次后就失效且有两分钟的有效期。我原来的实现是每次请求发现401就重新登录但在Android端因为code复用导致换openid失败登录流程卡死。解决方法是前端在收到401后先获取新的code再调登录接口同时后端增加容错——如果发现code换openid失败且当前存在有效token就延长原token的有效期而不是强制重新登录。7.5 包间图片上传与小程序代码包体积控制包间图片存在服务器本地还是用对象存储我最终选择了本地存储。原因很简单单店场景图片量不大一张包间图200KB以内几十张图总共几MB完全在服务器可承受范围内。用云存储阿里云OSS、腾讯云COS需要额外开通服务、配置凭证对单体项目来说是多余的复杂度。但这里有个小程序端的注意事项小程序代码包有2MB大小限制。如果包间图片直接放在小程序代码包里稍微多点图片就超限。正确的做法是图片放服务器或云端小程序通过URL访问。我的实现是商家后台上传图片到服务器指定目录nginx映射为可访问的静态资源路径小程序端直接image srchttps://your-domain.com/images/room1.jpg加载。8. 这套源码的扩展方向从麻将馆到更多空间预约场景做完这个麻将馆预约系统后我明显感觉到同一套架构可以平移到很多类似的场景。核心的预约逻辑——选资源、选时间、创建订单、支付、核销——是完全通用的不同的只是行业特定的字段和前端展示方式。比如剧本杀店包间换成主题房时段换成场次额外需要增加选择剧本的关联字段台球厅包间换成球桌时段颗粒度可以更细按小时而非固定时段茶室包间换成茶室包厢可能需要增加茶点预定的功能。这些扩展在现有表结构上都能实现——room表加几个字段、reservation_order表加一个关联ID不需要动核心架构。还有一个值得做的扩展是会员卡和次卡功能。麻将馆的常客消费频率高很多店都卖充值卡或次卡。在现有基础上增加一张会员卡表和充值流水表预约支付时优先扣减卡余额这样可以显著提升用户粘性。这部分我已经在规划中等实现后可以再写一篇补充文章。另一个实际需求是营销功能。比如发放优惠券新用户注册送一张满100减20的券用户预约时可以选择使用。这类功能在现有预约流程中嵌入核心是增加一张优惠券表和一个订单优惠关联表支付时计算优惠金额。从纯技术的角度看这套代码可以作为Spring Boot入门到进阶的学习素材。它涵盖了一个完整业务系统的大部分核心点登录认证、数据建模、事务管理、并发控制、前后端联调、部署上线。如果你能把每个模块的设计原因想清楚、把每个坑的排查过程吃透至少能顶上半年的工作经验积累。本文还有配套的精品资源点击获取