最近刚把一套会议管理系统从需求梳理做到联调上线,后端管理用的 Java + SSM,前台展示和签到统计用 Flask 做轻量支撑,整体就是标题里说的这套白云会议管理系统。开发过程中把会议预约、审批、签到、统计这几个模块完整跑通了,源码、调试文档、部署记录和讲解视频也整理成了配套交付物。这篇博文我不打算讲那种“从零粘贴代码”的教程式写法,而是把选型思路、表结构设计、核心接口实现、进度中踩到的坑以及最终部署形态完整复述一遍。无论是手头要做企业 OA 里会议模块,还是学校实验室想搞一个会议室预约小程序,这套方案的思路都值得参考。
1. 这套系统为什么同时用SSM和Flask
1.1 需求从哪里来
需求方是一家做项目交付的公司,内部大概有三四十人,每周要开各种评审会、周例会、客户对接会。之前会议室用的是纸质登记本加一张 Excel 表格,行政每天手动维护,经常出现同一个时间段会议室被重复预约、会议快开始了才发现投影仪被借走、会后纪要找不到归档位置这类问题。他们提需求时说了几个硬性要求:
- 员工可以自己提交会议预约申请,选择会议室、时间段、参会人数
- 需要主管审批,避免随意占用黄金时段
- 会议开始前通过邮件或内部消息提醒参会人
- 会议现场要能扫码签到,行政能查看实际到场人数
- 月底自动统计各团队会议使用情况
需求不算复杂,但涉及用户体系、审批流、时间冲突判断、签到二维码、统计报表,纯做一张静态网页或者只写一个简单 CRUD 都不够用。
1.2 两套技术栈的分工和理由
技术选型的时候有几个考虑。团队对 Java 比较熟,而且公司原有的 OA 系统就是 Spring 技术栈,会议管理作为其中一个子系统,用户登录、组织架构、权限模型可以直接复用,所以后台核心用 SSM(Spring + SpringMVC + MyBatis)是最稳妥的选择。真正让我犹豫的是前端展示和统计部分。
Flask 在会议管理项目里承担三件事:第一,渲染会议室日历视图和预约表单页面;第二,生成签到二维码和统计图表;第三,提供轻量 API 给小型展示屏使用。选择 Flask 而不是纯前端框架,是因为 Python 生态里的qrcode、openpyxl、flask-cors处理扫码签到二维码和 Excel 导出非常方便,开发速度比在 Java 侧硬写快得多。
说起来有点取巧,但这是我实际用下来的感受:核心业务和权威数据放在 SSM,展示、报表、杂活交给 Flask。SSM 保证事务和权限安全,Flask 保证灵活和效率,两者之间通过 HTTP JSON 接口通信。
1.3 “SSM过时了”这件事我怎么说
总有人觉得 SSM 太老,2025 年了还在推荐 SSM 是落伍。我个人的观点是:技术选型看场景,不追新。会议管理系统这种典型的企业内部业务系统,用户量级几百人封顶,并发量很低,SSM 的稳定性、可维护性、网上资料丰富程度都是实打实的优势。
而且对于大部分做毕设、做课设、做企业内部小系统的开发者来说,SSM 的学习曲线比 Spring Cloud 那一套平滑得多,你能把 Spring 容器、SpringMVC 请求流程、MyBatis 的 SQL 管控真正吃透,后续再上手 Spring Boot 和微服务其实是降维打击。所以这篇文章里我保留 SSM 的 XML 配置和声明式事务写法,不去套 Spring Boot 的自动配置,让大家能看到最本质的那一层。
2. 会议核心流程与数据库设计
2.1 业务闭环拆解
先梳理会议从“想开会”到“归档”的完整链路,这个步骤比写代码重要得多。我把它拆成一条闭环:
提交预约申请 -> 主管审批 -> 系统自动提醒 -> 会前签到 -> 会后填写纪要 -> 月度统计归档
每一步都需要对应数据支撑。预约申请涉及用户、会议室、时间段三个维度的交叉校验;审批涉及角色权限和状态流转;提醒功能可以做成定时任务扫描会议开始前半小时的待开始会议;签到涉及二维码生成和签到记录落库;统计则围绕会议室利用率、团队会议次数、签到率这些指标做聚合查询。
2.2 三张核心表的设计
会议管理系统的表比普通 CRUD 系统要多一些,但核心就是users、room、meeting,外加一张sign_record。具体设计如下。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| users | id, username, password, real_name, dept_id, role, position | 用户表,角色区分员工和管理员/审批人 |
| room | id, room_name, capacity, facility_ids, location, status | 会议室表,facility_ids 存投影、白板等设备 id 拼接 |
| meeting | id, title, content, organizer_id, room_id, start_time, end_time, status, approver_id, create_time | 会议预约主表,status 表示当前状态 |
| sign_record | id, meeting_id, user_id, sign_time, gps_location | 签到记录,按会议维度记录实际到场人 |
meeting表里的status字段是整套系统的核心状态机。我定义成:0已取消,1待审批,2审批通过,3进行中,4已结束,5已被驳回。实际运行中发现,状态字段必须配合时间逻辑一起判断。比如 2(审批通过)和 3(进行中)不是靠哪个接口手动改的,而应该定时任务根据start_time和end_time自动推进,避免因为操作员忘记点“开始会议”导致统计错乱。
另外会议室表不一定只有物理房间,还可以在 room 表里加一条room_type字段,区分线下会议室和线上虚拟会议室,方便疫情期间或跨地域团队使用。我实际做的时候把虚拟会议室也纳入了预约逻辑,只是location字段改成一个会议链接前缀。
2.3 会议时间冲突判断与兜底
会议室冲突判断是整个系统正确性的命门,也是最容易被新手写错的地方。判断逻辑其实很简单:新预约的时间段与已有有效预约的时间段存在交集,就是冲突。
这里的 SQL 写成:
SELECT * FROM meeting WHERE room_id = #{roomId} AND status IN (1, 2, 3) AND start_time < #{endTime} AND end_time > #{startTime}条件里特别注意status IN (1, 2, 3):待审批、审批通过、进行中的预约都要算作占用;已结束、已取消、已驳回的不算占用。很多人会漏掉状态过滤,把一个已经驳回的申请也算作冲突,导致会议室明明空着却无法预约。
另一个容易忽略的是,会议开始时间和结束时间的边界。比如某会议室 10:00-11:00 已有人预约,新人想约 11:00-12:00,理论上是允许的。所以判断条件里的start_time < #{endTime}和end_time > #{startTime}用的是严格小于、严格大于,而不是小于等于、大于等于,避免后一个预约恰好在整点开始时被误判为冲突。这块我在测试阶段反复验证过,边界案例的覆盖测试一定要做足。
3. SSM端的权限与预约实现
3.1 工程结构与分层
SSM 侧按照标准的 Controller-Service-Mapper 三层结构组织。Controller 层只负责接收参数和返回 JSON,Service 层写业务规则,Mapper 层写 SQL 和数据库交互。我习惯在工程里再拆一个dto包放接口参数对象,一个vo包放视图返回对象,避免 Map 满天飞,也让后面的接口文档更好写。
用 Maven 管理依赖,核心依赖就这么几个:
- spring-webmvc、spring-jdbc、spring-tx
- mybatis、mybatis-spring
- mysql-connector-java
- jackson-databind(处理 JSON)
- jstl(如果还涉及后端渲染 JSP)
web.xml里配置 DispatcherServlet 和字符编码过滤器;spring-mvc.xml配组件扫描、注解驱动、视图解析器;spring-mybatis.xml配数据源、SqlSessionFactory、MapperScannerConfigurer;spring-service.xml配事务管理器。
3.2 预约接口的事务与冲突校验
预约申请是针对meeting表的插入操作,但插入前必须做一整套校验。实际开发中我把校验逻辑写进了 Service 层,因为校验要同时查用户表、查会议室表、查预约冲突,任何一步失败都不能写入数据。
@Override @Transactional(rollbackFor = Exception.class) public void applyMeeting(MeetingApplyDTO dto) { // 1. 校验会议室存在且未被禁用 Room room = roomMapper.selectById(dto.getRoomId()); if (room == null || room.getStatus() == 0) { throw new BizException("会议室不存在或已被禁用"); } // 2. 校验时间范围合法性 if (dto.getStartTime().isAfter(dto.getEndTime())) { throw new BizException("会议开始时间不能晚于结束时间"); } if (dto.getStartTime().isBefore(LocalDateTime.now())) { throw new BizException("会议开始时间必须晚于当前时间"); } // 3. 冲突校验 int conflictCount = meetingMapper.countConflict( dto.getRoomId(), dto.getStartTime(), dto.getEndTime()); if (conflictCount > 0) { throw new BizException("该会议室在选定时间段已被占用,请调整时间或选择其他会议室"); } // 4. 校验开会时间不能超过会议室容量 if (dto.getAttendeeCount() > room.getCapacity()) { throw new BizException("参会人数超过会议室容量"); } // 5. 组装实体并插入 Meeting meeting = new Meeting(); meeting.setTitle(dto.getTitle()); ... meetingMapper.insert(meeting); }这里有个重要细节:@Transactional的rollbackFor必须写成Exception.class,不能只写RuntimeException.class。因为自定义的BizException如果继承的是Exception,默认情况下 Spring 事务只在碰到运行时异常时回滚,选中自定义业务异常会导致数据脏写。我见过不止一个项目栽在这个细节上,会议预约插入了,但回滚没有执行,冲突预约就莫名多了一条。
3.3 管理端的权限拦截与分页查询
审批、修改会议室、查看用户列表这些操作一定要有权限控制,否则任何普通员工都能把别人预约的会议驳回,系统就乱套了。我在 SpringMVC 中用了最简单的HandlerInterceptor方式实现权限过滤。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } // 需要管理权限的接口,通过请求路径中的 /admin/ 判断 if (request.getRequestURI().contains("/admin/") && !"admin".equals(loginUser.getRole())) { response.setStatus(403); return false; } return true; } }在spring-mvc.xml里注册拦截器,拦截/meeting/*、/admin/*这些路径,登录页和静态资源放行。
管理端最常用的功能是会议列表检索和审批。列表检索涉及多条件组合查询:按标题模糊搜索、按时间范围、按状态、按申请人部门,而且必须分页。MyBatis 里做一个动态 SQL:
<select id="selectMeetingPage" resultType="meeting" parameterType="map"> SELECT * FROM meeting <where> <if test="title != null and title != ''"> AND title LIKE CONCAT('%', #{title}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="startTime != null"> AND start_time >= #{startTime} </if> <if test="endTime != null"> AND end_time <= #{endTime} </if> <if test="deptId != null"> AND organizer_id IN (SELECT id FROM users WHERE dept_id = #{deptId}) </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>注意 XML 里>和<符号不能直接写,必须用>和<转义,否则 XML 解析会报错。分页参数offset的计算放在 Service 层完成:offset = (pageNum - 1) * pageSize。
审批操作则是更新会议状态:待审批 -> 通过/驳回,附带审批意见。审批通过后再触发邮件提醒逻辑,这一步我放在审批接口内同步调用,因为这个项目邮件服务做成了异步线程池,不会阻塞审批响应;如果邮件服务容易超时,建议用 MQ 或者定时任务扫状态变化,不要在主线程里阻塞。
4. Flask侧的页面、签到与统计
4.1 为什么展示层放在 Flask
项目里 Flask 服务和 SSM 服务是独立部署的两套程序,共用同一个 MySQL 数据库。Flask 直接读取数据库做展示层渲染,也可以通过 HTTP 调用 SSM 的接口做写操作。为了减少跨服务调试的复杂度,我做了个约定:Flask 只做读操作和签到写操作,涉及审批、预约、会议室增删改的写操作一律走 SSM 接口。
Flask 端用蓝图组织代码:
# app.py from flask import Flask from blueprints.views import view_bp from blueprints.api import api_bp app = Flask(__name__) app.register_blueprint(view_bp) app.register_blueprint(api_bp)view_bp负责页面渲染:会议室列表页、房间详情页、签到页面;api_bp负责返回 JSON 数据:统计接口、签到记录接口、日历视图接口。模板用 Jinja2,样式用最简单的基础 CSS,没有引入前端工程化框架,因为展示端本身不复杂,保持轻量即可。
4.2 扫码签到的实现链路
签到是会议管理系统里最有仪式感的一个功能,也是实际落地中最容易被现场情况打脸的环节。我实现的链路是:
- 会议审批通过后,系统自动为这个会议生成一个唯一签到码
sign_code - 签到码参与生成二维码,二维码内容是一个 URL:
https://[域名]/sign/{sign_code} - 参会人扫码后访问这个 URL,Flask 端校验当前用户登录态,若未登录则先跳登录
- 校验开会时间:开始前 30 分钟到结束后 60 分钟内允许签到
- 写入
sign_record,会议首页的签到人数加一
Flask 生成二维码用qrcode库,几行代码就能搞定:
import qrcode import qrcode.image.svg def generate_qrcode(sign_code): img = qrcode.make(f"https://meeting.example.com/sign/{sign_code}") buffer = io.BytesIO() img.save(buffer, format="PNG") return buffer.getvalue()二维码生成后存到服务器本地静态目录,URL 记录在meeting表的qrcode_path字段。特别注意:不要把整个会议 id 明文放进二维码,连续 id 容易被人遍历,全部会议的签到入口都暴露了。用单独的sign_code(UUID 或随机字符串)更安全。
签到页面在扫码后要展示会议名称、会议室、时间、当前签到人数,并提示每个人只允许签到一次。防重复签到的逻辑靠数据库唯一索引兜底:sign_record表加UNIQUE(meeting_id, user_id),就算前端按钮连点或者有人重复扫码,数据库层也会拒绝重复写入。
4.3 统计接口与数据报表
做统计是因为行政月底需要向领导汇报会议室使用情况。我用 Flask 写了几个聚合接口:
- 会议室利用率:某段时间内会议室被占用的时长 / 总时长
- 部门会议次数:按 organizer 所属部门分组统计
- 签到率:实际签到人数 / 应到人数,应到人数按预约时的
attendee_count计算
签到率的统计 SQL 写出来大概是:
@app.route("/api/stat/attendance") def attendance_stat(): start = request.args.get("start") end = request.args.get("end") sql = """ SELECT m.id, m.title, m.attendee_count AS expected, COUNT(DISTINCT s.user_id) AS actual, ROUND(COUNT(DISTINCT s.user_id) / m.attendee_count * 100, 1) AS rate FROM meeting m LEFT JOIN sign_record s ON s.meeting_id = m.id WHERE m.status = 4 AND m.start_time >= %s AND m.end_time <= %s GROUP BY m.id """ result = query_db(sql, (start, end)) return jsonify(result)统计接口的数据量不大,直接在 Python 里拼 SQL 完全可以。但如果会议量大,建议把统计逻辑放到 SSM 侧定期生成汇总报表,Flask 只管查汇总结果。这个优化点我写在部署章节里详细讲。
5. 从开发到上线的踩坑记录
5.1 “明明是下午3点,数据库存成了凌晨3点”
这是我联调时第一个遇到的诡异问题。页面选了 15:00 开始,数据库里存的却是03:00:00,等于时间被扣掉了 12 小时。排查半天发现不是前端传参问题,而是 JDBC 连接字符串里没有指定时区。
MySQL 的serverTimezone参数默认是服务器时区,而 Java 应用的时区和数据库服务器时区不一致时,时间转换就会出偏差。解决方式很简单,在 JDBC URL 上加时区参数:
jdbc:mysql://localhost:3306/meeting_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false顺带说一下,MySQL 8.x 的驱动com.mysql.cj.jdbc.Driver必须配置serverTimezone,否则启动就会报错;5.x 旧驱动虽然能跑,但容易出现这种隐蔽的时间偏移问题。
5.2 MyBatis里的时间比较:小于号必须转义
写动态 SQL 做冲突判断的时候,XML 里直接写了start_time < #{endTime},结果 MyBatis 解析 XML 直接报错:The content of elements must consist of well-formed character data。原因很简单,XML 中<是特殊字符,必须写成<,>写成>。
这个坑修一次很简单,但要注意项目中其他 XML 映射文件里,凡是涉及时间、数值比较的 SQL 都可能有同样的隐患。我后来写 SQL 前先在编辑器里检查一遍,凡是<和>都改成转义符,或者尽量用BETWEEN ... AND ...、>=、<=这样的写法规避掉特殊字符解析问题。
5.3 Flask跨域访问与CSRF
Flask 端页面里用 AJAX 调用 SSM 接口时,浏览器会拦住跨域请求。
我最初的方案是给 Flask 加flask-cors让后端支持跨域:
from flask_cors import CORS CORS(app, supports_credentials=True)这个方法能做跨域访问,但 Session 跨域共享比较麻烦。后来我调整了架构,前端页面大部分数据直接从 Flask 获取,Flask 再作为后端代理去调 SSM,这样浏览器看到的是同一个域名下的请求,没有跨域问题。写操作接口则全部放在 SSM 侧,管理员后台直接访问 SSM 的 Controller 地址,不走 Flask 转发。
这个调整很关键。如果执意让浏览器直接跨域调 SSM 接口,还要处理 token 传递、CSRF 防护,复杂度上升不少。架构上多一层 Flask 代理,逻辑一下子清楚了很多。
5.4 并发预约同一会议室的兜底
两台电脑同时提交同一会议室的相同时间段预约单,Service 层的冲突校验能挡住吗?实际测试发现挡不住:两个请求几乎同时拿到“当前无冲突”的查询结果,然后都往里面插数据,最终同一时间段出现两条有效预约。
解决思路是数据库层面的并发控制。我用的是最简单可靠的悲观锁方案:在事务里先SELECT * FROM room WHERE id = ? FOR UPDATE,锁住会议室记录,这样第二个预约请求的查询会被阻塞,只能等第一个事务提交后再判断冲突,自然就被校验住了。
// 在冲突校验之前加锁 Room lockRoom = roomMapper.selectByIdForUpdate(dto.getRoomId());把selectByIdForUpdate映射成带FOR UPDATE的 SQL:
<select id="selectByIdForUpdate" resultType="room"> SELECT * FROM room WHERE id = #{id} FOR UPDATE </select>注意FOR UPDATE只有在事务内才有效,所以这个方法必须保证在@Transactional的方法里被调用。这个兜底方案在小并发场景下足够稳,如果会议室规模很大、预约频率很高,再考虑引入 Redis 分布式锁,但当前项目规模完全没必要。
6. 部署结构、优化思路和最后建议
6.1 Nginx + Tomcat + Gunicorn的部署结构
整个系统最终部署在一台 Linux 服务器上,软件结构是 Nginx 做统一入口,Tomcat 跑 SSM 后端,Gunicorn 跑 Flask 服务。
部署路径有两条:
- 管理员访问
域名/admin开头路径,Nginx 转发到 Tomcat 的 8080 端口,走 SSM 的 Controller - 普通员工访问
域名/,Nginx 转发到 Gunicorn 监听的 5000 端口,由 Flask 渲染页面
Nginx 配置核心片段:
server { listen 80; server_name meeting.example.com; location /admin/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { proxy_pass http://127.0.0.1:5000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }Gunicorn 启动命令:
gunicorn -w 2 -b 127.0.0.1:5000 app:appSSM 项目通过 Maven 打成 war 包,扔到 Tomcat 的webapps目录里即可。数据库 MySQL 单独部署,用 UTF-8 字符集,配置文件统一管理,避免不同环境连接串不一致导致时区或编码问题。
6.2 性能和安全优化
系统量级不大,但日常使用也有需要优化的地方:
第一,给meeting表加复合索引。查询最频繁的条件是会议室和时间段,索引可以建在(room_id, start_time, end_time)上。加了索引之后,列表页和冲突校验的 SQL 都从全表扫描变成索引查找,体验提升明显。
第二,sign_record表加上meeting_id和user_id的联合唯一索引,既能防重复签到,也能让按会议查签到结果的查询走索引。
第三,定时任务清理过期数据。每天凌晨把状态为待审批、但开始时间已过的会议自动标记为“已过期”或“已驳回”,避免历史脏数据干扰统计。同时定期清理三个月以前的签到明细,归档到历史表,减小主表体积。
安全上,MyBatis 的#{}预编译天然防 SQL 注入,但要注意动态 SQL 里别写成${},${}是字符串拼接,存在注入风险。账号密码必须哈希存储,我用的是 BCrypt。管理员操作日志也要落库,防止误操作后无法追溯。
6.3 最后给你的建议
开发这套系统花了不少时间,回头再看,有几个经验确实值得写出来给后来者参考。
第一,表结构设计时一定要把状态字段想周全。会议不是只有“预约成功”和“预约失败”两个状态,中间还有审批、进行中、结束、取消、驳回这些细粒度状态。状态定清楚了,后面写统计逻辑、写定时任务、写日历展示都会顺畅很多。
第二,SSM 和 Flask 两套服务联调时,JSON 字段命名要提前约定好。最好是统一用驼峰命名,Java 侧用@JsonProperty或直接映射,Python 侧字典取值也不容易出错。别管大驼峰还是小驼峰,全团队统一就行,否则联调阶段会耗费大量时间在字段名对不上这件事上。
第三,别嫌弃技术老,重点是功能闭环是否完整可用。会议管理系统本质上就是个业务系统,稳定、可靠、能解决实际问题才是第一位的。把 SSM 的事务、MyBatis 的 SQL 控制、Flask 的轻量开发吃透,这些经验在任何项目里都能复用。
这套系统的源码、调试文档、讲解演示都整理成了完整交付包,感兴趣的朋友可以对照着文章里的表结构和接口思路动手改一版自己的会议管理系统。如果你正卡在某个环节,比如冲突判断写成死循环、Flask 跨域调不通、签到二维码扫出来打不开,欢迎留言交流。