ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java Web教务系统实战:Spring Boot + MyBatis从建表到部署全解析

Java Web教务系统实战:Spring Boot + MyBatis从建表到部署全解析 简介基于JavaSSMVue构建的数据学院教务管理系统完整源码面向课程设计、毕业设计及SSM初学者覆盖用户信息管理、图片素材与视频素材维护等核心模块。前端使用VueElementUI后端整合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 ) ENGINEInnoDB; 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-不可选 ) ENGINEInnoDB; 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) ) ENGINEInnoDB;三个设计点值得说明。第一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 idinsertSelection 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 教师录入成绩批量更新和自己踩过的N1坑教师端的最核心操作是录入成绩。一个教师带一门课学生几十人最常见做法是在成绩列表页直接录入然后一次性提交。后端用批量更新减少数据库交互update idbatchUpdateScore parameterTypelist foreach collectionlist itemitem separator; UPDATE course_selection SET score #{item.score} WHERE id #{item.id} AND course_id #{item.courseId} /foreach /update注意separator;会把多条Update拼成一条SQL字符串。MySQL默认连接参数里要带allowMultiQueriestrue否则驱动报错但安全加固时这个参数又不被推荐开启。所以我一般改成另一种写法用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层封装成MapString, 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai注意useUnicodetrue必须配characterEncodingutf8才能处理中文。低版本MySQL驱动还要额外加useSSLfalse不然启动时打一堆警告日志。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 11返回了全表数据或者直接报SQL语法错误。原因Mapper里的SQL写成了WHERE student_name LIKE %${keyword}%。${}是字符串拼接用户输入被当成SQL语法执行。项目虽然只做课程设计但安全习惯得从一开始养。解决LIKE查询需要用#{ }加函数拼接select idsearchStudent resultTypeStudentInfo 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 10Servlet标准从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.activeprod app.log 21 前台的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_NAMEedu-admin JAR_FILEtarget/${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.activeprod ${LOG_FILE} 21 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作品了。希望帮到你。最后再分享一个习惯走上生产环境前把配置文件里的密码、密钥换成环境变量引用代码仓库里不放任何真实密码。这个习惯能在后续排查问题时省下大量相互扯皮的精力。本文还有配套的精品资源点击获取
返回列表