简介:面向高校计算机相关专业学生与数据库课程设计指导教师,这份压缩包围绕“教室管理系统”Web大作业,提供一套可运行的数据库应用课程设计实例,适合用来理解从E-R建模、关系模式设计到前端页面与后端数据交互的完整流程,也可通过实体关系梳理教室、教师、学生、课程等对象,作为课程设计选题与答辩演示的参考。包内共39个文件,约1.94MB,以8个JSP页面、4个CSS与4个JS前端资源为主,辅以Java源码、工程配置、图标字体以及SQL Server的MDF/LDF数据库文件,另有设计说明文档,便于对照表结构、页面模块和业务逻辑进行学习与二次修改。该资源已有2113人学习,内容覆盖教室与课程信息管理、用户界面搭建、MVC分层、SQL查询与并发访问等课程重点,也能为课程设计报告、功能扩展和调试排错提供可参考的目录组织与实现思路。
1. 教室管理系统这门数据库课程设计,难住人的从来不是 SQL 语法
很多人接到「数据库课程设计 + 教室管理系统 + web 大作业」这个组合,第一反应是先背一遍增删改查,然后打开客户端建两张表就动手写页面。真开始写才发现,卡住的地方不是 select 写不出来,而是「周三下午第二节,同一间教室已经被排了课,学生又提交了一条借用申请,系统该不该拦、在哪一层拦、拦完给什么提示」。这类业务规则如果没落到表结构和事务上,后面每个页面都在打补丁。
这套方案围绕教室资源、排课计划、借用审批三条主线展开,把数据建模、接口分层、页面联调和验收答辩串成一条完整链路。适合正在做数据库课设、要交 Web 大作业、又不想只交一个登录注册 Demo 的人。它会告诉你哪张表该加唯一索引,哪段逻辑必须放进事务,以及答辩时怎么用一条 SQL 证明你的设计是对的。
2. 需求与数据建模:从角色边界到 E-R 图落地
2.1 三类角色各自的动作与不能碰的边界
教室管理系统的角色不需要设计得太复杂,学生、教师、管理员三类就够,但每类的动作边界必须一开始就划清楚,否则权限表会反复改。
学生能做的事:按时间段和人数查空闲教室、提交借用申请、查看自己申请的审批进度、取消还没审批的申请。教师除此之外还能替自己带的课程申请固定教室。管理员负责教室基础信息维护、审批借用申请、导出使用统计。没有任何角色可以直接改排课记录,排课只能新增或撤销,这是一条很关键的约束,它保证了排课表里的数据永远是可追溯的。
把这张动作边界表写进需求文档,后面接口的权限注解、页面的按钮显隐全都有依据。我一般会在需求阶段就把它列成一张「角色 × 动作」的矩阵,哪一格是空的一眼就能看出来,比写十页文字描述管用。
2.2 实体关系梳理与关系模式转换
这个系统的核心实体不多,但联系类型要分清。教学楼和教室是一对多,教室和排课记录是一对多,用户和借用申请是一对多,借用申请和审批日志是一对多。难点在「教室—时间段」这个隐含的多对多:一间教室在多个时间段可用,一个时间段有多个教室可用,这个联系本身不建表,而是靠排课表和申请表里的room_id + 时间段组合来体现。
E-R 图转关系模式时,一对多直接把外键放在多的一方,不需要额外建表;只有借用申请和审批日志这种需要保留历史轨迹的,才单独建表。转换结果大致是这几张:
| 表名 | 说明 | 关键字段 |
|---|---|---|
| sys_user | 用户账号与角色 | id, username, password, role |
| building | 教学楼 | id, name |
| room | 教室 | id, building_id, room_no, capacity, room_type, status |
| course_schedule | 排课记录 | id, room_id, course_id, week_day, slot_id, status |
| room_apply | 借用申请 | id, apply_no, room_id, applicant_id, use_date, slot_id, status |
| apply_log | 审批日志 | id, apply_id, operator_id, from_status, to_status, remark |
这里有个容易被忽略的点:申请单要单独给一个业务单号apply_no,不要拿自增主键当单号展示。主键是给数据库用的,单号是给人看的,学生拿着「借用申请 20240513001」去问管理员,比拿着「ID 87」体面得多。
2.3 表结构设计与约束:把业务规则写进 DDL
真正的设计功力体现在约束上。下面这几张表的建表语句,重点看唯一索引和状态位,这是后面所有冲突检测的地基。
-- 教室表:同一教学楼内教室编号唯一,防止重复录入 CREATE TABLE room ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '教室主键', building_id BIGINT NOT NULL COMMENT '所属教学楼', room_no VARCHAR(20) NOT NULL COMMENT '教室编号,如 A301', capacity INT NOT NULL DEFAULT 0 COMMENT '容纳人数', room_type TINYINT NOT NULL DEFAULT 0 COMMENT '0 普通 1 多媒体 2 机房', status TINYINT NOT NULL DEFAULT 1 COMMENT '1 可用 0 停用', is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记', UNIQUE KEY uk_building_room (building_id, room_no) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '教室信息表';教室编号用uk_building_room兜住,重复录入直接被数据库挡回去,不用在 Service 里写「先查再插」那种有并发漏洞的逻辑。
-- 排课表:按整学期固定节次建模,同一教室同一天同一节次只能排一门课 CREATE TABLE course_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id BIGINT NOT NULL COMMENT '教室', course_id BIGINT NOT NULL COMMENT '课程', teacher_id BIGINT NOT NULL COMMENT '任课教师', week_day TINYINT NOT NULL COMMENT '1-7 对应周一到周日', slot_id TINYINT NOT NULL COMMENT '节次:1=1-2节 2=3-4节 3=5-6节 4=7-8节 5=9-10节', status TINYINT NOT NULL DEFAULT 1 COMMENT '1 生效 0 已撤销', UNIQUE KEY uk_room_slot (room_id, week_day, slot_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '排课记录表';这里做了一次很关键的简化:按整学期固定节次排课,不做分周段。好处是uk_room_slot这个唯一索引能直接从数据库层面杜绝「同一间教室同一节次排两门课」,连事务都不用写。代价是第 9 周换老师、换教室这种场景要撤销后重建。课设范围内这个取舍完全够用,而且答辩时讲清楚为什么这么选,反而是加分项。
如果要支持「1-8 周排 A 课、9-16 周排 B 课」,唯一索引就兜不住了,因为区间重叠没法用索引判断,只能回到应用层加锁查重叠。这条路我会在第 3 章讲冲突检测时展开。
借用申请表同样要设计得能追溯:
CREATE TABLE room_apply ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_no VARCHAR(32) NOT NULL COMMENT '业务单号,展示用', room_id BIGINT NOT NULL, applicant_id BIGINT NOT NULL, use_date DATE NOT NULL COMMENT '使用日期', slot_id TINYINT NOT NULL COMMENT '节次,与排课表口径一致', purpose VARCHAR(200) NOT NULL COMMENT '用途说明', status TINYINT NOT NULL DEFAULT 0 COMMENT '0 待审 1 通过 2 驳回 3 已取消', auditor_id BIGINT NULL COMMENT '审批人,待审时为空', audit_time DATETIME NULL, audit_remark VARCHAR(200) NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_apply_no (apply_no), KEY idx_room_date (room_id, use_date) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '教室借用申请表';status用 tinyint 存状态码而不是存中文,是为了后面做状态机流转时能写成WHERE status = 0这种确定的条件;中文状态一旦改文案,全表都要刷。idx_room_date这个普通索引是给「查某间教室某天的申请」用的,审批页和学习者的查询页都吃这个索引。
提示:所有时间字段统一用
DATE/DATETIME,不要用VARCHAR存「2024-05-13」这种字符串。字符串比较在跨月跨年时会出现'2024-9-1' > '2024-10-1'这种反直觉结果,这类问题几乎每年课设都会出现。
3. 总体架构与后端实现:分层结构与四个核心模块
3.1 分层骨架与职责边界
后端用 Spring Boot + MyBatis + MySQL 是最常见的组合,换成 Flask 或 Django 思路完全一样:Controller 只负责接参数、做参数校验、返回统一格式;Service 承载业务规则和事务边界;Mapper 只管拼 SQL。目录大致是这样:
src/main/java/com/example/classroom/ ├── controller/ # 只做参数接收与响应封装,不写业务判断 │ ├── RoomController.java │ ├── ApplyController.java │ └── AuthController.java ├── service/ # 业务规则、事务、状态机都在这层 │ ├── RoomService.java │ ├── ApplyService.java │ └── impl/ ├── mapper/ # 一个接口对应一张表,SQL 写在 XML 或注解里 ├── entity/ # 与表结构一一对应的实体 └── common/ # 统一返回体、异常处理、拦截器分层最容易出问题的地方是 Controller 里写业务。一旦你把「判断教室是否被占用」写进 Controller,前端换个入口调用就得复制一遍,后面改规则要改两处。我一般会在 Code Review 时把 Controller 里超过 15 行的方法全部打回。
统一返回体也在这层定死,前端只认一种结构:
// 统一响应体:code 为 0 表示成功,非 0 表示业务失败 public class Result<T> { private int code; private String msg; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 0; r.msg = "success"; r.data = data; return r; } public static <T> Result<T> fail(String msg) { Result<T> r = new Result<>(); r.code = 1; r.msg = msg; return r; } }code用 0 表示成功是很多团队的约定,前端拦截器里只判断code !== 0就弹提示,不用每个接口单独处理。msg里放的是给人看的话,比如「该教室在周三 3-4 节已被占用」,不要放堆栈信息。
3.2 排课与借用模块:冲突检测的两种写法
冲突检测是这个系统最核心的一段逻辑,也是答辩时最容易被追问的地方。有两种实现路线,选错了后面全是麻烦。
第一种是基于固定节次的唯一索引,也就是第 2 章表结构里用的方案。插入排课时根本不用先查询,直接 insert,捕获DuplicateKeyException转成友好提示即可:
// 依赖 uk_room_slot 唯一索引兜底,捕获重复键异常后转成业务提示 public void addSchedule(ScheduleDTO dto) { try { scheduleMapper.insert(dto.toEntity()); } catch (DuplicateKeyException e) { // 唯一索引冲突说明该教室该节次已有排课 throw new BizException("该教室在周" + dto.getWeekDay() + " 第" + dto.getSlotId() + " 节已有排课,请更换教室或节次"); } }这段代码的好处是没有「先查后插」的时间窗口,两个人同时提交也只会有一个成功。代价是提示信息比较粗,无法告诉你冲突的是哪门课。课设场景下够用,如果要做细,就在捕获异常后再查一次冲突记录,把课程名带出来。
第二种是基于任意起止时间的区间重叠判断,适合借用申请这种按具体日期和时段填写的场景。判断逻辑是一句话:两个时间段重叠,当且仅当已有.start < 新的.end AND 已有.end > 新的.start。
-- 查某教室在某天某节次是否已有通过的借用,status=1 表示审批通过 SELECT COUNT(1) FROM room_apply WHERE room_id = #{roomId} AND use_date = #{useDate} AND slot_id = #{slotId} AND status = 1;如果时间粒度高到「几点几分」,就把slot_id换成start_time/end_time两个 datetime 字段,条件改成:
SELECT COUNT(1) FROM room_apply WHERE room_id = #{roomId} AND status = 1 AND start_time < #{endTime} AND end_time > #{startTime};注意这两个不等号的方向,写反了会导致首尾相接的两个申请被误判为冲突。<和>而不是<=和>=,意思是 10:00-12:00 和 12:00-14:00 不算冲突,这个细节答辩时讲出来很能体现思考深度。
但光有查询还不够,因为「查到没有 → 插入」之间有时间窗口。审批通过这种写操作必须加锁:
@Transactional(rollbackFor = Exception.class) public void approve(Long applyId, Long auditorId, String remark) { // 1. 行锁锁定当前申请,防止重复点击导致状态被覆盖 RoomApply apply = applyMapper.selectForUpdate(applyId); if (apply == null || apply.getStatus() != 0) { throw new BizException("该申请已被处理,请刷新后重试"); } // 2. 再次校验教室在该时段是否已被其他通过的申请占用 int conflict = applyMapper.countApprovedConflict( apply.getRoomId(), apply.getUseDate(), apply.getSlotId()); if (conflict > 0) { throw new BizException("该时段教室已被占用,无法通过"); } // 3. 更新状态并写审批日志,两步在同一事务内 applyMapper.updateStatus(applyId, 1, auditorId, remark); applyLogMapper.insert(buildLog(apply, 0, 1, auditorId, remark)); }selectForUpdate对应的 SQL 是SELECT ... FOR UPDATE,它会在事务提交前锁住这一行,第二个请求会阻塞而不是读到旧状态。第 2 步的二次校验不能省,因为从学生提交到管理员审批之间可能隔了几小时,期间教室可能被别的申请占用了。
3.3 登录态与角色校验:拦截器比注解更省心
登录用 Session 还是 Token 都行,课设里 Session 更简单,因为不用处理过期刷新。核心是把「当前用户」从请求里解出来,放进一个上下文对象,Service 层直接取。
// 登录拦截器:未登录直接返回 401,已登录则把用户放进 ThreadLocal public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { HttpSession session = req.getSession(false); if (session == null || session.getAttribute("userId") == null) { resp.setStatus(401); return false; } UserContext.set((Long) session.getAttribute("userId"), (Integer) session.getAttribute("role")); return true; } }角色校验我习惯用自定义注解 + 拦截器,而不是在每个方法里写 if:
@RequireRole(2) // 2 表示管理员 @PostMapping("/apply/approve") public Result<Void> approve(@RequestBody ApproveDTO dto) { ... }注解里只写角色码,拦截器统一读注解判断。这样接口权限一目了然,漏加注解的接口在 Code Review 时也能扫出来。这里有一个常见疏漏:只做了菜单隐藏,没做接口校验。学生把浏览器地址栏改成管理员页面的路由,页面进不去,但直接调接口还是能过。所有写接口都必须有角色注解。
3.4 借用审批状态机与日志表
借用申请的状态流转必须显式定义,不能散落在各个 Service 方法里。这个系统的状态就四个:0 待审核、1 已通过、2 已驳回、3 已取消。允许的流转只有三条:0→1、0→2、0→3。其余全部拒绝。
// 状态机:只允许从待审状态出发的三条流转,其余组合直接抛异常 private static final Map<Integer, Set<Integer>> FLOW = Map.of( 0, Set.of(1, 2, 3) ); private void checkTransition(int from, int to) { if (!FLOW.getOrDefault(from, Set.of()).contains(to)) { throw new BizException("非法的状态流转:" + from + " -> " + to); } }把状态机单独抽出来好处是:后面做「已通过的申请能不能撤销」这种需求变更,只改这个 Map 就行,不用满项目找 update 语句。同时每次流转都往apply_log写一条记录,记录 from、to、操作人和备注,这个表在答辩演示时非常有用,老师问「你怎么证明这次审批发生过」,直接把日志表查出来就行。
4. 前端页面与接口联调:从原型到可用界面
4.1 页面清单与路由规划
页面不用多,但要覆盖完整闭环。下面这张表是我做课设时的标准清单,七个页面基本能撑起一次完整演示:
| 页面 | 路由 | 主要接口 | 可见角色 |
|---|---|---|---|
| 登录页 | /login | POST /auth/login | 全部 |
| 空闲教室查询 | /rooms/free | GET /rooms/free | 学生、教师 |
| 我的申请 | /apply/mine | GET /apply/mine | 学生、教师 |
| 提交申请 | /apply/create | POST /apply | 学生、教师 |
| 审批列表 | /admin/apply | GET /apply/pending | 管理员 |
| 教室维护 | /admin/room | GET/POST/PUT /rooms | 管理员 |
| 使用统计 | /admin/stat | GET /stat/usage | 管理员 |
路由按角色分区,/admin前缀的页面全部由路由守卫拦截。统计页可以做得简单一点,按教学楼和按周次做两个柱状图就够,它的价值不在于好看,而在于答辩时能说清楚「数据是从哪张表聚合出来的」。
4.2 表格分页与查询条件封装
前端最容易写乱的是查询参数。三四个筛选条件加一个分页,如果每个页面各写一套拼接逻辑,改一个字段名要改十处。我一般会封一个通用的列表加载函数:
// 通用列表加载:把筛选条件、页码、页大小合并成一个请求体 async function loadList(url, filters, page, size, render) { const params = new URLSearchParams(); Object.entries(filters).forEach(([k, v]) => { if (v !== '' && v !== null && v !== undefined) params.append(k, v); }); params.append('page', page); params.append('size', size); const resp = await fetch(`${url}?${params.toString()}`, { headers: { 'Content-Type': 'application/json' } }); const result = await resp.json(); if (result.code !== 0) { alert(result.msg); // 业务失败统一提示,不进入渲染 return; } render(result.data.records, result.data.total); }关键在于空值过滤。前端表单里没填的输入框拿到的是空字符串,如果不过滤直接传给后端,MyBatis 的if标签会把它当成有效条件,拼出AND room_no = ''这种恒假条件,列表永远是空的。这类问题排查起来很费时间,因为网络面板里看起来参数都传了。
后端接收分页参数时,要做边界保护:page小于 1 就当 1,size超过 100 就截到 100。否则学生把 size 改成 100000,一次查全表,页面卡死还会被老师当成性能问题。
4.3 联调顺序与 Mock 数据的退场
联调不要按页面顺序来,要按依赖顺序来。我一般是这样排:
先打通字典和基础数据,也就是教学楼、教室这两张表的增删改查,因为后面所有页面都要引用教室下拉框。然后做登录和拦截器,保证带不带登录态的行为都对。接着做只读查询,空闲教室查询和我的申请列表,这两个页面能验证多表关联和分页。最后才做写操作和审批流,因为写操作依赖前面所有的基础数据。
Mock 数据要尽早退场。很多课设前端用假数据把页面做得漂漂亮亮,后端接口一直没接通,到了答辩现场一刷新全是写死的数组。我的做法是每个页面接通真接口后,立刻把 mock 文件删掉,并且在代码里搜一遍mock关键字确认没有残留。前端调用失败时不吞异常,直接把后端的msg弹出来,联调阶段这比任何日志都好用。
另外,跨域问题在前后端分离时几乎必现。开发阶段在后端加一个全局 CORS 配置,允许本地端口访问,不要等到部署时才发现。这个配置只影响开发环境,生产环境用同源部署或者反向代理,不要把allowOrigin("*")带到线上。
5. 避坑与排查:课程设计里最容易翻车的五个点
5.1 数据建模层面的三个坑
坑一:时间字段用字符串存,范围查询全部失效。
现象是「查询某天之后的申请」时,结果时多时少,跨月尤其明显。原因是用VARCHAR(20)存了2024-5-3这种没补零的日期,字符串比较按字符逐位比,'2024-5-3'会大于'2024-12-1',因为第二位5大于1。解决办法是把字段类型改成DATE或DATETIME,前端传参也统一用yyyy-MM-dd补零格式,让数据库来做比较。改类型的时候记得把历史数据一起转换,别只改建表语句。
坑二:只建了主键,没建唯一索引,同一教室被排了两节课。
现象是排课列表里出现两条完全一样的记录,学生查空闲教室时却说「已占用」。原因是插入前只做了「先查再插」,两个请求同时通过查询,然后都执行了插入。解决办法是给(room_id, week_day, slot_id)加唯一索引,让数据库做最终裁判,代码里捕获DuplicateKeyException转成提示。索引加上之后如果报「Duplicate entry」,说明历史脏数据还在,要先清一遍重复记录再建索引。
坑三:删除教室直接物理删除,历史借用记录全变孤儿。
现象是统计页按教室分组时出现一堆空名字,或者历史申请详情页打不开。原因是对教室执行了DELETE,而申请表里的room_id还指着那条已经不存在的记录。解决办法是改成软删除,用is_deleted标记,所有查询默认带is_deleted = 0,历史记录关联查询时仍能查到教室名称。外键约束在课设里可以选择性开启,但软删除这个习惯一定要有。
5.2 接口与联调层面的两个坑
坑四:审批按钮点两下,状态被覆盖或者日志写了两条。
现象是管理员手快点了两次通过,日志表里出现两条一模一样的记录,或者第二次把已经通过的申请又「通过」了一次。原因是接口没做幂等,两次请求都读到了「待审核」状态。解决办法有两层:前端点击后立即禁用按钮,后端用UPDATE ... WHERE id = ? AND status = 0带状态条件更新,根据影响行数判断是否真的更新成功,影响行数为 0 就返回「该申请已被处理」。
坑五:中文乱码和时区差八小时。
现象是提交的中文用途说明存进库里变成问号,或者创建时间比实际时间早八小时。乱码的原因一般是连接串没指定字符集,把 JDBC URL 里加上characterEncoding=utf8并把库和表统一成utf8mb4即可。时区问题出在连接串的serverTimezone配置和数据库默认时区不一致,统一指定成Asia/Shanghai,前后端传输时间统一用标准格式字符串。这两个问题不改,演示时一定会在老师面前出现。
注意:这五个坑里有四个都能在开发阶段用一条 SQL 复现,别等到答辩前一天才发现。建完库先手动插几条边界数据,比写十个单元测试快。
6. 验收与答辩:怎么让老师三分钟看懂你的数据库设计
答辩现场时间很短,老师不会逐行看代码,他们看的是三样东西:E-R 图是否和表结构对得上、核心业务规则有没有落到数据库层、演示时数据能不能自圆其说。我见过太多人把 PPT 做成功能截图轮播,讲完十分钟,老师问一句「你这个教室冲突是怎么防的」,答不上来就尴尬了。
我的建议是准备一个「三分钟主线」:打开数据库客户端,先展示 E-R 图,指着room和course_schedule说清楚一对多关系;然后当场执行一条冲突检测 SQL,插入一条重复排课,让唯一索引报错,证明规则是数据库在兜底而不是靠前端;最后打开审批页,点一次通过,再点一次,让老师看到第二次被拦截。这条主线走完,比讲半小时架构有效得多。
答辩问题提前准备好回答要点,下面这几个是高频题:
| 老师可能问 | 回答要点 |
|---|---|
| 为什么排课和借用要分两张表 | 排课是学期固定计划,借用是临时占用,生命周期和审批流不同,合表会导致状态字段含义混乱 |
| 唯一索引和事务锁为什么两个都要 | 索引防的是固定节次的重复插入,事务锁防的是审批过程中的并发覆盖,解决的不是同一类问题 |
| 如果教室要支持分周段排课,怎么改 | 唯一索引换成应用层区间重叠查询加行锁,或者把周次拆成独立的周次表做关联 |
| 数据量大了怎么优化 | 给高频查询条件建联合索引,统计页做定时汇总表,避免每次实时聚合 |
还有一个细节:演示数据要提前准备好,不要现场录。至少准备三间教室、两条排课、一条待审批的申请、一条已通过的申请,数据之间有关联,能串起完整故事。现场录入的数据一旦触发校验失败,节奏就乱了。
我从第一次交课设翻车之后,养成了一个习惯:每次交付前,强制自己用一条 SQL 把最复杂的业务规则单独跑一遍,跑通了再动页面。因为页面可以慢慢调,数据错了整个演示就废了。这套教室管理系统的表结构、冲突检测 SQL、状态机代码和页面清单都整理在这份课程设计压缩包里,拿到之后建议先按第 2 章的建表语句把库跑起来,再对着第 4 章的页面清单逐个核对接口,最后用第 6 章的三分钟主线自己演练一遍。希望帮到你。
本文还有配套的精品资源,点击获取