简介:面向高校计算机相关专业毕业设计或课程设计场景,这份微信小程序琴房管理系统项目包含完整源码、说明文档、LW及演示视频。系统按学生与管理员双角色设计:学生端支持琴房查看、预约与公告浏览,管理员端覆盖学生信息管理、琴房公告发布及登录注销等操作,业务链路完整,适合需要快速理解小程序全栈开发并产出毕设成果的读者。压缩包共1105个文件,大小约25.4MB,以vue、js、java、class等前后端源码文件为主,辅以wxml/wxss小程序页面、json配置、sql数据库脚本、docx文档,以及png/svg界面素材和mp4演示视频,目录结构清晰、资源类型齐全。已有188人学习下载。包内还附带安装/运行/构建的bat脚本、关键文件备份与常见配置说明,结合演示视频和说明文档,可帮助读者梳理琴房预约的业务流程、接口设计及前后端交互细节,便于二次开发与答辩准备。
1. 琴房管理系统怎么做:这个微信小程序项目到底解决什么问题
琴房管理在很多学校还停留在「微信群接龙 + 管理员人工排表」的阶段:学生接龙预约,到了现场发现琴房被占用,管理员每天花大量时间核对时间表、处理冲突。
基于微信小程序的琴房管理系统,把整套流程搬到线上——学生打开小程序查看琴房实时状态、按时间段预约,管理员维护琴房、管理预约记录。这类项目常出现在毕业设计和课程设计中,因为业务边界清晰,又完整覆盖了小程序前端、服务端接口、数据库设计和定时任务几个模块。第一次做小程序全栈项目,选它能把前后端基本功都练到;评估它值不值得做,核心就看一点:预约冲突怎么用最少的代码挡住。本文把这条线从头到尾讲透。
2. 需求拆解与数据库建模:琴房预约的核心是时间冲突检测
做这类系统,别急着写代码,先把业务流程和表结构想清楚。后台的编排决定了预约逻辑的复杂度,一旦表结构定错,后面改起来牵一发动全身。琴房管理系统翻来覆去就那么几个页面,但真正考验设计能力的,是预约这件事怎么用数据模型表达出来。
2.1 三种角色与核心业务流程:预约、取消、签到怎么串
系统的角色其实就三类:学生、管理员、定时任务。学生侧重在链路上走:查看琴房列表 → 选择日期与时间段 → 提交预约 → 到琴房签到 → 完成使用。管理员侧重维护:维护琴房信息、查看所有预约、异常情况下强制取消。定时任务做兜底:把超时未签到的预约标记为爽约,把过期预约归档。
业务规则上,我一般会按这几条定:
- 同一琴房同一日期同一时间段,只能存在一条有效预约。
- 预约开始时间必须早于结束时间,且落在琴房营业时间内。
- 只能预约未来 7 天内的时段,不能预约过去的日期。
- 取消预约必须在开始时间之前完成。
- 预约开始后迟到 15 分钟未签到,视为爽约。
这些规则里最容易出错的是第 1 条——时间冲突。判断两个时间段是否重叠,很多人第一反应是「新预约的开始时间是否落在已有预约的区间里」,这个直觉是错的。正确的判定公式是:只要新时段的开始早于已有时段的结束,且新时段的结束晚于已有时段的开始,就必然重叠。这个公式贯穿整篇文章,后面的 SQL 和代码都在围着它转。
2.2 用三张表把业务落地:user、room、booking 的字段设计与索引
核心就三张表,字段不需要多,够用就行。下面是推荐的表结构。
-- 用户表 CREATE TABLE `sys_user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL COMMENT '微信用户唯一标识', `nickname` VARCHAR(50) DEFAULT '' COMMENT '昵称', `student_no` VARCHAR(20) DEFAULT '' COMMENT '学号,管理员可录入', `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0-学生 1-管理员', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '0-禁用 1-正常', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 琴房表 CREATE TABLE `piano_room` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `room_name` VARCHAR(50) NOT NULL COMMENT '房间名,如 A101', `location` VARCHAR(100) DEFAULT '' COMMENT '位置描述', `piano_type` VARCHAR(30) DEFAULT '' COMMENT '钢琴类型', `is_open` TINYINT NOT NULL DEFAULT 1 COMMENT '0-关闭 1-开放', `open_time` TIME DEFAULT '08:00:00' COMMENT '营业开始', `close_time` TIME DEFAULT '22:00:00' COMMENT '营业结束', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='琴房表'; -- 预约表 CREATE TABLE `booking` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `room_id` INT UNSIGNED NOT NULL COMMENT '琴房 id', `user_id` INT UNSIGNED NOT NULL COMMENT '用户 id', `book_date` DATE NOT NULL COMMENT '预约日期', `start_time` TIME NOT NULL COMMENT '开始时间', `end_time` TIME NOT NULL COMMENT '结束时间', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-已预约 1-已完成 2-已取消 3-爽约', `remark` VARCHAR(200) DEFAULT '' COMMENT '备注', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_room_time` (`room_id`, `book_date`, `start_time`, `end_time`), KEY `idx_user_status` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约表';几个设计要点说明。
user 表的 openid 必须加唯一索引。微信小程序登录拿到的是 openid,它是用户在当前小程序下的唯一身份标识,每次登录都用它做插入或更新,重复插入会直接报错,也能防止同一个微信号注册出多个账号。
booking 表的 idx_room_time 联合索引是冲突查询的关键。冲突检测的 SQL 会按 room_id 加 book_date 过滤,再用 start_time 和 end_time 做区间比较,这个索引能让查询走 range scan 而不是全表扫描。数据量几千条时差别不明显,但到上万条后,有没有这个索引就是毫秒级和秒级的差别。
status 字段用 TINYINT 数字而不是字符串。毕业设计里很多人习惯存 'booked'、'cancelled' 这种字符串,看着直观,但索引效率和代码可读性都不如数字枚举。前后端约定好 0/1/2/3 的含义,后端定义常量类统一使用,比到处写魔法数强得多。
2.3 接口清单与鉴权方式:小程序端和服务端怎么分工
接口按资源拆,RESTful 风格就够,不需要引入复杂框架。核心接口如下。
| 接口 | 方法 | 核心入参 | 说明 |
|---|---|---|---|
| /api/auth/login | POST | code | 用 wx.login 的 code 换登录态 token |
| /api/room/list | GET | 无 | 琴房列表与开放状态 |
| /api/room/detail | GET | roomId | 琴房详情,含营业时间与可预约时段 |
| /api/booking/create | POST | roomId、bookDate、startTime、endTime | 创建预约 |
| /api/booking/mine | GET | 无 | 当前用户的预约记录 |
| /api/booking/cancel | POST | bookingId | 取消预约 |
| /api/booking/checkin | POST | bookingId | 到琴房签到 |
| /api/admin/room/save | POST | 琴房信息 | 管理员维护琴房 |
鉴权有两种常见做法。轻量一点的是后端生成随机 token 存到 Redis,设过期时间,小程序每次请求把 token 放在 Authorization 头里;重一点的是直接上 JWT,登录时签发,过期时间由服务端控制。对这个项目来说,Redis token 方案更直观,也方便管理员端做强制下线,我一般会用这种。后端用拦截器统一校验,白名单放行登录接口,其余接口都要求携带有效 token,管理员接口再额外查一次 role 字段。
3. 小程序端实现:登录态、琴房列表和预约提交的关键代码
小程序端的核心页面就三个:琴房列表页、预约页、我的预约页。这一章把代码拆开讲,从登录态到预约提交,每一步给出能直接用的片段。所有请求都走 wx.request,业务层把 url 和 data 组织好就行。
3.1 微信登录与 Token 会话管理:code 换 token 的完整链路
微信小程序的登录链路是:小程序调用 wx.login 拿到一次性 code,后端拿 code 去微信接口换 openid 和 session_key,再生成业务 token 返回给小程序。code 只能用一次且有效期很短,所以小程序端不能缓存 code,每次启动都要重新调用 wx.login。
// miniprogram/utils/auth.js const TOKEN_KEY = 'token'; function login() { return new Promise((resolve, reject) => { wx.login({ success: (res) => { if (!res.code) { reject(new Error('wx.login 未返回 code')); return; } wx.request({ url: getApp().globalData.apiBase + '/api/auth/login', method: 'POST', data: { code: res.code }, success: (resp) => { if (resp.data.code === 0) { const token = resp.data.data.token; wx.setStorageSync(TOKEN_KEY, token); resolve(resp.data.data); } else { reject(new Error(resp.data.msg || '登录失败')); } }, fail: (err) => reject(err) }); }, fail: (err) => reject(err) }); }); } function getToken() { return wx.getStorageSync(TOKEN_KEY); } module.exports = { login, getToken, TOKEN_KEY };逻辑说明:wx.login 成功后拿到 code,把它 POST 给后端,后端用 code 换 openid 后生成 token 返回。前端把 token 放进本地缓存,后续所有请求从缓存取。参数上注意 apiBase 是后端服务地址,开发阶段指向局域网 IP 加端口,正式上线必须换成已配置 HTTPS 证书的域名。
提示:token 失效时后端通常会返回 401,小程序端应该封装一个请求函数,统一拦截 401 后自动重新登录并重放原请求。这个习惯能帮你省掉大量「登录态过期」导致的现场翻车。
关于 session_key,它用于解密微信加密数据,比如手机号。琴房管理系统如果只是用 openid 做身份识别,session_key 在后端换完就可以丢弃,不用存。见过不少同学把 session_key 也写进 user 表,没有必要,除非你还要做微信手机号授权。
3.2 琴房列表与状态展示:拉取数据、渲染与下拉刷新
琴房列表页的要点是状态展示。后端返回的 room 里带 status 字段,前端要把它翻译成用户能看懂的文字和颜色,而不是直接把数字渲染到页面上。
// pages/room/room.js const { getToken } = require('../utils/auth.js'); Page({ data: { rooms: [], loading: false }, onShow() { this.fetchRooms(); }, onPullDownRefresh() { this.fetchRooms(true); }, fetchRooms(isRefresh = false) { if (this.data.loading) return; this.setData({ loading: true }); wx.request({ url: getApp().globalData.apiBase + '/api/room/list', header: { Authorization: 'Bearer ' + getToken() }, success: (res) => { if (res.data.code === 0) { const rooms = res.data.data.map((room) => ({ ...room, statusText: this.statusText(room.status), statusClass: 'room-status-' + room.status })); this.setData({ rooms }); } else { wx.showToast({ title: res.data.msg || '加载失败', icon: 'none' }); } }, complete: () => { this.setData({ loading: false }); if (isRefresh) wx.stopPullDownRefresh(); } }); }, statusText(status) { const map = { 0: '空闲', 1: '使用中', 2: '维护中' }; return map[status] || '未知'; }, goBook(e) { const roomId = e.currentTarget.dataset.id; wx.navigateTo({ url: '/pages/book/book?roomId=' + roomId }); } });对应的 WXML 片段:
<view class="room-card" wx:for="{{rooms}}" wx:key="id"> <view class="room-name">{{item.roomName}}</view> <view class="room-location">{{item.location}} · {{item.pianoType}}</view> <view class="room-status {{item.statusClass}}">{{item.statusText}}</view> <button type="primary" size="mini" bindtap="goBook">// pages/book/book.js const { getToken } = require('../utils/auth.js'); const SLOT_MINUTES = 60; const MAX_ADVANCE_DAYS = 7; Page({ data: { roomId: '', room: null, dateList: [], dateIndex: 0, slotList: [], slotIndex: -1, submitting: false }, onLoad(options) { this.setData({ roomId: options.roomId }); this.buildDateList(); this.loadRoomDetail(); }, buildDateList() { const list = []; const week = ['日', '一', '二', '三', '四', '五', '六']; for (let i = 0; i < MAX_ADVANCE_DAYS; i++) { const d = new Date(); d.setDate(d.getDate() + i); const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const label = i === 0 ? '今天' : i === 1 ? '明天' : `${d.getMonth() + 1}月${d.getDate()}日 周${week[d.getDay()]}`; list.push({ dateStr, label }); } this.setData({ dateList: list }); }, loadRoomDetail() { wx.request({ url: getApp().globalData.apiBase + '/api/room/detail', data: { roomId: this.data.roomId }, header: { Authorization: 'Bearer ' + getToken() }, success: (res) => { if (res.data.code === 0) { this.setData({ room: res.data.data }); this.buildSlotList(); } } }); }, buildSlotList() { const room = this.data.room; if (!room) return; const slots = []; const openHour = parseInt(room.openTime.slice(0, 2), 10); const closeHour = parseInt(room.closeTime.slice(0, 2), 10); for (let h = openHour; h + SLOT_MINUTES / 60 <= closeHour; h++) { const start = `${String(h).padStart(2, '0')}:00:00`; const end = `${String(h + SLOT_MINUTES / 60).padStart(2, '0')}:00:00`; slots.push({ start, end, label: `${start.slice(0, 5)} - ${end.slice(0, 5)}` }); } this.setData({ slotList: slots }); }, onDateChange(e) { this.setData({ dateIndex: Number(e.detail.value), slotIndex: -1 }); }, onSlotSelect(e) { this.setData({ slotIndex: Number(e.currentTarget.dataset.index) }); }, submit() { const { roomId, dateList, dateIndex, slotList, slotIndex, submitting } = this.data; if (slotIndex < 0) { wx.showToast({ title: '请选择时间段', icon: 'none' }); return; } if (submitting) return; this.setData({ submitting: true }); const date = dateList[dateIndex].dateStr; const slot = slotList[slotIndex]; wx.request({ url: getApp().globalData.apiBase + '/api/booking/create', method: 'POST', header: { Authorization: 'Bearer ' + getToken() }, data: { roomId: roomId, bookDate: date, startTime: slot.start, endTime: slot.end }, success: (res) => { if (res.data.code === 0) { wx.showToast({ title: '预约成功', icon: 'success' }); setTimeout(() => wx.navigateBack(), 1500); } else { wx.showToast({ title: res.data.msg || '预约失败', icon: 'none' }); } }, complete: () => { this.setData({ submitting: false }); } }); } });逻辑说明:日期生成用循环加 7 天,第一二天显示「今天」「明天」,后面的显示具体日期和星期,这个细节在演示时很加分。时间片按 room.openTime 和 closeTime 解析出小时数,循环生成整点时段。submitting 标志防止用户重复点击提交,这个在预约场景里是必须的,不然一次点击发出两个请求,就会出现「我只点了一次,怎么订了两条」。
注意一个边界:时间片生成只考虑了整点,如果琴房营业时间是 08:30 到 21:30,这种写法生成的时段会不准。稳妥的做法是后端在 room/detail 接口里直接返回可预约时间片数组,前端只负责渲染。这样营业时间、粒度、特殊日期的规则全部收敛在后端一处,前端永远不用自己去推。
4. 后端服务与冲突控制:把预约规则写成可执行的代码
后端是这类系统的重头戏。预约创建、冲突检测、超时释放这三块写好了,项目核心逻辑就立住了。这一章以 Java Spring Boot 的写法为例,其他语言思路一致,SQL 部分可以直接搬过去。
4.1 预约创建的完整流程:参数校验、冲突检测与事务
预约创建的接口不能只是简单 insert,它要按顺序做五件事:参数校验、琴房校验、营业时间校验、冲突检测、插入。任何一步失败都返回业务错误。
@Transactional(rollbackFor = Exception.class) public Booking createBooking(BookingDTO dto, Long userId) { // 1. 基本参数校验 if (dto.getStartTime().compareTo(dto.getEndTime()) >= 0) { throw new BusinessException("开始时间必须早于结束时间"); } if (dto.getBookDate().isBefore(LocalDate.now())) { throw new BusinessException("不能预约过去的日期"); } // 2. 琴房存在且开放 PianoRoom room = roomMapper.selectById(dto.getRoomId()); if (room == null || room.getIsOpen() != 1) { throw new BusinessException("琴房不存在或未开放"); } // 3. 时间必须落在琴房营业时间范围内 if (dto.getStartTime().isBefore(room.getOpenTime()) || dto.getEndTime().isAfter(room.getCloseTime())) { throw new BusinessException("预约时间超出琴房营业时间"); } // 4. 行锁 + 冲突检测 int conflict = bookingMapper.countConflictForUpdate( dto.getRoomId(), dto.getBookDate(), dto.getStartTime(), dto.getEndTime()); if (conflict > 0) { throw new BusinessException("该时间段已被预约,请选择其他时间"); } // 5. 插入预约记录 Booking booking = new Booking(); booking.setRoomId(dto.getRoomId()); booking.setUserId(userId); booking.setBookDate(dto.getBookDate()); booking.setStartTime(dto.getStartTime()); booking.setEndTime(dto.getEndTime()); booking.setStatus(BookingStatus.BOOKED); bookingMapper.insert(booking); return booking; }逻辑说明:第 1 步挡住「开始晚于结束」和「预约过去日期」两种低级错误,这类请求根本不该进到数据库。第 3 步校验营业时间,前端选了 21:30 但琴房 21:00 关门,这里直接拒绝。第 4 步是核心,countConflictForUpdate 会先锁住相关记录再统计,避免两个并发请求同时通过检查。第 5 步插入后事务提交,整个过程要么全部成功,要么全部回滚,不会出现「冲突检测通过了但插入失败」的脏状态。
注意:@Transactional 一定要加 rollbackFor = Exception.class。Spring 默认只在 RuntimeException 时回滚,抛自定义 checked 异常时事务不回滚,数据就写进去了,这个细节坑过很多人。
还有一个容易踩的坑:有的同学把冲突检测拆成独立 service 方法,然后在 createBooking 里调用。如果这个方法没有事务传播,检查完再插入的间隙,另一个请求可能已经插入了同一时段。所以检测和插入必须在同一个事务方法里,这是硬要求。
4.2 冲突检测 SQL 的正确写法:区间重叠判定与索引利用
冲突检测的 SQL 是全文最重要的几行代码。
<select id="countConflictForUpdate" resultType="int"> SELECT COUNT(*) FROM booking WHERE room_id = #{roomId} AND book_date = #{bookDate} AND status = 0 AND start_time < #{endTime} AND end_time > #{startTime} FOR UPDATE </select>逻辑说明:新预约时段是 [startTime, endTime],已有预约时段是 [start_time, end_time]。两者重叠的充要条件是:新开始时间早于已有结束时间,且新结束时间晚于已有开始时间。写成 SQL 就是 start_time < #{endTime} AND end_time > #{startTime}。再加 book_date 限定同一天,status = 0 只统计有效预约,排除已取消和已归档的记录。
反例值得单独说。有同学会写成下面这两种:
-- 反例一:只覆盖「完全包含」的情况 WHERE start_time >= #{startTime} AND end_time <= #{endTime} -- 反例二:只看已有预约的开始时间是否落在新时段内 WHERE start_time BETWEEN #{startTime} AND #{endTime}反例一漏掉了「新时段横跨已有时段」的部分重叠;反例二漏掉了「已有时段横跨新时段」的场景。实测中,这两种写法会各放走一批冲突预约,等管理员在后台看到记录重了再去手工取消,就很被动。
FOR UPDATE 是 MySQL InnoDB 的行锁语法,配合 idx_room_time 索引,它会锁住命中的索引区间。并发场景下,两个请求同时查同一琴房同一天的数据,后到的会等先到事务提交后才继续,这样冲突检测就变成串行执行,不会出现两人同时订走同一时段的情况。这个锁粒度对这个项目足够,没必要上 Redis 分布式锁。
idx_room_time 联合索引(room_id, book_date, start_time, end_time)覆盖了查询的过滤条件,MySQL 会走索引 range scan,数据量到十万级也不会慢。如果这个项目用于期末设计,这属于「可展示的性能设计点」,答辩时值得拿出来讲。
4.3 超时释放与签到:定时任务让预约状态自动流转
预约状态不能永远停在「已预约」。到了时间没来,系统要自动释放琴房并标记爽约;当天用完了,要标记完成。这些靠定时任务和签到接口配合完成。
@Component public class BookingScheduler { @Autowired private BookingMapper bookingMapper; // 每分钟跑一次:已预约但开始时间已过 15 分钟的,标记为爽约 @Scheduled(cron = "0 * * * * ?") public void markNoShow() { List<Booking> list = bookingMapper.findExpiredNoShows(); for (Booking b : list) { b.setStatus(BookingStatus.NO_SHOW); bookingMapper.updateById(b); } } // 每天凌晨 2 点:把前一天的记录批量归档为已完成 @Scheduled(cron = "0 0 2 * * ?") public void archiveYesterday() { bookingMapper.archiveByDate(LocalDate.now().minusDays(1)); } }对应的 SQL:
-- 找出已超时未签到的预约 SELECT * FROM booking WHERE status = 0 AND (book_date < CURDATE() OR (book_date = CURDATE() AND start_time < DATE_SUB(CURTIME(), INTERVAL 15 MINUTE)))逻辑说明:markNoShow 的判定条件是「开始时间已过去 15 分钟且状态仍是已预约」。这里故意留 15 分钟缓冲,学生可能因为路上耽搁晚到几分钟,直接到点就释放很容易引发投诉。archiveYesterday 是每天凌晨的兜底任务,把前一天未完成的记录批量归档。cron 表达式里,0 * * * * ?表示每分钟第 0 秒执行,0 0 2 * * ?表示每天凌晨 2 点整执行。
签到接口单独说一下。学生到琴房后点「签到」,后端要校验:该预约属于当前用户、状态是已预约、当前时间不早于预约开始时间,然后状态改成已完成。有个细节容易漏:预约明天 10:00,现在签到必须拒绝;预约今天 10:00 而现在是 10:05,允许签到。只判断「日期相等」而忽略当前时刻,会导致预约还没开始就能提前签到的笑话逻辑。
到这里,系统核心链路已经通了:前端选时间、后端挡住冲突、定时任务兜底释放。第 5 章把开发中最高发的几个问题集中排一遍雷。
5. 琴房管理系统避坑指南:5 个高发问题与排查方法
这一章按「现象 → 原因 → 解决」的格式写,都是这个项目里反复出现的问题,每一类我都见过不止一次。你如果正在调试,直接对照着查,比从头翻日志快得多。
5.1 冲突检测漏掉「部分重叠」导致重复预约
现象:A 同学约了 10:00-11:00,B 同学竟然还能约 10:30-11:30,后台出现两条重叠预约。
原因:冲突检测 SQL 写成了 start_time >= #{startTime} AND end_time <= #{endTime},只判断新时段是否完整包含在已有时段内。B 的 10:30-11:30 和 A 的 10:00-11:00 是交叉重叠:新开始 10:30 落在 A 的时段内,但新结束 11:30 超出了 A 的结束时间,条件不成立,于是被放行。
解决:改用区间重叠判定 start_time < #{endTime} AND end_time > #{startTime}。改完用三种用例验证:新时段在已有时段内部、新时段横跨已有时段、新时段与已有时段首尾相接,前两个必须返回冲突,第三个在边界恰好相切时允许通过。我一般会把这几个用例写进单元测试,防止后续改动时悄悄回归。
5.2 并发预约「超卖」:同一时段被两人同时订走
现象:两个学生同时(间隔几百毫秒)提交同一琴房同一时段的预约,两个接口都返回成功,数据库里两条记录。
原因:没有加锁的「先查询后插入」存在竞态窗口。请求 A 查到冲突数为 0,还没插入,请求 B 也查到冲突数为 0,接着两个请求都执行插入,冲突检测形同虚设。
解决:在冲突检测 SQL 末尾加 FOR UPDATE,并让检测与插入处于同一事务中。后到的请求会等待先到事务提交后再执行查询,此时冲突数变成 1,预约被拒绝。如果项目用的是 PostgreSQL,对应语法是 FOR KEY SHARE;如果不想依赖数据库锁,可以在 booking 表加唯一索引(room_id, book_date, start_time)做兜底,插入冲突时直接报错,再用异常重试逻辑处理。
5.3 预约时间凭空差 8 小时:数据库时区问题
现象:小程序端选了 10:00 提交,数据库里存的是 02:00,管理后台看到的预约时间全部不对。
原因:Java 的 LocalDateTime 和 MySQL 的 DATETIME 在时区解析上出现偏差。常见的是 JDBC 连接串里没有指定 serverTimezone,驱动用了默认时区,跟服务器系统时区不一致,导致时间偏移。
解决:在 JDBC 连接串上显式指定时区,写成 jdbc:mysql://localhost:3306/piano?serverTimezone=Asia/Shanghai。同时约定前后端传输时间一律用字符串,格式如 10:00:00,不传时间戳。TIME 类型本身不涉及时区换算,用字符串传递能避开大部分时区坑。这属于典型的「开发环境不出错、一换机器就翻车」的问题,答辩前一定要换台机器部署验证一遍。
5.4 开发者工具正常、真机请求失败:域名与 HTTPS
现象:微信开发者工具里所有接口都通,扫码到真机上一片空白,控制台报 request:fail url not in domain list。
原因:小程序真机环境强制要求后台接口走 HTTPS,且域名必须在小程序后台配置为 request 合法域名。开发工具默认不校验合法域名,本地 http 接口能通,但真机不认。
解决:开发阶段在开发者工具的详情设置里勾选「不校验合法域名、TLS 版本以及 HTTPS 证书」,这只解决本机调试。要真机完整跑通,需要准备一个已配置 HTTPS 证书的域名,在小程序后台配好 request 合法域名,再把后端接口地址从局域网 IP 换成线上域名。证书和域名校验是硬条件,没有捷径。演示视频建议在真机网络通畅时录制,别在开发工具里录,两者观感差很多。
5.5 前端选了合法时间、后端却报「超出营业时间」
现象:前端时间选择器最多到 21:00,但用户改一下请求参数就能提交 22:00 的预约;或者前端按 60 分钟生成时间片,后端允许提交 30 分钟的时段,预约粒度混乱。
原因:前端的时间组装逻辑和后端校验规则是两套代码,没有统一。用户只要绕过前端界面直接调接口,就能提交任意时间。
解决:把「可预约时段」的完整定义收敛到后端。常见做法是后端 room/detail 接口直接返回该琴房可预约的时间片数组,前端渲染时直接用,不做二次计算;后端 create 接口再做兜底校验,即使前端被改,非法时间也会被拒绝。一句话:前端做体验,后端做规则,互不越界。
6. 让项目在答辩现场不翻车:预置数据与演示路径
项目能跑和能演示是两回事。很多同学代码写完了,上台演示时现场预约、现场等数据,结果网络慢、时间对不上、页面转圈。提前预置数据是最有效的规避手段。
预置数据的核心原则:让演示路径上的每一步都有「看起来真实」的数据支撑。下面这组 SQL 是三张表的最小演示数据集。
-- 琴房 INSERT INTO piano_room (room_name, location, piano_type, is_open, open_time, close_time) VALUES ('A101', '1号楼 1层', '立式钢琴', 1, '08:00:00', '22:00:00'), ('A102', '1号楼 1层', '三角钢琴', 1, '08:00:00', '22:00:00'), ('B201', '2号楼 2层', '电子钢琴', 0, '09:00:00', '21:00:00'); -- 用户 INSERT INTO sys_user (openid, nickname, role, status) VALUES ('demo_admin_openid', '管理员', 1, 1), ('demo_student_openid', '演示学生', 0, 1); -- 预约:一条待使用、一条已取消、一条已完成 INSERT INTO booking (room_id, user_id, book_date, start_time, end_time, status) VALUES (1, 2, DATE_ADD(CURDATE(), INTERVAL 1 DAY), '10:00:00', '11:00:00', 0), (1, 2, DATE_ADD(CURDATE(), INTERVAL 1 DAY), '14:00:00', '15:00:00', 2), (2, 2, DATE_ADD(CURDATE(), INTERVAL -1 DAY), '08:00:00', '09:00:00', 1);预约数据里用 DATE_ADD(CURDATE(), INTERVAL 1 DAY) 替代写死的日期,这个细节很关键:它保证任何一天打开演示环境,数据都是「明天有预约」,不会因为时间推移全部过期。演示数据永远新鲜,这是答辩演示最省心的一条技巧。
演示路径我一般按这个顺序走:登录进首页看到三间琴房状态各异 → 点开 A101 看到明天的时段列表 → 选 10:00-11:00 提交预约成功 → 切到「我的预约」看到刚刚的记录 → 取消再重新预约 → 打开管理员视角看全部预约记录。整条路径覆盖登录、查询、创建、取消、管理五个核心功能,五分钟讲完,节奏刚好。
另一个容易忽略的点:提前把演示用的账号登录态保持住,别在现场等 wx.login 转圈。正式演示前五分钟,在演示手机上完整走一遍登录流程,确认 token 有效,再把小程序切到后台待命。现场只做操作,不做登录。
做这个项目时我最大的教训,是把「代码写完」当成「项目做完」,结果第一次联调时被冲突检测的边界条件打脸。如果你正在做这个方向,我建议把第 4 章的三种重叠用例写进测试,跑通了再谈演示。开发里多留一分敬畏,答辩时就少一分慌张。希望帮到你。
本文还有配套的精品资源,点击获取