ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数据库课程设计实战:教室管理系统表设计与预约冲突检测

数据库课程设计实战:教室管理系统表设计与预约冲突检测

简介:数据库应用课程设计.zip 面向计算机专业学生及Web课程设计学习者,围绕教室管理系统整合数据库建模、JSP页面开发与数据持久化实践,可用于课程作业参考、项目复现和答辩准备。压缩包共39个文件,约1.94MB,含8个JSP页面、4个CSS与4个JS静态资源,以及MDF/LDF数据库文件、docx设计文档和工程配置,覆盖前端界面、业务页面、数据库存储与项目说明等环节。已有2114人学习,说明该案例在同类课程设计中具有较高参考价值。读者可据此了解教室、教师、课程等信息的组织方式,观察E-R模型落到关系表后的页面交互与查询流程,并结合设计文档梳理系统结构、功能划分和部署文件;其中的数据库设计、用户认证、查询优化与测试调试内容,也能为课程设计阶段的功能对照和排错提供参考。

1. 从「能跑」到「能答辩」:一份课程设计包真正要过的三道关

答辩前一晚系统还能点开,第二天被问一句「你的预约表为什么这么建」就哑了——这是拿到数据库应用课程设计.zip 的人最常见的状态。这类包通常就是一个教室管理系统的 web大作业:建表脚本、后端 CRUD、几张管理页面,加一份写得比较薄的说明文档。它能帮你把「数据库」这门课从 E-R 图落到真实表结构,也能让一个 web大作业在两天内跑起来,但它不会自动帮你回答设计取舍。适合两类人:一是需要按时交课程设计、又不想从零搭脚手架的人;二是想借一个完整小系统把建表、索引、事务、前后端对接串一遍的人。下面按拆包、建表、接口、排错、答辩的顺序走一遍,重点放在那些「跑起来之后才会暴露」的地方。

2. 拆包与环境:把教室管理系统从压缩包跑到浏览器

拿到包先别急着找启动类,目录结构本身就是一份技术说明。这类课程设计包的目录命名往往很直白,sql/、src/、web/、doc/四件套,认清楚之后能省掉大量试错。

2.1 先认栈再动手:目录里的四个信号

我一般先用两条命令把结构摸清,而不是一层层点开文件夹。压缩包解开后进入根目录执行:

# 只看两层目录,避免被 node_modules 或 target 淹没 find . -maxdepth 2 -type d | sort # 找四类关键文件:建表脚本、构建文件、配置文件、前端依赖 find . -maxdepth 3 \( -name "*.sql" -o -name "pom.xml" -o -name "build.gradle" \ -o -name "application*.yml" -o -name "application*.properties" -o -name "package.json" \) | sort

第一条命令的-maxdepth 2是控制递归深度,课程设计包一般不会超过三层,深度太大会把编译产物也列出来。第二条命令同时匹配六种文件名,是为了用一次执行回答四个问题:有没有pom.xml或build.gradle,决定后端是 Java 系还是别的;有没有多个.sql文件,决定建表脚本是分开的还是合一的;有没有application.yml,决定配置改哪里;有没有package.json,决定前端要不要单独装依赖。

这一步的结论通常落在三种情况上。第一种是 Spring Boot + MyBatis + 静态页面,前端就是resources/static下的 html,改完直接重启即可,最省事。第二种是前后端分离,前端目录带package.json,需要先npm install再npm run dev,此时后端要开跨域。第三种是老式 JSP/Servlet,WEB-INF下有web.xml,需要配 Tomcat 而不是直接java -jar。三种情况的环境准备完全不同,先分清能避免「命令敲了没反应」的无效时间。

还有两个容易被忽略的信号:doc/目录里如果有 E-R 图或表结构说明,先扫一眼字段中文名,后面写注释和答辩都用得上;sql/里如果有data.sql或init.sql,说明带初始数据,别只导schema.sql,否则登录页连账号都没有。

2.2 建库建表:字符集、排序规则与导入顺序

