☰
Java Web教务系统实战:Spring Boot + MyBatis从建表到部署全解析
2026/10/1 5:11:38 网站建设 项目流程

简介:基于Java+SSM+Vue构建的数据学院教务管理系统完整源码,面向课程设计、毕业设计及SSM初学者,覆盖用户信息管理、图片素材与视频素材维护等核心模块。前端使用Vue+ElementUI,后端整合Spring、SpringMVC、MyBatisPlus与MySQL,目录结构和接口设计清晰,导入开发环境即可调试。压缩包共465个文件、约8.88MB,其中含128个java源文件、49个vue组件、162个svg图标、24个js脚本,并附带SQL初始化脚本和Word版设计文档,便于快速还原数据库并核对系统设计。已有176人学习下载,采用JDK1.8与MySQL5.7即可运行,是理解完整前后端分离项目实现流程的实用参考。

1. 数据学院教务系统:比想象中更值得自己动手做一遍的Java Web项目

如果你正在找毕业设计、课程设计或面试项目,数据学院教务管理系统几乎是最稳妥的选择。它不是一个"看起来简单做起来坑多"的玩具项目,而是一套完整的Java Web工程:有数据库设计、有角色权限、有选课和成绩这种带并发含义的业务场景。做一遍下来,Spring Boot、MyBatis、前端页面和数据表设计四个环节会全部打通——这也是它常年排在javavweb源码热搜里的原因。

这个系统解决的问题很具体:学生选课、教师录入成绩、教务管理员排课和统计。三套角色共用一套代码,天然逼着你做权限控制和业务隔离。本文会按"选型→建表→写代码→踩坑→部署"的顺序讲,所有代码都是可直接用的写法。先说明白一件事:技术不必追新,把Spring Boot + MyBatis + MySQL这套主线做透,比堆一堆框架名有用得多。

2. 技术选型和数据库设计:先想清楚数据关系,再谈写代码

2.1 为什么Java Web方向选了Spring Boot而不是SSH或Servlet

数据学院教务系统涉及多角色、多实体的数据关联,用纯Servlet手写会陷入请求转发和参数封装的重复劳动。Spring Boot胜在三点:内置Tomcat,一行命令启动;Starter机制省去大半XML配置;配合MyBatis写SQL时,复杂关联查询能直接看到语句,调试起来比JPA的自动生成SQL更直观。

很多教程推荐Spring Boot 2.x配JDK 8,生产环境确实也大量存在这个组合。我这里按目前课程设计和面试中更常见的版本写:JDK 8 + Spring Boot 2.7.x + MyBatis 3.5.x + MySQL 5.7。版本不必纠结,你只要能说清楚"为什么用这个配置"就够了。如果你的Java环境已经装到JDK 17,把Spring Boot换到3.x同样能跑通,但注意3.x要求Jakarta命名空间,代码里javax.servlet要整体替换成jakarta.servlet。

前端方面,教务系统这类增删改查密集的项目,用Thymeleaf服务端渲染比前后端分离省事得多——不需要处理跨域,不需要联调接口协议,Session天然可用。如果你想把代码做成面试亮点,也可以把前端换成Vue + Axios,后端写REST接口,但本文的代码基于Thymeleaf,读者拿到就能跑。

2.2 核心表结构:六张表把教务闭环撑起来

数据库设计是教务系统的地基。我刚做这个项目时先把所有字段塞进一张大表里,后来加一个"老师查看自己课程"的功能改了三层代码。正确做法是拆表,让每个业务实体各归其位。

学生表、教师表、管理员表可以共用一张用户表加role字段区分,也可以拆成三张。我倾向于拆成用户表加扩展信息表——教务系统的角色边界清晰,但学生有班级、教师有职称,字段差异大。别过度设计,五到六张表刚好够用。核心表如下:

表名关键字段作用
sys_userid, username, password, role统一登录入口,role区分角色
student_infouser_id, student_no, class_name学生扩展信息
teacher_infouser_id, teacher_no, title教师扩展信息
courseid, course_name, teacher_id, credit, capacity课程基本信息和选课容量
course_selectionid, course_id, student_id, score选课记录和成绩存储
semesterid, name, current学期管理,控制选课开关

