简介:面向高校计算机相关专业学生的这份数据库课程设计资料,围绕教务管理系统,以 MySQL 与 Java 完成需求分析、数据库设计、系统实现及测试,适合数据库原理、Java 课程设计参考与动手实践。压缩包共 28 个文件、约 4.45MB,含 9 个 Java 源文件、9 个 class 文件、2 个 properties 配置、2 个 jar 包、1 个 SQL 脚本及 project、classpath 等工程文件;SQL 脚本可用于建库建表,源码与编译文件便于对照阅读,配置和依赖文件有助于导入 IDE 后复现工程。已有 2649 人学习,说明其作为课程设计样例具有一定参考度。读者可从中了解教务管理场景下的库表设计、功能划分和 Java 连接 MySQL 的实现思路,并借助脚本与工程配置复现实验环境,在修改完善中形成个性化方案,同时参考报告的组织方式整理需求分析、ER 图、关系模式与测试记录,积累数据库应用开发与课程设计写作经验。
1. 答辩前先问一句:两个人抢最后一门课,你的库会不会超卖
答辩现场被问得最多的不是页面好不好看,而是“两个人同时点选最后一门课,你的库会不会超卖”。这套数据库课程设计-教务管理系统用 MySQL 做数据底座、Java 做业务层,把学生、教师、课程、选课、成绩、课表串成一条能跑通的链路。它适合正在做数据库课程设计、需要一套可运行源码和建表脚本的人,也适合刚接触 JDBC/MyBatis 事务、想把增删改查升级成带约束业务系统的开发者。资源里能直接对照的是表结构、初始化数据、后端接口和几个关键事务写法;能不能用在答辩里,取决于你有没有把约束、并发和权限这三件事讲清楚。下面按建库、接 Java、跑业务、排错、自检的顺序拆。
2. 数据模型先立住:7 张业务表、1 张账号表和 4 个不能省的唯一约束
2.1 从选课流程反推实体关系,别一上来就画大而全的 ER 图
课程设计最容易犯的错,是把“课程”和“教学班”合成一张表。课程是静态的,比如“数据库原理,4 学分,64 学时”;教学班是动态的,比如“2024-2025-1 学期,张老师,周一 1-2 节,容量 60”。同一门课可以开多个教学班,每个班有自己的老师和容量。把这两层拆开,选课、排课、成绩录入才有地方挂。
核心实体我一般会落成 7 张业务表:院系department、学生student、教师teacher、课程course、教学班course_section、选课enrollment、成绩score。再加一张账号表sys_user管登录和角色。enrollment里放学生和教学班的关系,唯一约束(student_id, section_id)直接封死重复选课;score单独一张表,用enrollment_id一对一关联,方便后面加平时分、期末分、总评、绩点、录入人和审核状态。成绩如果塞回enrollment,以后加补考、重修、成绩审核会越来越挤。
账号表不要和学生表、教师表混在一起。sys_user只存用户名、密码哈希、角色和ref_id,ref_id指向学生或教师主键。登录时先查账号,再按角色去对应表取信息。这样学生改手机号不会影响登录,教师离职也可以只停账号不删业务数据。
2.2 建库建表脚本:字符集、引擎、主外键一次到位
下面这段 SQL 是这套课程设计里最该先跑通的部分。我把它压到关键表,字段注释和约束都留着,方便你对着改成自己学校的规则。
CREATE DATABASE IF NOT EXISTS edu_admin DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; USE edu_admin; CREATE TABLE department ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, dept_code VARCHAR(20) NOT NULL, dept_name VARCHAR(100) NOT NULL, UNIQUE KEY uk_dept_code (dept_code) ) ENGINE=InnoDB; CREATE TABLE sys_user ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password_hash VARCHAR(100) NOT NULL COMMENT 'BCrypt 哈希,不存明文', role TINYINT NOT NULL COMMENT '1学生 2教师 3管理员', ref_id BIGINT UNSIGNED NULL COMMENT '关联学生或教师ID', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0停用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username), KEY idx_role_ref (role, ref_id) ) ENGINE=InnoDB; CREATE TABLE student ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL, name VARCHAR(50) NOT NULL, gender TINYINT NOT NULL DEFAULT 0, dept_id BIGINT UNSIGNED NOT NULL, major VARCHAR(100) NOT NULL, grade_year SMALLINT NOT NULL, phone VARCHAR(20) NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1在读 0离校', UNIQUE KEY uk_student_no (student_no), KEY idx_student_dept (dept_id), CONSTRAINT fk_student_dept FOREIGN KEY (dept_id) REFERENCES department(id) ) ENGINE=InnoDB; CREATE TABLE teacher ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, teacher_no VARCHAR(20) NOT NULL, name VARCHAR(50) NOT NULL, dept_id BIGINT UNSIGNED NOT NULL, title VARCHAR(30) NULL, status TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_teacher_no (teacher_no), KEY idx_teacher_dept (dept_id), CONSTRAINT fk_teacher_dept FOREIGN KEY (dept_id) REFERENCES department(id) ) ENGINE=InnoDB; CREATE TABLE course ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(20) NOT NULL, course_name VARCHAR(100) NOT NULL, credit DECIMAL(3,1) NOT NULL, hours SMALLINT NOT NULL, course_type TINYINT NOT NULL COMMENT '1必修 2选修', dept_id BIGINT UNSIGNED NOT NULL, UNIQUE KEY uk_course_code (course_code), KEY idx_course_dept (dept_id), CONSTRAINT fk_course_dept FOREIGN KEY (dept_id) REFERENCES department(id) ) ENGINE=InnoDB; CREATE TABLE course_section ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, course_id BIGINT UNSIGNED NOT NULL, teacher_id BIGINT UNSIGNED NOT NULL, semester VARCHAR(20) NOT NULL COMMENT '如 2024-2025-1', day_of_week TINYINT NOT NULL COMMENT '1-7', start_section TINYINT NOT NULL COMMENT '第几节开始', end_section TINYINT NOT NULL COMMENT '第几节结束', start_week TINYINT NOT NULL, end_week TINYINT NOT NULL, classroom VARCHAR(50) NOT NULL, capacity INT NOT NULL DEFAULT 0, selected_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT '1开放 0关闭', UNIQUE KEY uk_section_time (semester, teacher_id, day_of_week, start_section), KEY idx_section_course (course_id), KEY idx_section_teacher (teacher_id), CONSTRAINT fk_section_course FOREIGN KEY (course_id) REFERENCES course(id), CONSTRAINT fk_section_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(id) ) ENGINE=InnoDB; CREATE TABLE enrollment ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, student_id BIGINT UNSIGNED NOT NULL, section_id BIGINT UNSIGNED NOT NULL, enroll_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 1 COMMENT '1已选 2已退', UNIQUE KEY uk_student_section (student_id, section_id), KEY idx_enroll_section (section_id), CONSTRAINT fk_enroll_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_enroll_section FOREIGN KEY (section_id) REFERENCES course_section(id) ) ENGINE=InnoDB; CREATE TABLE score ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, enrollment_id BIGINT UNSIGNED NOT NULL, usual_score DECIMAL(5,2) NULL, exam_score DECIMAL(5,2) NULL, total_score DECIMAL(5,2) NULL, grade_point DECIMAL(3,2) NULL, recorded_by BIGINT UNSIGNED NULL, recorded_at DATETIME NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已提交', UNIQUE KEY uk_score_enrollment (enrollment_id), CONSTRAINT fk_score_enrollment FOREIGN KEY (enrollment_id) REFERENCES enrollment(id) ) ENGINE=InnoDB;这段脚本里有三个点值得对着讲。第一,所有表显式写ENGINE=InnoDB,因为后面要靠事务和行锁,MyISAM 不支持事务,选课扣减会直接翻车。第二,字符集统一utf8mb4,排序规则用utf8mb4_unicode_ci,不要库用一套、表用一套、连接又用一套,否则学生姓名里的生僻字或特殊符号会变问号。第三,外键默认RESTRICT,删学生或删教学班时数据库会拦住你,这是好事,逼着你做软删除,而不是把关联数据删出孤儿记录。
course_section上我加了uk_section_time,用学期、教师、星期、开始节次做唯一约束,防止同一个老师同一时间被排两门课。这个约束不是万能,周次不同、单双周不同仍可能冲突,所以后面第 4 章还会用 SQL 做一次时间重叠检测。课程设计里不要指望一个唯一索引解决所有排课问题,但先把最粗的冲突挡住。
2.3 索引与唯一约束:哪些字段必须加,哪些加了反而拖慢写入
选课表上的uk_student_section (student_id, section_id)是整套设计的命门。它同时承担两个职责:防止同一学生重复选同一教学班;给“查某个学生选了哪些课”提供最左前缀索引。但注意,组合索引的第二列section_id不能单独用来查“某个教学班有哪些学生”,所以我又加了idx_enroll_section (section_id)。这两个索引一个管学生视角,一个管教学班视角,缺一个都会让某条查询走全表。
成绩表上的uk_score_enrollment (enrollment_id)也是同理,一个选课记录只能有一条成绩。不要用“先查再插”的方式在 Java 里判重,并发下两个请求都查不到,然后都插进去。唯一索引是最后一道兜底,Java 里捕获DuplicateKeyException转成业务提示就行。
不是所有字段都值得加索引。gender、status这种低基数字段,单独建索引意义不大,MySQL 优化器大概率不选。student.name做模糊查询LIKE '%张%'也用不上普通索引,课程设计数据量小,别为了显得专业给每个字段都来一条。真正需要盯的是外键列、学期、教学班、学号、课程代码这些出现在WHERE、JOIN、ORDER BY里的字段。
可以用EXPLAIN看一眼学生课表查询有没有走对索引:
EXPLAIN SELECT s.student_no, c.course_name, cs.day_of_week, cs.start_section FROM enrollment e JOIN course_section cs ON cs.id = e.section_id JOIN course c ON c.id = cs.course_id WHERE e.student_id = 1 AND cs.semester = '2024-2025-1' AND e.status = 1;看key列是不是走了uk_student_section或idx_enroll_section,看rows是不是从很小的数字开始。如果type是ALL,说明在扫全表,先检查字段类型是否一致,比如student_id是BIGINT UNSIGNED,Java 传了字符串,或者关联字段字符集不同,索引会直接失效。课程设计里这种隐式转换很常见,尤其是学号被当成VARCHAR又拿数字去查。
2.4 初始化数据与测试账号:造数脚本别写死在 SQL 里
建完表要造测试数据。手写十几条INSERT可以,但要测分页、测容量、测并发,至少得有两三百个学生。我一般用一段 Python 生成 SQL 文件,再一次性导入,比在数据库里写存储过程好改。
import random dept_id = 1 majors = ["软件工程", "计算机科学与技术", "信息管理"] rows = [] for i in range(1, 201): student_no = f"2024{i:04d}" name = f"学生{i:03d}" major = random.choice(majors) rows.append( f"('{student_no}','{name}',{random.randint(0,1)},{dept_id}," f"'{major}',2024,'1380000{i:04d}',1)" ) with open("student_seed.sql", "w", encoding="utf-8") as f: f.write( "INSERT INTO student(student_no,name,gender,dept_id,major,grade_year,phone,status) VALUES\n" ) f.write(",\n".join(rows) + ";\n")这段脚本只做一件事:生成 200 条学生INSERT。student_no用年份加序号,保证唯一;name用“学生001”这种虚构代称,避免把真实姓名带进库里;phone用固定前缀加序号,方便后面查重复。生成后先source student_seed.sql导入,再跑一条SELECT COUNT(*)确认条数。测试账号的密码不要用明文,Java 侧用 BCrypt 生成哈希后写进sys_user.password_hash,SQL 脚本里只放占位哈希。
提示:造数脚本生成的文件不要直接提交到生产库,课程设计演示完把
student_seed.sql和测试账号单独放一个目录,答辩时只导演示数据,避免把几百条垃圾数据带进最终提交包。
3. Java 接库不玄学:连接池、Mapper 与事务边界的 5 个参数
3.1 配置文件:JDBC URL 里四个参数比代码更关键
Java 侧常见做法是 Spring Boot + MyBatis + HikariCP。课程设计不一定非要上微服务,但连接池和事务别省。下面这份application.yml是我会先贴进项目的版本,重点看 URL 和 Hikari 参数。
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/edu_admin?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true username: edu_app password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1700000 connection-init-sql: SELECT 1 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: trueserverTimezone=Asia/Shanghai必须显式写。很多课程设计项目在本机跑没事,换台机器时间就差 8 小时,原因是驱动默认按 UTC 解析DATETIME。useUnicode=true&characterEncoding=utf8配合库表的utf8mb4使用,别只改一半。rewriteBatchedStatements=true是给批量插入成绩用的,MySQL 驱动会把多条INSERT合并,批量写入能快一个量级。useSSL=false只适合本机课程设计,真部署时按环境配证书。
连接池maximum-pool-size不是越大越好。课程设计并发不高,10 个连接足够,connection-timeout给 3 秒,拿不到连接就快速失败,别让请求卡 30 秒。max-lifetime设 1700 秒,小于 MySQL 默认wait_timeout28800 秒,避免连接被服务端单方面断开后客户端还在用死连接。minimum-idle给 2,启动时不用开太多。
3.2 MyBatis 写选课与成绩查询:XML 里少用${}
课程设计里 MyBatis 有两种写法:注解和 XML。简单 CRUD 用注解可以,但选课、成绩、课表这种多表连接,我建议放 XML,SQL 长一点但好改。下面是一个学生课表查询的 Mapper 接口和 XML 片段。
@Mapper public interface EnrollmentMapper { List<StudentSectionVO> selectStudentSections( @Param("studentId") Long studentId, @Param("semester") String semester ); }<select id="selectStudentSections" resultType="com.example.edu.vo.StudentSectionVO"> SELECT c.course_name, c.credit, t.name AS teacherName, cs.day_of_week, cs.start_section, cs.end_section, cs.classroom FROM enrollment e JOIN course_section cs ON cs.id = e.section_id JOIN course c ON c.id = cs.course_id JOIN teacher t ON t.id = cs.teacher_id WHERE e.student_id = #{studentId} AND cs.semester = #{semester} AND e.status = 1 ORDER BY cs.day_of_week, cs.start_section </select>#{}会走预编译,${}是字符串拼接。semester这种来自前端的参数必须用#{},否则就是 SQL 注入练习场。如果排序字段要动态传,比如orderBy,不要直接拼${orderBy},先在 Java 里做白名单,只允许day_of_week、start_section、credit这几个值,再拼进去。resultType里的 VO 字段名和 SQL 别名对上,map-underscore-to-camel-case只能处理下划线转驼峰,teacherName这种别名最好显式写AS。
3.3 选课事务与并发控制:唯一索引加条件更新,别先查后插
选课是整套系统最值得讲的事务点。常见错误写法是先SELECT教学班,判断selected_count < capacity,再INSERT选课,再UPDATE人数。两个请求同时进来,都查到还有名额,都插入成功,容量就超了。正确做法是把容量判断放进UPDATE的WHERE里,让数据库的行锁和条件更新来保证原子性。
@Service public class EnrollmentService { @Transactional(rollbackFor = Exception.class) public void enroll(Long studentId, Long sectionId) { // 先扣容量:只有未满且开放的教学班才会更新成功 int updated = sectionMapper.tryIncreaseSelected(sectionId); if (updated == 0) { throw new BizException("容量已满或课程未开放"); } try { enrollmentMapper.insert(studentId, sectionId); } catch (DuplicateKeyException e) { // 唯一索引兜底,重复选课回滚上面的扣减 throw new BizException("你已经选过这门课"); } } }<update id="tryIncreaseSelected"> UPDATE course_section SET selected_count = selected_count + 1 WHERE id = #{sectionId} AND status = 1 AND selected_count < capacity </update>tryIncreaseSelected返回受影响行数:1 表示扣减成功,0 表示没名额或课程关闭。先扣容量再插入选课,如果插入因为唯一索引失败,@Transactional会把前面的扣减回滚。这里rollbackFor = Exception.class要写上,否则受检异常默认不回滚。方法必须是public,并且从另一个 Bean 调用,别在同一个类里自调用,否则 Spring AOP 代理不生效,事务等于没开。
隔离级别用默认的READ_COMMITTED或REPEATABLE_READ都行,条件更新本身是当前读,不依赖快照。不要在这段代码外面再套一个“先SELECT再判断”的逻辑,那样只会多一次无意义的查询。
3.4 成绩批量写入与精度处理:用 BigDecimal,别用 double
成绩录入一般是一次提交一个教学班几十条记录。逐条INSERT也能跑,但批量写入更稳。下面这段 Service 先算总评和绩点,再批量 upsert。
@Transactional(rollbackFor = Exception.class) public void saveScores(Long sectionId, List<ScoreInput> inputs) { List<Score> list = new ArrayList<>(); for (ScoreInput input : inputs) { BigDecimal usual = input.getUsualScore(); BigDecimal exam = input.getExamScore(); BigDecimal total = usual.multiply(new BigDecimal("0.3")) .add(exam.multiply(new BigDecimal("0.7"))) .setScale(2, RoundingMode.HALF_UP); BigDecimal gp = toGradePoint(total); list.add(new Score(input.getEnrollmentId(), usual, exam, total, gp)); } scoreMapper.batchUpsert(list); }<insert id="batchUpsert"> INSERT INTO score(enrollment_id, usual_score, exam_score, total_score, grade_point, recorded_at, status) VALUES <foreach collection="list" item="it" separator=","> (#{it.enrollmentId}, #{it.usualScore}, #{it.examScore}, #{it.totalScore}, #{it.gradePoint}, NOW(), 1) </foreach> ON DUPLICATE KEY UPDATE usual_score = VALUES(usual_score), exam_score = VALUES(exam_score), total_score = VALUES(total_score), grade_point = VALUES(grade_point), recorded_at = NOW(), status = 1 </insert>BigDecimal的乘法要传字符串构造器,别用new BigDecimal(0.3),那个会带二进制误差。setScale(2, RoundingMode.HALF_UP)明确四舍五入到两位。数据库字段用DECIMAL(5,2),不要用FLOAT或DOUBLE。批量大小控制在 500 条以内,太大容易把 SQL 包撑爆,太小又发挥不出rewriteBatchedStatements的效果。ON DUPLICATE KEY UPDATE依赖uk_score_enrollment,重复录入就是更新,不会插出两条成绩。
4. 四条链路跑通:登录鉴权、选课扣减、成绩录入、课表冲突
4.1 登录与角色鉴权:别把权限只写在前端
课程设计里常见做法是登录成功把role塞进 Session,后端拦截器按 URL 前缀判断。前端隐藏菜单只是体验,后端每个接口都要再校验一次。下面是一个简化的拦截器。
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws IOException { Object role = req.getSession().getAttribute("role"); if (role == null) { resp.setStatus(401); return false; } String uri = req.getRequestURI(); if (uri.startsWith("/admin/") && !Integer.valueOf(3).equals(role)) { resp.setStatus(403); return false; } if (uri.startsWith("/teacher/") && !Integer.valueOf(2).equals(role)) { resp.setStatus(403); return false; } return true; } }登录校验用 BCrypt:
if (!passwordEncoder.matches(rawPassword, user.getPasswordHash())) { throw new BizException("用户名或密码错误"); }passwordEncoder.matches每次都会用盐重新算哈希,不要自己写 MD5。角色判断不要只看前端传的role参数,Session 里的值在登录时写入,后续不可信前端。管理员接口、教师成绩录入接口、学生选课接口分别走不同前缀,拦截器统一拦。课程设计不用上 Spring Security 全套也可以,但至少要有这一层。
4.2 选课链路:容量扣减、重复选课与退课
选课接口的studentId必须从 Session 取,不能从前端请求体里拿。否则 A 学生改个参数就能替 B 学生选课。Controller 里只传sectionId,Service 里从登录上下文取当前学生。
退课是两个动作:把enrollment.status改成 2,把course_section.selected_count减 1。两个动作放同一个事务,并且减 1 要带selected_count > 0条件。
UPDATE enrollment SET status = 2 WHERE id = #{enrollmentId} AND student_id = #{studentId} AND status = 1; UPDATE course_section SET selected_count = selected_count - 1 WHERE id = #{sectionId} AND selected_count > 0;第一条的受影响行数如果是 0,说明这条选课记录不存在、不属于当前学生、或者已经退过,直接返回“退课失败”。第二条防止人数被减成负数。退课接口要允许重复调用不报错,前端点两次不会把人数减两次,因为第一次已经把status改成 2,第二次更新行数为 0。
4.3 成绩录入与绩点计算:事务、校验和审计字段
成绩录入除了算总评,还要做两件事:校验分数范围,记录录入人和时间。分数不在 0 到 100 之间直接拒绝,别等数据库报错。绩点规则各校不同,下面这段是常见的 5 分制写法,实际用的时候按你们教务规则改。
public BigDecimal toGradePoint(BigDecimal total) { if (total.compareTo(new BigDecimal("60")) < 0) { return BigDecimal.ZERO.setScale(2); } BigDecimal gp = total.subtract(new BigDecimal("50")) .divide(new BigDecimal("10"), 2, RoundingMode.HALF_UP); return gp.min(new BigDecimal("5.00")); } private void validateScore(BigDecimal score) { if (score == null || score.compareTo(BigDecimal.ZERO) < 0 || score.compareTo(new BigDecimal("100")) > 0) { throw new BizException("分数必须在 0 到 100 之间"); } }总评 60 分对应绩点 1.0,100 分对应 5.0,低于 60 记 0。如果学校是 4.0 制,不要硬套这个公式,改成查表映射。recorded_by存教师 ID,recorded_at存提交时间,后面答辩被问“成绩谁改的”能答上来。成绩状态从草稿改成已提交后,学生端才可见,教师端再改就要走审核,这个字段虽然课程设计不一定实现,但表里先留着。
4.4 课表查询与时间冲突:别把“周一1-2节”存成字符串
课表冲突检测的前提是时间字段可比较。class_time存成“周一1-2节”看着直观,查冲突时只能全表取出来在 Java 里循环,数据一多就难受。拆成day_of_week、start_section、end_section、start_week、end_week五个字段,SQL 才能用区间重叠判断。
教师排课冲突可以这样查:
SELECT 1 FROM course_section cs WHERE cs.teacher_id = #{teacherId} AND cs.semester = #{semester} AND cs.status = 1 AND cs.day_of_week = #{dayOfWeek} AND NOT (cs.end_section < #{startSection} OR cs.start_section > #{endSection}) AND NOT (cs.end_week < #{startWeek} OR cs.start_week > #{endWeek}) LIMIT 1;两个时间段不重叠的条件是“一个的结束早于另一个的开始,或一个的开始晚于另一个的结束”。取反就是重叠。周次也要同样判断,否则单周课和双周课会被误判冲突。学生课表查询直接把enrollment、course_section、course、teacher连起来,按星期和节次排序,前端按表格渲染。
4.5 分页与导出:别在内存里做全量循环
成绩导出 CSV 时,常见写法是select *查全表,塞进List,再循环写文件。课程设计数据量小看不出问题,但答辩时老师问“如果全校成绩都导呢”,这个写法就答不上来。至少用分页查询,一页 500 条,边查边写。
response.setContentType("text/csv;charset=UTF-8"); response.setHeader("Content-Disposition", "attachment;filename=scores.csv"); try (PrintWriter writer = response.getWriter()) { writer.write('\ufeff'); // 让 Excel 识别 UTF-8 writer.println("学号,姓名,课程,总评,绩点"); int page = 1; while (true) { List<ScoreRow> rows = scoreMapper.selectPage(sectionId, (page - 1) * 500, 500); if (rows.isEmpty()) { break; } for (ScoreRow row : rows) { writer.println(row.getStudentNo() + "," + row.getName() + "," + row.getCourseName() + "," + row.getTotalScore() + "," + row.getGradePoint()); } page++; } }\ufeff是 BOM,不加的话 Excel 打开中文可能乱码。selectPage用LIMIT #{offset}, #{size},不要用LIMIT ${offset}拼字符串。字段里如果有逗号或换行,CSV 要加引号转义,课程设计里姓名和课程名一般没有,但学号是数字时 Excel 会吃掉前导零,导出前可以在学号前加\t或按文本格式处理。
5. 避坑与排查:课程设计里最容易翻车的 5 个现场
5.1 中文乱码与时区错乱:现象是姓名变问号,时间差 8 小时
现象:学生姓名在页面显示???,或者选课时间比实际晚 8 小时。原因通常不是 Java 编码,而是 MySQL 库、表、连接三处字符集不一致,加上 JDBC URL 没写serverTimezone。解决顺序是先查库表,再查连接,最后查驱动。
SHOW VARIABLES LIKE 'character_set%'; SELECT table_name, table_collation FROM information_schema.tables WHERE table_schema = 'edu_admin';character_set_database和character_set_connection都应该是utf8mb4。表排序规则不是utf8mb4_unicode_ci的,用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci改。JDBC URL 补上useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai。如果时间字段用的是TIMESTAMP,还会受服务器时区影响,课程设计里统一用DATETIME更省心。
5.2 外键约束导致删数据失败:报 1451 不是数据库坏了
现象:删除一个学生,控制台报Cannot delete or update a parent row: a foreign key constraint fails,错误码 1451。原因是这个学生在enrollment或score里还有记录,外键默认RESTRICT拦住了。解决不是把外键全删了,而是做软删除:把student.status改成 0,登录时判断状态,列表默认查status=1。
如果确实要物理删除,先删成绩,再删选课,再删学生,三步放同一个事务。顺序反了就会报 1451。ON DELETE CASCADE看着省事,但删学生把成绩一起带走,答辩时被问“成绩审计怎么办”就不好答。课程设计里我一般保留RESTRICT,用状态字段控制业务可见性。
5.3 选课超卖与重复选课:先查后插在并发下一定出问题
现象:容量 60 的教学班选进去 61 个人,或者同一学生出现两条选课记录。原因是 Java 里先SELECT COUNT(*)判断,再INSERT,两个请求同时通过判断。解决靠数据库约束和条件更新,不靠 Java 判断。
-- 查超卖:选课人数大于容量 SELECT cs.id, cs.capacity, COUNT(e.id) AS cnt FROM course_section cs JOIN enrollment e ON e.section_id = cs.id AND e.status = 1 GROUP BY cs.id, cs.capacity HAVING cnt > cs.capacity; -- 查重复:同一学生同一教学班多条有效记录 SELECT student_id, section_id, COUNT(*) AS cnt FROM enrollment WHERE status = 1 GROUP BY student_id, section_id HAVING cnt > 1;第一条查出超卖记录后,不要手动改selected_count了事,要回看选课接口是不是还在用先查后插。第二条如果查出重复,说明唯一索引没建或者被删了,先把重复数据按id保留最早一条、其余置status=2,再补唯一索引。补索引前先清重复,否则建不上。
5.4 连接池耗尽与事务不生效:等 30 秒不是网络慢
现象:页面一直转圈,日志里出现Connection is not available, request timed out after 30000ms;或者方法抛了异常,数据库却没回滚。原因可能是手动拿连接没关、事务方法自调用、异常被catch吞掉,或者异常类型不在回滚范围。
// 反例:异常被吞,事务不会回滚 @Transactional public void wrong() { try { jdbcTemplate.update("UPDATE course_section SET selected_count = selected_count + 1 WHERE id = 1"); int x = 1 / 0; } catch (Exception e) { log.error("扣减失败", e); // 没有继续抛出 } }连接泄漏常见于手写 JDBC 时Connection、Statement、ResultSet没在finally里关。用 Spring 的JdbcTemplate或 MyBatis 一般不会,但自己dataSource.getConnection()就要用 try-with-resources。事务不生效先看方法是不是public,是不是从同类内部调用,再看异常有没有被吞。排查时把 MyBatis 和事务日志级别调到DEBUG,看SqlSession什么时候提交、什么时候回滚。
5.5 成绩小数精度与四舍五入:59.995 到底算不算 60
现象:平时分 59.995,总评存成 59.99 或 60.00,和手算对不上。原因是 Java 用了double,或者数据库字段是FLOAT,二进制浮点存不下十进制小数。解决是全程BigDecimal,数据库用DECIMAL(5,2),计算时明确RoundingMode。
BigDecimal total = usual.multiply(new BigDecimal("0.3")) .add <p> <a href="https://download.csdn.net/download/qq_42146402/88694433" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>