建表脚本不要直接在 Navicat 之类的工具里全选执行,先确认两件事:脚本头部有没有CREATE DATABASE,以及有没有USE语句。很多课程设计包的脚本只写了CREATE TABLE,依赖你在工具里手动选库,结果就是表建到了别的库里,后端启动时报「表不存在」。

我习惯自己补一段建库语句,把字符集显式写死:

-- 建库时就把字符集定死,避免后续建表继承到 latin1 CREATE DATABASE IF NOT EXISTS classroom_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE classroom_db; -- 教室表:room_no 用唯一索引而不是主键,主键留给自增 id CREATE TABLE IF NOT EXISTS classroom ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', room_no VARCHAR(32) NOT NULL COMMENT '教室编号,如 A301', building VARCHAR(64) NOT NULL COMMENT '所属楼栋', capacity SMALLINT NOT NULL DEFAULT 0 COMMENT '容纳人数', room_type TINYINT NOT NULL DEFAULT 1 COMMENT '1普通 2机房 3多媒体', status TINYINT NOT NULL DEFAULT 1 COMMENT '1可用 0停用', PRIMARY KEY (id), UNIQUE KEY uk_room_no (room_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教室信息表';

字符集这里选utf8mb4而不是utf8,原因是 MySQL 里的utf8实际最多三字节,存不下 emoji 和部分生僻字,教室名里带特殊符号时就会插入失败。排序规则用utf8mb4_general_ci还是utf8mb4_0900_ai_ci取决于 MySQL 版本,8.0 以上两者都可用,课程设计场景对排序精度没要求,选前者兼容性更好。ENGINE=InnoDB必须显式写,因为后面要用事务和行锁,MyISAM 不支持。

room_no单独建唯一索引而不是当主键,是个值得在答辩时说的点:教室编号是业务标识,可能因为校区调整而变更,主键一旦被外键引用就不该动,用自增 id 做物理主键、业务编号做唯一约束,是更稳的做法。

导入时指定字符集,避免命令行客户端默认编码不一致导致中文变问号:

# --default-character-set 必须加,否则 Windows 终端下中文注释会乱码 mysql -uroot -p --default-character-set=utf8mb4 classroom_db < sql/schema.sql mysql -uroot -p --default-character-set=utf8mb4 classroom_db < sql/data.sql

执行顺序不能反。schema.sql建表,data.sql插初始数据,如果data.sql里有外键引用,顺序反了会直接报 1452。导入完成后用一条命令自检:

-- 确认表和字符集都对 SELECT TABLE_NAME, TABLE_COLLATION, TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'classroom_db';

TABLE_ROWS对 InnoDB 是估算值,不要拿它当准确行数,这里只是确认表都建出来了。

提示:如果脚本里出现DEFINER=或CREATE TRIGGER,优先用 root 或具备 SUPER 权限的账号导入,普通账号会报 1227。

2.3 配置与启动:三处必改参数

配置文件是跑不起来的重灾区。课程设计包里的application.yml往往带着作者本机的路径和密码,直接启动必然连不上库。常见需要改的只有三处:数据库连接、端口、文件上传路径(如果有)。

server: port: 8080 spring: datasource: # serverTimezone 不写会导致时间字段差 8 小时,这是最隐蔽的坑 url: jdbc:mysql://127.0.0.1:3306/classroom_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 改成你自己的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: # mapper xml 的位置,改包名时这里要同步 mapper-locations: classpath:mapper/*.xml configuration: # 下划线字段自动映射到驼峰属性,省掉大量 resultMap map-underscore-to-camel-case: true logging: level: com.example.classroom.mapper: debug

逐项说明。serverTimezone=Asia/Shanghai是必加项,Java 的LocalDateTime和 MySQL 的DATETIME在时区不一致时会出现「存进去 14:00,查出来 06:00」的现象,排查起来非常费时间。useSSL=false是本地开发关掉 SSL 握手,省掉证书警告。allowPublicKeyRetrieval=true在 MySQL 8 默认认证插件下不加会报Public Key Retrieval is not allowed。map-underscore-to-camel-case打开后,room_no能自动映射到roomNo,否则每个查询都要手写resultMap。