设计时有两个字段容易被忽略。第一个是course_selection里的score,默认值设为NULL而不是0——NULL表示未录入成绩,0会被统计函数当成真实分数,期评出来一堆零分。第二个是capacity容量字段,选课逻辑里必须用Update语句做库存扣减,否则并发下超卖。

2.3 建表SQL脚本:直接可执行的版本

把SQL设计写成可执行脚本,是这个项目从"想法"变成"代码"的第一步。按下面脚本建库建表,后续所有代码都可以直接对接,无需改字段名。

CREATE DATABASE IF NOT EXISTS edu_admin DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE edu_admin; CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(100) NOT NULL, role TINYINT NOT NULL COMMENT '1-学生 2-教师 3-管理员', status TINYINT DEFAULT 1 COMMENT '1-正常 0-禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, teacher_id INT NOT NULL COMMENT '关联sys_user.id', credit DECIMAL(3,1) DEFAULT 2.0, capacity INT DEFAULT 50, selected_count INT DEFAULT 0, semester_id INT NOT NULL, status TINYINT DEFAULT 1 COMMENT '1-可选 0-不可选' ) ENGINE=InnoDB; CREATE TABLE course_selection ( id INT PRIMARY KEY AUTO_INCREMENT, course_id INT NOT NULL, student_id INT NOT NULL COMMENT '关联student_info.id', score DECIMAL(5,2) DEFAULT NULL, UNIQUE KEY uk_course_student (course_id, student_id) ) ENGINE=InnoDB;

三个设计点值得说明。第一,course_selection加唯一索引uk_course_student,从数据库层面拦住重复选课,不必依赖业务代码判断。第二,course表里放selected_count字段,这是选课并发控制的钥匙——判断容量和增加计数必须是同一个Update语句,不能先Select判断再Insert。第三,sys_user.role用Tinyint存数值,比存字符串更省空间,也别用ENUM,后面加角色时改表结构麻烦。

3. 把核心功能一块块落地:从登录到成绩统计的完整代码

3.1 登录鉴权与角色路由:Filter拦截还是拦截器

教务系统三套角色共用一个登录入口,登录成功后要跳转到不同的首页。这里用Spring Boot拦截器实现顶层权限控制,不用Shiro或Spring Security——课程设计阶段用Shiro要写配置类和权限注解,复杂度全部堆在前期,反而把业务代码的篇幅挤掉了。

先写拦截器,对所有请求做登录校验和角色校验:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); SysUser user = (SysUser) session.getAttribute("loginUser"); if (user == null) { response.sendRedirect("/login"); return false; } // 角色路径匹配:学生访问教师页面直接拒绝 String uri = request.getRequestURI(); if (uri.startsWith("/teacher/") && user.getRole() != 2) { response.setStatus(HttpStatus.FORBIDDEN.value()); response.getWriter().write("无权限访问"); return false; } return true; } }

这段逻辑里最关键的是把登录用户对象塞进Session,后续所有页面用session.getAttribute("loginUser")取当前身份。我踩过一个坑:只拦截了.do结尾的请求,静态资源没放行,导致首页CSS全丢。注册拦截器时要显式放行static目录。

