简介:这份资源是面向计算机专业学生与Java开发初学者的毕业设计级项目源码,基于微信小程序与SSM框架实现会议发布与预约系统,可解决会议信息分散、预约流程繁琐、参会管理低效等问题。压缩包共1139个文件,约14.9MB,涵盖95个Java后端源码、121个Vue管理端组件、159个JavaScript脚本,以及78个wxss与76个wxml小程序页面文件,另含86个json配置、2个sql建表脚本和若干图片资源,前后端与数据库结构完整。系统分为小程序端与后台管理端:小程序端提供注册登录、会议浏览、详情查看、预约申请与提醒通知;后端基于Spring MVC设计RESTful接口,结合MyBatis完成会议、预约、用户等数据持久化,管理员可发布会议、审批预约并统计参会名单。目前已有89人学习,适合作为课程设计、毕业设计参考或SSM与小程序联调的实战范例,便于快速理解项目分层与接口交互逻辑。
1. 会议预约这件事,为什么用小程序加 SSM 做反而最稳
很多团队一开始想得很简单:会议室就那几间,拉个共享表格让大家填,谁先写谁用。真跑两周就崩——时间冲突没人拦、临时改期没人通知、月底统计谁用了多少次全靠人肉翻记录。问题不在工具,在于「发布」和「预约」这两个动作天然需要状态约束:同一时段只能被一个人占住,发布者要能改要能撤,参与者要能查要能退。
微信小程序加 SSM(Spring + SpringMVC + MyBatis)这套组合,恰好卡在一个很舒服的位置。小程序负责「人随时在手机上点两下」的入口体验,SSM 负责「并发下不超卖、状态可追溯」的后端逻辑。它不追求高并发大流量,但把会议发布、时段预约、冲突校验、状态流转这几件事做扎实,对中小团队、实验室、公司内部会议室管理来说,落地成本低、可控性强。这篇就按我实际搭过一遍的顺序,把选型理由、表结构、接口、冲突校验和踩过的坑讲清楚,新手能照着跑,熟手能直接看边界参数。
2. 先把数据模型定死:会议、时段、预约三张表怎么切
动手写代码之前,表结构没定清楚,后面接口会反复改。我一般先把「会议」和「预约」拆开:会议是发布出来的内容,预约是某个人对某个会议某个时段的占用。中间再插一张「时段表」,是因为一场会议可能拆成多个可预约的时间片,比如上午场、下午场分开约。
2.1 三张核心表与字段取舍
先看会议表。它承载发布信息,字段要能支撑「草稿、已发布、已结束」三种状态。
CREATE TABLE meeting ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT '会议标题', description VARCHAR(500) DEFAULT NULL COMMENT '会议说明', location VARCHAR(100) NOT NULL COMMENT '会议地点', publisher_id BIGINT NOT NULL COMMENT '发布人ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已发布 2已结束', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_publisher (publisher_id), INDEX idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;时段表是冲突校验的落点。每个时段有明确的开始和结束,预约只能挂在时段上,不能直接挂会议,这样校验范围才收得住。
CREATE TABLE meeting_slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, meeting_id BIGINT NOT NULL COMMENT '所属会议', start_time DATETIME NOT NULL COMMENT '时段开始', end_time DATETIME NOT NULL COMMENT '时段结束', capacity INT NOT NULL DEFAULT 1 COMMENT '可预约人数', booked INT NOT NULL DEFAULT 0 COMMENT '已预约人数', INDEX idx_meeting (meeting_id), INDEX idx_time (start_time, end_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;预约表记录「谁约了哪个时段」,并且用唯一索引兜住重复预约。
CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, slot_id BIGINT NOT NULL COMMENT '时段ID', user_id BIGINT NOT NULL COMMENT '预约人ID', status TINYINT NOT NULL DEFAULT 1 COMMENT '1已预约 2已取消', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_slot_user (slot_id, user_id), INDEX idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个取舍要讲清楚:capacity和booked放在时段表上,是为了让「还剩几个名额」这种高频查询不用去 count 预约表。代价是每次预约成功都要同步更新booked,所以必须放在同一个事务里,否则会出现名额对不上的玄学问题。
2.2 状态字段为什么不用布尔值
会议状态用TINYINT而不是is_published布尔值,是因为真实流程里「草稿」和「已结束」是两种完全不同的不可预约状态。用布尔值你迟早要再加一个字段,不如一开始就用枚举。预约状态同理,取消不是删除记录,而是把status改成 2,这样统计和追溯都还在。我见过有人直接物理删除预约记录,结果用户说「我明明约过」,后台查无此据,只能吃哑巴亏。
2.3 索引怎么加才不白加
meeting_slot上的idx_time是给冲突校验用的,查询「某时间段内是否已有会议」会走这个索引。reservation上的uk_slot_user是唯一索引,它同时承担两个职责:防重复预约,以及给「查某人是否约过某时段」提供索引。注意唯一索引里带了status吗?没有。这意味着用户取消后再次预约同一时段会撞唯一键。常见做法是把唯一键改成(slot_id, user_id, status)或者在取消时做逻辑处理,我一般选前者,简单直接。
3. 后端接口怎么排:发布、列表、预约、取消四步走
SSM 里 Controller 层负责收参和返回,Service 层扛事务和校验,Mapper 层只管 SQL。这个分层不是形式主义,是因为冲突校验和名额扣减必须在一个事务里,放 Controller 会写乱。
3.1 发布会议接口与时段批量写入
发布会议时,会议主体和时段是一起提交的。前端传一个会议对象加一个时段数组,后端先插会议拿到自增 ID,再批量插时段。
@RestController @RequestMapping("/api/meeting") public class MeetingController { @Autowired private MeetingService meetingService; @PostMapping("/publish") public Result publish(@RequestBody MeetingPublishDTO dto) { // 参数基础校验:标题、地点、时段不能为空 if (dto.getTitle() == null || dto.getSlots() == null || dto.getSlots().isEmpty()) { return Result.fail("标题和时段不能为空"); } // 交给 Service 处理事务:插会议 + 批量插时段 Long meetingId = meetingService.publish(dto); return Result.ok(meetingId); } }Service 层用@Transactional包住两次写入,任何一步失败整体回滚。
@Service public class MeetingServiceImpl implements MeetingService { @Autowired private MeetingMapper meetingMapper; @Autowired private MeetingSlotMapper slotMapper; @Override @Transactional(rollbackFor = Exception.class) public Long publish(MeetingPublishDTO dto) { Meeting meeting = new Meeting(); meeting.setTitle(dto.getTitle()); meeting.setLocation(dto.getLocation()); meeting.setPublisherId(dto.getPublisherId()); meeting.setStatus(1); // 直接发布 meetingMapper.insert(meeting); for (SlotDTO s : dto.getSlots()) { // 校验时段合法性:结束必须晚于开始 if (!s.getEndTime().after(s.getStartTime())) { throw new BizException("时段结束时间必须晚于开始时间"); } MeetingSlot slot = new MeetingSlot(); slot.setMeetingId(meeting.getId()); slot.setStartTime(s.getStartTime()); slot.setEndTime(s.getEndTime()); slot.setCapacity(s.getCapacity()); slotMapper.insert(slot); } return meeting.getId(); } }参数说明:rollbackFor = Exception.class是为了让受检异常也回滚,默认只回滚运行时异常,这个坑我踩过,当时时段插了一半失败,会议却留在库里成了脏数据。status直接设 1 是简化流程,如果要草稿功能,改成先存 0,再单独提供发布接口。
3.2 预约接口的并发控制
预约是整套系统里唯一有并发风险的地方。两个人同时点「预约」,如果先查再改,中间有窗口期,会超卖。我的做法是在 SQL 层用条件更新,把校验和扣减合成一条语句。
<update id="bookSlot"> UPDATE meeting_slot SET booked = booked + 1 WHERE id = #{slotId} AND booked < capacity </update>Mapper 返回受影响行数,Service 判断是否为 1。
@Override @Transactional(rollbackFor = Exception.class) public void reserve(Long slotId, Long userId) { // 先尝试占名额,条件更新保证不超卖 int affected = slotMapper.bookSlot(slotId); if (affected == 0) { throw new BizException("该时段名额已满"); } // 再插预约记录,唯一索引兜住重复预约 try { Reservation r = new Reservation(); r.setSlotId(slotId); r.setUserId(userId); reservationMapper.insert(r); } catch (DuplicateKeyException e) { // 重复预约,事务回滚,名额自动还回去 throw new BizException("你已预约过该时段"); } }逻辑说明:bookSlot的WHERE booked < capacity是关键,它让数据库在行锁层面保证不会超卖。如果先SELECT再UPDATE,两个请求可能都读到booked=0,然后都加一,变成 2。参数上capacity默认 1,会议室场景通常就是 1,多人会议可以调大。DuplicateKeyException捕获后抛业务异常,事务回滚会把刚才加的名额还回去,这就是为什么两步必须在同一事务里。
3.3 取消预约与名额归还
取消不是删记录,是改状态加归还名额。
@Override @Transactional(rollbackFor = Exception.class) public void cancel(Long slotId, Long userId) { // 只允许取消自己已预约的记录 int affected = reservationMapper.cancel(slotId, userId); if (affected == 0) { throw new BizException("没有可取消的预约"); } // 归还名额 slotMapper.releaseSlot(slotId); }对应的 SQL:
<update id="cancel"> UPDATE reservation SET status = 2 WHERE slot_id = #{slotId} AND user_id = #{userId} AND status = 1 </update> <update id="releaseSlot"> UPDATE meeting_slot SET booked = booked - 1 WHERE id = #{slotId} AND booked > 0 </update>status = 1这个条件很重要,防止重复取消把名额减成负数。booked > 0是第二道保险。这两条一起用,基本不会出现名额错乱。
4. 小程序端怎么接:请求封装、时间格式与状态渲染
小程序端不复杂,但有几个细节不注意就会翻车。我一般先封装一层请求,把 token、错误提示、loading 统一处理掉,页面里只关心业务数据。
4.1 请求封装与统一错误处理
// utils/request.js const BASE_URL = 'https://your-domain.com/api'; function 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', 'token': wx.getStorageSync('token') || '' }, success(res) { // 后端统一返回 { code, msg, data } if (res.data.code === 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); } }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); } module.exports = { request };逻辑说明:code === 0是约定的成功码,非 0 直接弹提示并 reject,页面里就不用每个请求都写错误分支。token从本地缓存取,登录后写入。注意content-type用application/json,SSM 那边@RequestBody才能正常解析,用表单格式会接不到参数,这个坑很常见。
4.2 时间格式的坑与处理
后端返回的DATETIME序列化成 JSON 后,常见是时间戳或者yyyy-MM-dd HH:mm:ss字符串。小程序new Date()对带横杠的字符串在部分机型上解析失败,这是血泪经验。稳妥做法是后端统一返回时间戳,前端自己格式化。
// utils/format.js function formatTime(ts) { const d = new Date(ts); const pad = n => (n < 10 ? '0' + n : n); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + `${pad(d.getHours())}:${pad(d.getMinutes())}`; } module.exports = { formatTime };参数说明:ts是毫秒时间戳。如果后端给的是秒级,记得乘 1000。我一般让后端在 DTO 里直接转成毫秒,前端不做单位猜测,少一个出错点。
4.3 预约按钮的状态渲染
列表页每个时段要显示「可预约 / 已满 / 已预约」三种状态,按钮行为不同。
// 根据时段数据计算按钮状态 function slotState(slot, myReservedIds) { if (myReservedIds.includes(slot.id)) return 'reserved'; // 已预约 if (slot.booked >= slot.capacity) return 'full'; // 已满 return 'available'; // 可预约 }逻辑说明:myReservedIds是当前用户已预约的时段 ID 数组,进页面时单独查一次。不要靠booked判断自己是否约过,因为名额可能被别人占满,你约过但显示「已满」就矛盾了。状态渲染和按钮点击要分开处理,reserved状态点进去是取消,available是预约,full置灰。
5. 避坑与排查:那些让我加班到深夜的问题
这一章全是实际踩过的坑,每条按现象、原因、解决写。你如果正在做类似系统,大概率会撞上其中两三个。
5.1 预约成功但名额没减,或者减了两次
现象:用户约上了,但时段显示还剩名额;或者取消一次,名额减了两次。
原因:名额更新和预约记录不在同一事务,或者@Transactional没生效。常见是 Service 内部方法自调用,代理失效,事务根本没开。
解决:确认@Transactional加在 public 方法上,且调用方是通过 Spring 注入的 Bean 调用的,不是this.自调用。另外检查数据库引擎是不是 InnoDB,MyISAM 不支持事务,这个坑很隐蔽。
5.2 并发下还是超卖了一个
现象:压测时booked超过了capacity。
原因:bookSlot的 SQL 写成了先查后改,或者隔离级别设成了读未提交。
解决:坚持用UPDATE ... WHERE booked < capacity这种条件更新,让数据库行锁兜底。隔离级别用默认的可重复读即可,不要为了「性能」去调低。如果分库分表了,条件更新跨库会失效,那就得用分布式锁,但中小系统没必要走到那一步。
5.3 小程序请求后端一直 401
现象:登录后调接口还是提示未授权。
原因:token 没带上,或者后端拦截器校验的 header 名和前端写的不一致。
解决:前端封装里统一加 header,后端拦截器打印一下收到的 header 名。常见是前端写token,后端读Authorization,对不上。另外检查 token 有没有过期,过期了要引导重新登录,而不是一直弹 401。
5.4 时段跨天导致冲突校验漏判
现象:晚上 23:00 到次日 01:00 的时段,和另一个 00:00 到 02:00 的时段没检测出冲突。
原因:冲突校验只比了日期部分,或者用了字符串比较时间。
解决:时间一律用DATETIME或时间戳存,比较用start_time < other_end AND end_time > other_start这个标准区间重叠公式。不要用BETWEEN,它处理不了跨天和边界相等的情况。这个公式我用了很多次,稳。
5.5 发布会议后列表不刷新
现象:发布成功返回列表页,新会议没出现。
原因:小程序页面onShow里没重新拉数据,或者用了缓存没清。
解决:列表页在onShow里重新请求,不要只在onLoad里拉一次。如果做了本地缓存,发布成功后主动清掉对应缓存键。这个不算大问题,但用户会觉得「系统有 bug」,体验分扣得冤枉。
6. 进阶技巧:把冲突校验做成可复用的时间区间工具
基础功能跑通后,真正拉开差距的是冲突校验的健壮性。我一般会把它抽成一个独立工具类,不依赖具体业务表,输入一组已有区间和一个待校验区间,返回是否冲突。这样会议时段、会议室占用、甚至人员排班都能复用。
public class TimeRangeUtil { /** * 判断两个时间区间是否重叠 * 重叠条件:start1 < end2 且 end1 > start2 */ public static boolean isOverlap(Date start1, Date end1, Date start2, Date end2) { return start1.before(end2) && end1.after(start2); } /** * 校验待插入区间是否与已有区间列表冲突 */ public static boolean hasConflict(Date newStart, Date newEnd, List<TimeRange> existing) { for (TimeRange r : existing) { if (isOverlap(newStart, newEnd, r.getStart(), r.getEnd())) { return true; } } return false; } }参数说明:isOverlap用的是严格小于和严格大于,意味着首尾相接(一个结束等于另一个开始)不算冲突,这符合会议室实际使用——上一场 10:00 结束,下一场 10:00 开始是合理的。如果你希望留出缓冲时间,把before改成!after并加个缓冲分钟数即可。hasConflict遍历已有区间,数据量大时可以先把已有区间按开始时间排序,再用二分查找优化,但一般会议场景几十条数据,遍历足够。
再进一步,可以把「同一会议室同一时段只能有一场会议」这个约束做成数据库层的排他检查。MySQL 没有原生的区间排他约束,但可以在插入前用SELECT ... FOR UPDATE锁住相关行,或者干脆在应用层用上面这个工具加事务串行化。我一般选应用层加条件更新,简单可控。
验证方法上,我会写一组边界用例:完全重叠、部分重叠、首尾相接、包含关系、跨天。跑通这五种,冲突校验基本就不会出问题了。这个习惯是从一次线上事故后养成的——当时一个跨天时段没测到,导致两场会议撞在同一间会议室,现场尴尬得不行。从那以后,时间相关的逻辑我一定先写边界用例再写实现。希望帮到你。
本文还有配套的精品资源,点击获取