最后一行日志级别是给排错用的,把 mapper 包的 SQL 打出来,能直接看到实际执行的语句和参数,比在代码里到处加System.out高效得多。

启动分两种情况。有 Maven 包装器的直接用包装器,避免本机 Maven 版本不一致:

# 先编译再运行,跳过测试,课程设计包里的测试经常是空的或过时的 ./mvnw clean package -DskipTests # 前台启动,日志直接打屏幕上,方便看启动失败原因 java -jar target/classroom-*.jar

如果包里有mvnw就用./mvnw,没有再用mvn。-DskipTests不是偷懒,而是这类包的测试类经常引用了已删除的表或字段,跑测试只会浪费十分钟。启动成功的标志不是「看到 Spring 图标」,而是日志里出现Tomcat started on port(s): 8080且没有APPLICATION FAILED TO START。浏览器打开http://127.0.0.1:8080能看到登录页,才算第一关过了。

3. 教室管理系统的表设计:从 E-R 到可落地的五张表

跑起来只是开始,课程设计真正的评分点在表结构。教室管理系统的业务边界很清晰:有哪些教室、谁能用、什么时候用、用来干什么。把这四句话翻译成表,就是五张核心表。

3.1 五张核心表与字段选型

先看整体划分,再看字段为什么这么定:

表名作用关键字段预估行数量级
classroom教室档案room_no、building、capacity、room_type百级
sys_user用户(学生/教师/管理员)username、password、role千级
course_schedule固定课表占用classroom_id、week_day、section千级
reservation临时预约classroom_id、user_id、start_time、end_time万级
building楼栋字典name、campus十级

reservation是唯一会持续增长的表,字段选型要格外小心。几个具体决定:

时间字段用DATETIME而不是TIMESTAMP。TIMESTAMP范围只到 2038 年,且受服务器时区影响,DATETIME存的是字面值,配合前面配置的serverTimezone更可控。容量用SMALLINT,最大 32767,教室人数不可能超。状态字段用TINYINT而不是VARCHAR,1待审核 2已通过 3已拒绝 4已取消,查询时status IN (1,2)比字符串比较快,也省空间。

主键统一用BIGINT AUTO_INCREMENT,不用 UUID。UUID 做主键在 InnoDB 里会导致页分裂,插入性能下降,课程设计数据量小感受不到,但答辩时被问到「为什么不用 UUID」能答上来是加分项:自增主键是顺序写入,聚簇索引维护成本低;UUID 的好处是分布式唯一,单机系统用不上。