注册拦截器并加上密码处理。密码不能明文存储,用MD5加盐虽然不够强,但课程设计阶段是共识做法,面试时能主动提"生产环境应换BCrypt"反而是加分项:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/doLogin", "/static/**", "/error"); } }

登录逻辑里要注意密码加密的方式要和注册时一致。我见过很多项目改了加密算法却忘了重设已有用户密码,导致所有老用户登录失败。处理方法见后面的避坑章节。

3.2 学生选课:并发安全的库存扣减SQL

选课是教务系统里最需要"写对"的功能。新手写法是查出课程剩余容量,Java里判断大于0,再Insert选课记录。这个写法在单机低并发下没问题,一旦多人同时选同一门课,剩余容量判断就穿透了。

正确的改进是让数据库来完成判断和扣减。一条Update语句同时做容量检查和自减:

UPDATE course SET selected_count = selected_count + 1 WHERE id = #{courseId} AND selected_count < capacity

这条语句影响行数为1时说明扣减成功,为0说明课程已满。但还有个漏洞:如果学生对同一门课重复提交,selected_count依然会加一,而选课记录因为唯一索引报错。所以还要在业务代码里先查一次是否已选过,或者用INSERT ... SELECT配合条件子查询。这里给出MyBatis的Mapper写法:

<insert id="insertSelection"> INSERT INTO course_selection (course_id, student_id) SELECT #{courseId}, #{studentId} FROM dual WHERE NOT EXISTS ( SELECT 1 FROM course_selection WHERE course_id = #{courseId} AND student_id = #{studentId} ) </insert>

这段SQL从dual表开始查询,NOT EXISTS保证重复选课时插入失败且不新增记录。配合前面的Update语句,两条SQL一起放在Service方法里,用@Transactional包住。但注意事务并不能解决并发——两个事务同时执行Update和Insert时,行锁会依次释放,最终数据库的数据不会出错,只是资源正好满时后到的学生可能选课失败但拿到的是成功页面。要彻底解决,可以在事务里先SELECT ... FOR UPDATE锁行,课程设计做到这一步已经超过大部分人的水平了。

3.3 教师录入成绩:批量更新和自己踩过的N+1坑

教师端的最核心操作是录入成绩。一个教师带一门课,学生几十人,最常见做法是在成绩列表页直接录入,然后一次性提交。后端用批量更新减少数据库交互:

<update id="batchUpdateScore" parameterType="list"> <foreach collection="list" item="item" separator=";"> UPDATE course_selection SET score = #{item.score} WHERE id = #{item.id} AND course_id = #{item.courseId} </foreach> </update>

注意separator=";"会把多条Update拼成一条SQL字符串。MySQL默认连接参数里要带allowMultiQueries=true,否则驱动报错;但安全加固时这个参数又不被推荐开启。所以我一般改成另一种写法,用CASE WHEN合并成一条语句。不过那会损失可读性,这里保留多查询写法,部署时在连接串加参数即可。

录入成绩功能还要做权限校验:这个成绩是不是当前教师的课程的学生。上面的SQL里AND course_id = #{item.courseId}隐藏校验,但更稳妥的是在Service层先查一次课程归属。

3.4 管理员统计分析:三个SQL分组触及教务看板

管理员的首页通常是数据看板:总学生数、总课程数、各学期选课人数。这个统计功能用MyBatis写动态SQL很顺手:

SELECT c.semester_id, c.course_name, COUNT(cs.id) AS student_count FROM course c LEFT JOIN course_selection cs ON c.id = cs.course_id GROUP BY c.id, c.course_name ORDER BY c.semester_id

这里必须用LEFT JOIN而不是INNER JOIN——INNER JOIN会丢掉没有学生选的课程,看板数据比实际少。GROUP BY里要带上c.course_name,因为c.id相同但course_name不同会报only_full_group_by错误。MySQL 5.7默认打开这个模式,SQL里少一个GROUP BY字段直接报错,这是最常见的翻车原因。

这条统计SQL返回的多行数据要在Service层封装成Map<String, Object>,前端用JavaScript的ECharts渲染成柱状图。ECharts体积不大,从CDN引入即可,注意服务器离线环境就下载到本地static目录。

4. 教务系统源码调试中的五个必踩坑:现象、原因、解决办法

4.1 选课超卖:数据库字段里出现负数容量

现象:选课高峰期,课程容量为0时还能继续选进学生,selected_count超过capacity甚至出现负数。

原因:我在3.2节已经埋了伏笔——没有用Update条件语句做扣减。初级实现里先Select查容量,再判断、再Insert,三步之间被线程插队,多个请求同时读到剩余容量都是1,全部通过校验。数据库行锁只有在写操作时才生效,读操作不加锁。

解决:扣减和判断放到同一条Update里完成。代码在3.2节已经给出。还要注意,事务里多个Update之间别人读到的可能还是旧值,所以容量显示功能用普通Select问题不大,扣减操作一定要用条件Update。

4.2 时间字段的时区错乱:数据库存的和页面展示差8小时

现象:成绩单的提交时间在页面上显示比实际慢了8小时,日志里打印的当前时间正常。

原因:JDBC连接串里没设置时区,MySQL驱动默认用服务器时区,如果MySQL装在中国地区但JVM默认时区是UTC就会差8个小时。这是Java Web项目血泪经验里出镜率极高的问题。

解决:在application.yml的JDBC URL上追加参数。

spring: datasource: url: jdbc:mysql://localhost:3306/edu_admin?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

注意useUnicode=true必须配characterEncoding=utf8才能处理中文。低版本MySQL驱动还要额外加useSSL=false,不然启动时打一堆警告日志。

4.3 文件上传功能本地正常、部署后失效:绝对路径的锅

现象:学生头像上传后,本地访问正常,部署到服务器后图片404。

原因:代码里把上传路径写成了/usr/local/uploads之类的一本道路径。本机跑和服务器文件系统结构不同,路径不存在时File对象会自动创建吗?不会。而且Java Web打包成Jar后,项目内相对路径是不能写入文件的,实际写入位置和页面读取位置不一致。

解决:把上传目录配置化,用路径清理代替字符串拼接。Controller里建议这样处理:

String uploadDir = System.getProperty("user.dir") + File.separator + "uploads"; File dir = new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); }

然后在静态资源映射里把这个本地目录暴露出去:

@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceLocations("file:" + uploadDir + File.separator); }

上线时把user.dir换成环境变量指定的目录,例如/data/edu_uploads。这个坑的核心教训是:永远不要在代码里拼写绝对路径。

4.4 MyBatis的${}和#{ }用混:SQL注入防护

现象:搜索学生姓名时,输入' OR '1'='1返回了全表数据,或者直接报SQL语法错误。

原因:Mapper里的SQL写成了WHERE student_name LIKE '%${keyword}%'。${}是字符串拼接,用户输入被当成SQL语法执行。项目虽然只做课程设计,但安全习惯得从一开始养。

解决:LIKE查询需要用#{ }加函数拼接:

<select id="searchStudent" resultType="StudentInfo"> SELECT * FROM student_info WHERE student_name LIKE CONCAT('%', #{keyword}, '%') </select>

#{ }会预编译参数,输入内容里的单引号被转义,SQL注入失去效果。同时,数据访问层的所有入参都接受这样的预编译处理。第三章节里的选课重复校验代码也是全参数化的。

4.5 2023年后的新坑:Spring Boot 3.0迁移时包名替换

现象:网上复制Spring Boot 2.x代码到3.x项目里,import javax.servlet.http.HttpSession直接红线编译不过。

原因:Spring Boot 3.0底层容器从Tomcat 9升级到Tomcat 10,Servlet标准从javax迁到Jakarta命名空间。代码层面只是包名变化,但改起来涉及所有Controller和拦截器,新手容易卡住。

解决:如果是新项目且想用JDK 17,直接搜jakarta.servlet.http.HttpSession替换。如果项目要部署到老服务器,或者你习惯看教程代码,Spring Boot 2.7.x是更稳的选择。这里给出我的偏好:课程设计选2.7.x,面试聊版本升级时能说出上面的原因就够了。

5. 从源码变成可交付的系统:配置、打包和部署

5.1 把application.yml写完整:开发与生产的环境区分

很多课程设计代码的配置文件只有几行,跑起来就完事。若真要交付给学院用,配置还需要更完整。我一般这样组织:

5.2 Maven打包和三种部署方式对照

打可执行Jar包在项目根目录执行:

mvn clean package -DskipTests

生成的Jar在target/目录下。三种部署方式各有适合场景:

方式命令适合场景
开发调试mvn spring-boot:run本地联调
服务器部署java -jar edu-admin.jar后台常驻
守护进程部署systemctl start edu-admin学院正式环境

这里选择java命令部署,配合nohup后台运行:

nohup java -jar edu-admin.jar --spring.profiles.active=prod > app.log 2>&1 &

前台的Jar包部署方式对服务器内存要求不高,因为Spring Boot内嵌Tomcat,不需要单独安装Web容器。但生产环境建议加一层Nginx做反向代理,动静分离也能缓解Tomcat的压力。Nginx配置里最关键的一段:

location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

注意proxy_set_header这几个头。缺少X-Real-IP会导致后端的日志全记录成127.0.0.1,排查问题时看到的全是代理地址,根本定位不到真实访客。若是跨域配置没做,前端页面调接口也会被同源策略拦下。

5.3 最省心的一次性启动脚本

写一个shell脚本,把打包、迁移数据库变更和启动串起来:

#!/bin/bash APP_NAME=edu-admin JAR_FILE=target/${APP_NAME}.jar LOG_FILE=/data/logs/${APP_NAME}.log # 按当前代码重新打包 mvn clean package -DskipTests # 停掉旧进程 pid=$(ps -ef | grep ${JAR_FILE} | grep -v grep | awk '{print $2}') if [ -n "$pid" ]; then kill -9 $pid fi # 启动新版本 nohup java -jar ${JAR_FILE} --spring.profiles.active=prod >> ${LOG_FILE} 2>&1 & echo "deploy done, pid: $!"

脚本逻辑不复杂,但解决了一个实际问题:重复部署时端口被老进程占住,java -jar启动直接失败。grep -v grep防止把脚本自己的进程也匹配进去,awk '{print $2}'取PID。日志输出到固定文件,出了问题直接tail -f看。

Windows环境下没有shell脚本,但我建议课程设计用IDEA里直接mvn package然后java -jar,或者用Jenkins来做自动化,这一步不是必需项。

6. 进阶技巧:给教务系统加统一日志审计,排查问题不再靠猜

做了上面五章,系统已经能完整跑通,但离"好用"还差一个关键环节——出问题时能快速定位是谁在什么时间做了什么操作。教务系统的敏感操作很多:管理员改成绩、老师改分数、学生退课。我习惯用Spring AOP做一个操作日志切面,对指定方法开启日志记录。

核心思路是自定义一个注解@OpLog,标注在需要审计的Controller方法上,再用AOP拦截标注了注解的方法,把入参、用户、时间写进日志表:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OpLog { String value() default ""; }
@Aspect @Component public class OpLogAspect { @Autowired private OpLogMapper opLogMapper; @Around("@annotation(opLog)") public Object around(ProceedingJoinPoint pjp, OpLog opLog) throws Throwable { long start = System.currentTimeMillis(); Object result = pjp.proceed(); long cost = System.currentTimeMillis() - start; // 从RequestContextHolder拿当前登录人,ThreadLocal里存着 ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attrs.getRequest(); HttpSession session = request.getSession(); SysUser user = (SysUser) session.getAttribute("loginUser"); OpLogEntity entity = new OpLogEntity(); entity.setUserId(user.getId()); entity.setMethod(pjp.getSignature().getName()); entity.setOperate(opLog.value()); entity.setCostMs(cost); entity.setIp(getIpAddr(request)); opLogMapper.insert(entity); return result; } }

这段代码踩过的一个坑是pjp.proceed()返回后可能被正常返回,也可能被异常打断。上面写法只记录了成功场景,异常场景方法进不来,日志会丢。要记录失败操作得用try-catch包住proceed(),catch里同样写入日志,再重新抛出异常。不然系统出问题时日志里一片空白,只能翻服务器日志猜原因。

对应建一张日志表op_log,字段有id, user_id, method_name, operate_desc, cost_ms, ip_addr, create_time。只记录核心操作,不要所有查询都加注解——那会把一张表灌爆,而且实际排查问题也用不上"谁看了列表页"这种信息。

我用这个技巧是因为吃过亏:学院反馈"有学生成绩被改了但不知道是谁改的",数据库里只有成绩字段变了,没有痕迹。加了AOP审计后,这类问题五分钟内能定位到人。技巧本身不复杂,但能在面试时展示你考虑过"可观测性"这个生产环境才会逼你面对的问题。项目做到这里,从头到尾已经是一个完整的Java Web作品了。希望帮到你。

最后再分享一个习惯:走上生产环境前,把配置文件里的密码、密钥换成环境变量引用,代码仓库里不放任何真实密码。这个习惯能在后续排查问题时省下大量相互扯皮的精力。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询