-- 预约表:核心业务表,索引和约束都在这里 CREATE TABLE IF NOT EXISTS reservation ( id BIGINT NOT NULL AUTO_INCREMENT, classroom_id BIGINT NOT NULL COMMENT '教室id', user_id BIGINT NOT NULL COMMENT '申请人id', start_time DATETIME NOT NULL COMMENT '开始时间', end_time DATETIME NOT NULL COMMENT '结束时间', purpose VARCHAR(200) DEFAULT NULL COMMENT '用途说明', status TINYINT NOT NULL DEFAULT 1 COMMENT '1待审 2通过 3拒绝 4取消', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_room_time (classroom_id, start_time, end_time), KEY idx_user (user_id), CONSTRAINT fk_res_room FOREIGN KEY (classroom_id) REFERENCES classroom (id), CONSTRAINT fk_res_user FOREIGN KEY (user_id) REFERENCES sys_user (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教室预约表';

idx_room_time这个联合索引的顺序不能随便调。冲突检测的查询条件是「同一教室 + 时间区间重叠」,把classroom_id放最左,等值匹配先过滤,再在剩余行里比较时间范围,索引才能用上。如果反过来把start_time放最左,等值条件用不上,只能走范围扫描。这一点在数据量上千后差异明显,答辩时可以直接拿EXPLAIN结果说话。

3.2 主外键、唯一约束与「数据库管不了的那部分」

外键在课程设计里是个争议点。很多教程建议「互联网公司不用外键,靠应用层保证」,但课程设计的评分点恰恰是「有没有体现完整性约束」。我的做法是:保留外键,但把删除策略想清楚。

reservation对classroom的外键默认是RESTRICT,意味着有预约记录的教室删不掉。这是对的,但要在管理页面给出友好提示,而不是把 1451 错误直接抛给用户。如果业务上确实要允许删除教室,用ON DELETE CASCADE会连带删掉历史预约,数据就没了;更稳的做法是给classroom加status字段做逻辑删除,物理删除只留给真正废弃的数据。

唯一约束能解决一部分重复问题,但解决不了时间区间重叠。原因很直接:唯一索引比较的是「值相等」,而「时间段重叠」是个区间相交判断,[9:00,11:00]和[10:00,12:00]没有任何一个字段相等,唯一索引拦不住。这是这类系统最典型的认知差,也是答辩高频问题。

所以完整性要分两层看:

约束类型能拦住什么拦不住什么落地位置
主键id 重复业务重复表定义
唯一索引room_no重复、同一用户同一时段重复提交不同用户抢同一教室表定义
外键引用不存在的教室/用户状态非法流转表定义
CHECKend_time > start_time跨行区间重叠表定义(8.0 生效)
应用层事务并发下的区间重叠绕过接口的直接写库Service 层

CHECK约束在 MySQL 8.0.16 之后才真正生效,之前的版本只做语法解析不执行。加一条CHECK (end_time > start_time)成本很低,能拦住「结束时间早于开始时间」这种低级错误:

ALTER TABLE reservation ADD CONSTRAINT chk_time_range CHECK (end_time > start_time);

真正难的是并发下的区间重叠,这个只能放到 Service 层用事务加锁解决,第 4 章展开。

3.3 范式取舍:什么时候该故意留冗余

三范式是课程设计的必考内容,但全按三范式建表,查询会很难写。reservation列表页要显示教室编号和申请人姓名,如果严格按范式,只存classroom_id和user_id,每次列表都要 JOIN 两张表。数据量小的时候无所谓,但这个 JOIN 在分页查询里会放大成额外开销。

我的处理是:核心业务表保持三范式,展示层用视图或查询时 JOIN,不往reservation里冗余room_no。理由是教室编号可能变,冗余后要同步更新,反而增加不一致风险。但有一类冗余是合理的——统计类字段。比如classroom表加一个reserve_count记录被预约次数,每次预约成功+1。这个字段严格来说可以从reservation聚合出来,属于冗余,但它把「热门教室排行」从一次GROUP BY全表扫描变成一次单表排序,代价可控。

判断标准可以总结成一句话:冗余字段如果「只增不改」或者「改了也不影响正确性」,可以留;如果「改了要和主表同步且同步会漏」,就别留。课程设计里常见的错误是把user_name冗余进reservation,用户改名后历史记录全对不上,答辩时被追问一句就露馅。

注意:如果坚持用视图做展示层,记得在说明文档里写清楚视图定义,而不是只在数据库里建了没人知道。评分看的是设计意图,不是对象数量。

4. CRUD 闭环:分页检索、预约冲突与前后端字段对齐

后端接口和前端页面对不上,是这类包二次开发时最耗时的环节。这一章按「查询 → 写入 → 校验」的顺序,把三个最容易出问题的点拆开。

4.1 条件分页查询:一条 SQL 替掉三层循环

课程设计包里常见的反面写法是先查全表,再在 Java 里for循环过滤,最后subList分页。数据量上百条时看不出问题,一旦导入几千条测试数据,页面就要转好几秒。正确做法是把条件和分页都下推到 SQL。

-- 预约列表:条件可选,用 IS NULL 短路,避免动态拼 SQL 注入 SELECT r.id, r.classroom_id, c.room_no, c.building, u.real_name, r.start_time, r.end_time, r.status FROM reservation r JOIN classroom c ON c.id = r.classroom_id JOIN sys_user u ON u.id = r.user_id WHERE (#{roomNo} IS NULL OR c.room_no LIKE CONCAT('%', #{roomNo}, '%')) AND (#{status} IS NULL OR r.status = #{status}) AND (#{beginTime} IS NULL OR r.start_time >= #{beginTime}) ORDER BY r.start_time DESC LIMIT #{offset}, #{pageSize};

(#{roomNo} IS NULL OR ...)这种写法叫「短路条件」,好处是不用<if>标签拼 SQL,语句固定,能被 MySQL 的查询缓存和预编译复用。缺点是当参数为 NULL 时,优化器可能选不到最优索引,但在课程设计的数据量下可以接受。如果追求更规范,用 MyBatis 的<where>+<if>动态拼接也可以,代价是 XML 变长。

LIMIT #{offset}, #{pageSize}的offset必须由后端算,不能信前端传的值。计算公式是(pageNum - 1) * pageSize,并且在 Service 层做边界校验:pageNum < 1归 1,pageSize上限设 100,防止有人传pageSize=999999把内存打爆。深分页(offset 很大)在 MySQL 里是「先扫再丢」,数据量大时会慢,课程设计阶段不用优化,但答辩时能说出「可以用游标分页或延迟关联优化」就是加分。

对应的 Mapper 接口和 XML 片段:

<!-- ReservationMapper.xml,resultType 用别名,需在配置里开 type-aliases-package --> <select id="selectPage" resultType="ReservationVO"> SELECT r.id, c.room_no AS roomNo, u.real_name AS userName, r.start_time AS startTime, r.end_time AS endTime, r.status FROM reservation r JOIN classroom c ON c.id = r.classroom_id JOIN sys_user u ON u.id = r.user_id <where> <if test="roomNo != null and roomNo != ''"> AND c.room_no LIKE CONCAT('%', #{roomNo}, '%') </if> <if test="status != null"> AND r.status = #{status} </if> </where> ORDER BY r.start_time DESC LIMIT #{offset}, #{pageSize} </select>

这里用了AS roomNo手动起别名,原因是map-underscore-to-camel-case只对「下划线转驼峰」生效,real_name会转成realName,但前端可能要的是userName,别名能省掉一次字段改名。参数上,offset和pageSize建议在 Controller 层就用@RequestParam(defaultValue = "1")给定默认值,避免前端不传时报空指针。

4.2 预约冲突检测:四种错误写法与一种正确姿势

这是整个系统最值得讲的一段。时间区间重叠的判断,很多包里的写法是错的,而且错得很隐蔽——单人测试永远发现不了,一并发就出问题。

先看常见的四种错误写法:

第一种,用BETWEEN:start_time BETWEEN #{start} AND #{end}。这只判断了「开始时间落在区间内」,如果新预约的开始时间早于已有预约、结束时间覆盖过去,就漏判了。

第二种,用「完全包含」:start_time >= #{start} AND end_time <= #{end}。只拦住了「新预约完全包住旧预约」的情况,反过来旧包新就拦不住。

第三种,用日期相等:DATE(start_time) = DATE(#{start})。这会把同一天所有预约都算冲突,误杀严重,而且DATE()函数作用在字段上会让索引失效。

第四种,用OR拼接四个条件但漏了一个:只写了「新开始落在旧区间」和「新结束落在旧区间」,漏了「新区间完全覆盖旧区间」。四个条件少一个,边界就漏。

正确的判断只有一个:两个区间重叠,当且仅当「新开始 < 旧结束」且「新结束 > 旧开始」。取反就是「不重叠」的两个条件:新结束 <= 旧开始,或新开始 >= 旧结束。

-- 冲突检测:status 只算待审和已通过,已拒绝/已取消不占位 SELECT COUNT(1) FROM reservation WHERE classroom_id = #{classroomId} AND status IN (1, 2) AND start_time < #{endTime} AND end_time > #{startTime};

COUNT(1)比COUNT(*)在语义上更明确,性能差异可以忽略。status IN (1, 2)这个条件不能少,否则被拒绝的申请也会挡住后来人。start_time < #{endTime}用的是严格小于,因为「上一场 11:00 结束、下一场 11:00 开始」不算冲突,这是教室排课的真实语义,用<=会把相邻时段误判成冲突。

单条 SQL 解决不了并发。两个请求同时查到COUNT = 0,然后同时插入,数据库里就出现两条重叠记录。解决办法是在事务里先锁住教室这一行,把并发串行化:

@Service public class ReservationService { @Autowired private ClassroomMapper classroomMapper; @Autowired private ReservationMapper reservationMapper; // rollbackFor 显式指定,避免受检异常不回滚 @Transactional(rollbackFor = Exception.class) public void create(ReservationDTO dto) { if (!dto.getEndTime().isAfter(dto.getStartTime())) { throw new BizException("结束时间必须晚于开始时间"); } // 1. 对教室行加排他锁,同一教室的并发请求排队进入 classroomMapper.selectForUpdate(dto.getClassroomId()); // 2. 锁住之后再查冲突,此时结果可信 int conflict = reservationMapper.countConflict( dto.getClassroomId(), dto.getStartTime(), dto.getEndTime()); if (conflict > 0) { throw new BizException("该时段已被占用,请换个时间"); } // 3. 插入,status 默认待审核 reservationMapper.insert(dto.toEntity()); } }

关键在selectForUpdate,对应 SQL 是SELECT id FROM classroom WHERE id = ? FOR UPDATE。FOR UPDATE加的是行级排他锁,事务提交前其他事务对这一行的FOR UPDATE会阻塞,从而保证「检查 + 插入」是一个原子序列。参数说明:锁的是classroom表的行,不是reservation表,因为要防的是「同一教室」的并发,不同教室之间不应该互相阻塞。

这里有个边界要提:FOR UPDATE依赖索引,如果WHERE id = ?没走主键,可能退化成表锁。另外事务范围要尽量短,锁住之后不要在里面做远程调用或文件上传,否则锁持有时间变长,并发直接掉下来。

提示:如果不想引入行锁,也可以给reservation加一个「教室 id + 时间段哈希」的唯一索引做兜底,但哈希冲突和时间粒度会把问题复杂化,课程设计阶段行锁方案更好讲清楚。

4.3 前端表单与接口约定:字段名对不上是最常见的锅

后端返回{"roomNo": "A301"},前端读row.room_no,页面就是空白。这类问题占联调时间的一半以上,根源是命名风格没统一。后端的 Java 属性是驼峰,数据库字段是下划线,JSON 序列化默认输出驼峰,前端如果按下划线取值就会undefined。

约定比配置更重要。我一般固定三条:接口出入参一律驼峰;日期时间统一用yyyy-MM-dd HH:mm:ss字符串传输,不用时间戳;布尔值不用 0/1,用true/false。第三条在课程设计里经常被忽略,status用 1/2/3 表示状态没问题,但enabled这类字段用1传到前端,JS 里if (row.enabled)恒为真,因为1是真值、0也是真值(非空字符串场景除外),这个坑很典型。

日期格式化的配置:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai serialization: write-dates-as-timestamps: false

write-dates-as-timestamps: false不加的话,LocalDateTime会序列化成数组[2024,5,1,9,0],前端拿到一脸懵。time-zone要和 JDBC URL 里的serverTimezone保持一致,两处不一致等于没配。

前端提交前做一次本地校验,能挡掉大部分无效请求:

// 提交预约前的前端校验,减少无效请求,但后端必须再校验一遍 function validateForm(form) { if (!form.classroomId) return '请选择教室'; if (!form.startTime || !form.endTime) return '请选择时间段'; const start = new Date(form.startTime.replace(/-/g, '/')); const end = new Date(form.endTime.replace(/-/g, '/')); if (end <= start) return '结束时间必须晚于开始时间'; // 单次预约不超过 4 小时,业务规则写在前端体验更好 if ((end - start) / 3600000 > 4) return '单次预约不能超过 4 小时'; return null; }

replace(/-/g, '/')是为了兼容部分浏览器对yyyy-MM-dd解析不一致的问题,Safari 早期版本对横线格式的new Date()会返回Invalid Date。前端校验只是体验优化,后端那层if (!dto.getEndTime().isAfter(dto.getStartTime()))不能删,因为请求可以绕过页面直接发。

5. 五个高频翻车现场:从乱码到重复预约的排查手册

跑通之后的问题才是真问题。下面五条是我在这个场景里反复遇到的,每条按「现象 → 原因 → 解决」写,能直接对照排查。

5.1 中文变问号或乱码

现象:页面上的教室名显示成???或教室,数据库里查出来也不对。

原因:三层编码不一致。数据库/表/连接三处的字符集没对齐,或者导入脚本时客户端编码不是utf8mb4。教室这种是典型的「UTF-8 字节被当成 Latin1 解读」,说明写入端是 UTF-8、读取端按 Latin1 解码。

解决:先查三处字符集,再逐层修。

-- 查库、表、列的字符集,定位是哪一层不对 SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA; SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'classroom_db'; SHOW VARIABLES LIKE 'character_set%';

如果表已经是 latin1,用ALTER TABLE classroom CONVERT TO CHARACTER SET utf8mb4;转换,注意这会重建表,先备份。连接层的问题看 JDBC URL 里有没有characterEncoding=utf8,以及命令行导入时有没有加--default-character-set=utf8mb4。三层都对齐后重启应用,已经写坏的旧数据要重新导入,字符集转换救不回已经变成问号的内容。

5.2 删除教室时报 1451,插入预约时报 1452

现象:管理页面删教室,后端抛Cannot delete or update a parent row;或者新增预约提示Cannot add or update a child row。

原因:前者是外键RESTRICT生效,教室被预约表引用;后者是插入的classroom_id或user_id在父表里不存在,常见于前端传了字符串"null"或者 id 对不上。

解决:删除改成逻辑删除,把classroom.status置 0,列表查询加status = 1过滤,物理删除只留给确认无引用的数据。插入前在 Service 层校验父记录存在,不要依赖数据库报错给用户看。如果确实要保留物理删除,把外键改成ON DELETE SET NULL并把classroom_id设为可空,但这会让历史预约失去教室信息,不推荐。

5.3 时间差 8 小时或差一天

现象:页面上选的 9:00,存进库变成 1:00;或者列表里显示的日期比实际早一天。

原因:时区链路没打通。数据库会话时区、JDBC 连接时区、Jackson 序列化时区三者任一不一致都会偏移。差一天的情况多出现在用java.util.Date加yyyy-MM-dd格式化时,Date带时区信息,跨时区格式化会跨日。

解决:统一用LocalDateTime代替Date,JDBC URL 加serverTimezone=Asia/Shanghai,Jackson 配time-zone: Asia/Shanghai,数据库服务器时区用SELECT @@global.time_zone, @@session.time_zone;确认。三处都设成东八区后,剩下唯一的变量是前端,前端不要自己做时区转换,原样传字符串即可。

5.4 前端请求 403 或 CORS 报错

现象:前后端分离启动后,浏览器控制台报has been blocked by CORS policy,或者所有 POST 请求 403。

原因:后端没配跨域,或者配了但没允许Content-Type: application/json触发的预检请求。403 还可能是 Spring Security 默认拦截了所有请求,而包里的配置类没放行接口。

解决:开发环境用全局跨域配置,生产环境交给网关。Spring Boot 里加一个配置类即可,注意allowedOrigins不要图省事写"*"同时带allowCredentials(true),这两个组合在规范里不允许,会被浏览器拒绝。如果包里带了 Spring Security,检查SecurityFilterChain是否对/api/**做了permitAll,登录接口和静态资源要单独放行。

5.5 压测时出现同一时段两条预约

现象:单人点没问题,用两个浏览器同时提交同一教室同一时段,两条都成功了。

原因:先查后插不是原子操作,两个事务都读到COUNT = 0。默认隔离级别REPEATABLE READ下,普通SELECT是快照读,看不到对方未提交的插入,所以拦不住。

解决:按 4.2 的方案,在事务里先SELECT ... FOR UPDATE锁教室行,再查冲突再插入。验证方法是开两个数据库会话手动模拟:会话 A 执行BEGIN; SELECT * FROM classroom WHERE id = 1 FOR UPDATE;不提交,会话 B 执行同样的语句会阻塞,说明锁生效。另外确认表引擎是 InnoDB,MyISAM 只有表锁,行为和预期不一致。

注意:FOR UPDATE必须在事务里才有效,如果方法上没加@Transactional,锁会在语句结束后立即释放,等于没加。

6. 答辩前把系统再抬一档:三个小改动让设计说明站得住

课程设计的评分差距往往不在功能多少,而在「能不能说清楚为什么」。答辩前留半天做三件事,成本很低,但能让整个设计从「跑通的作业」变成「有思考的系统」。

第一件,补一张操作日志表。结构极简:id、user_id、action、target_id、create_time。在预约创建、审核通过、取消三个入口各记一条。这张表本身不复杂,但它在答辩时能撑起两个话题:一是「审计」,说明你知道业务数据要留痕;二是「写入放大」,说明你意识到每次业务操作会多一次插入,可以考虑异步落库。表格设计如下:

字段类型说明
idBIGINT自增主键
user_idBIGINT操作人,外键到sys_user
actionVARCHAR(32)如RESERVE_CREATE、RESERVE_AUDIT
target_idBIGINT被操作对象 id,不建外键,避免多态引用
create_timeDATETIME默认CURRENT_TIMESTAMP

target_id故意不建外键,因为它可能指向reservation也可能指向classroom,多态外键在关系库里没有标准做法,这一点能主动说出来,比被问住强。

第二件,用一个视图把「教室使用率」固化下来。视图的好处是把复杂查询封装成一张「表」,前端或报表直接查视图,不用在 Java 里拼 SQL:

CREATE OR REPLACE VIEW v_room_usage AS SELECT c.id AS classroom_id, c.room_no AS room_no, c.building AS building, COUNT(r.id) AS reserve_cnt, IFNULL(SUM(TIMESTAMPDIFF(MINUTE, r.start_time, r.end_time)), 0) AS total_minutes FROM classroom c LEFT JOIN reservation r ON r.classroom_id = c.id AND r.status = 2 GROUP BY c.id, c.room_no, c.building;

LEFT JOIN保证没有预约的教室也出现在结果里,COUNT(r.id)而不是COUNT(*)才能正确返回 0。TIMESTAMPDIFF(MINUTE, ...)算出总时长,除以教室开放总时长就是使用率。视图的代价是每次查询都实时计算,数据量大时要改成定时汇总表,这个取舍可以在答辩时主动提一句。

第三件,把EXPLAIN结果截图放进说明文档。挑冲突检测那条 SQL,在reservation表里灌两千条测试数据,执行EXPLAIN SELECT ...,重点看type是不是ref、key是不是用上了idx_room_time、rows估算多少。这一步的意义是让「我建了索引」变成「我验证了索引生效」。如果发现type是ALL,说明索引没命中,回头检查联合索引的字段顺序和查询条件是否匹配。

最后说个我自己的习惯。从那以后我每次交这类课程设计前,都会把数据库导出成一份干净的.sql,在一个全新的库名里完整导入一遍,再用一个普通权限账号(不是 root)跑一遍主流程。这么做是因为吃过一次亏:本地库是几个月前建的,字符集和现在脚本里的不一致,导出的脚本在别人机器上跑出来全是乱码,而我自己机器上一切正常。用新库加普通账号复现一遍,能把「只在你这台机器上成立」的问题提前暴露出来。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进