每年毕业季,计算机专业的选题清单里几乎都能看到"高校毕业实习管理系统"的身影。说实话,这个题目被选中的频率高到有点"烂大街",但它确实是练手SSM框架、理解企业级项目分层结构的经典载体。原因很简单:它角色多、流程长、状态复杂,但业务规则又是大多数人都能理解的——学生找实习、老师批材料、企业给评价、系里出统计。这套CRUD逻辑一旦跑通,你基本就掌握了Java Web开发最核心的那套东西。这篇博文我会从选题价值讲起,把技术选型、业务闭环、表结构设计、核心代码写法到答辩追问一路拆完,尽量把我在辅导毕设过程中看到的那些坑提前帮你填上。
如果你正为"计算机毕设做什么"发愁,或者已经定下这个题目但不知道从哪儿下手,这篇文章可以当作一份完整的设计说明书来用。它不是零散的功能清单,而是从"为什么要这么做"到"代码具体长什么样"的完整推导过程,适合打算用SSM框架做实习管理系统的同学参考。
1. 为什么"实习管理系统"是历年毕设的常青树:需求与选题价值
1.1 高校实习管理到底痛在哪
先别急着写代码,把业务背景搞明白比什么都重要。很多同学拿到题目第一反应是"我先建个库,建几张表",结果做到中期发现角色对不上、流程串不起来,回头再改数据库,折腾得够呛。
高校的毕业实习管理,线下流程通常是这样的:学院发通知、学生找实习单位、企业出具接收证明、学生把材料交回学校、实习过程中要交周报或月报、结束以后企业导师给评价、校内指导老师再给评分、最后系里汇总成绩并归档。这套流程涉及四类角色:学生、校内指导老师、企业导师(或企业联系人)、院系教务管理员。痛点也非常统一:
- 材料靠纸质或邮件来回传递,归档和追溯极麻烦;
- 实习过程是"黑盒",指导老师看不到学生平时的进展;
- 成绩评定标准不一,汇总统计靠Excel来回合并;
- 各角色之间信息不同步,学生催老师、老师问教务、教务找学生,大量沟通成本。
所以一个合格的实习管理系统,本质是给这个"线下流程"做一个数字化闭环:从实习申请、企业审核、学院审批,到过程材料提交、周报月报填写,再到实习结束后的双向评价与成绩归档,每一步都要有据可查。
1.2 为什么说这个选题"难度适中、覆盖面广"
计算机毕设的选题,最怕两种:一种是太简单,全是单表CRUD,答辩时老师问几句就露馅;另一种是太难,比如分布式、高并发,本科阶段既做不透也讲不清。实习管理系统恰好卡在中间。
它的覆盖面体现在技术栈上:SSM框架(Spring + SpringMVC + MyBatis)需要你把IoC容器、AOP事务、MVC路由、ORM映射这几大块全部用起来;业务上要处理四种角色权限(学生、老师、企业、管理员),多对多关系设计,状态的逐级流转,文件上传,分页搜索,以及最后的图表统计。这几乎覆盖了Java Web岗位面试里最常见的知识点。
但它又不至于难到无法驾驭:没有复杂的算法,没有高并发,没有分布式事务,核心还是"围绕一张主表做状态流转 + 若干附属表做过程记录"。哪怕你的编码水平一般,只要把架构理清楚,按照分层规范去写,就能产出一个结构清晰、可以完整运行的系统。答辩时老师看重的,往往不是你写了多少炫技的代码,而是你的业务逻辑是否闭环、表设计是否合理、关键技术点能不能讲明白。
1.3 明确系统边界:先画出角色与功能矩阵
动工之前,我强烈建议你先画一张角色功能矩阵表,把"哪个角色能做什么操作"列清楚。这能帮你避免后期权限混乱。下面是我带毕设时常用的一个基础版本:
| 角色 | 核心操作 | 说明 |
|---|---|---|
| 学生 | 填写/修改实习申请、上传材料、提交周报/月报、查看成绩 | 只能看自己的数据 |
| 校内指导老师 | 审批所属学生的实习申请、审阅周报、给出平时分与终评成绩 | 只能看自己名下的学生 |
| 企业导师/联系人 | 确认学生实习接收、填写企业评价与评分 | 只关联本企业的实习学生 |
| 院系管理员 | 维护用户/专业/班级信息、配置实习周期、处理申请、汇总导出成绩、发布通知 | 全模块可见 |
这张表出来以后,你的实体类和数据库表设计基本就有方向了,后面每一步操作都是在为这些角色服务。
2. 技术选型复盘:SSM框架为何仍是稳妥之选
2.1 SSM的含义与分工
SSM是Spring + SpringMVC + MyBatis的组合,它们在系统中的分工非常清晰:Spring负责对象管理和依赖注入,并把事务控制在Service层;SpringMVC负责HTTP请求的路由分发,也就是Controller层那片东西;MyBatis负责数据库操作,把SQL写到Mapper里,兼顾灵活性和可控性。
很多同学会问,现在企业里都流行Spring Boot了,毕设还有必要用SSM吗?我的看法是,看学校要求。相当一部分院校的课程设计和毕设大纲里,SSM还是默认的技术选型。如果你的学校没有强制要求,Spring Boot + MyBatis也完全可以,主体代码差别并不大,只是少了大量XML配置。
但如果你明确要用SSM,那答辩时被问"为什么不用Spring Boot",你不能只会说"学校让我们用"。比较稳妥的回答是:Spring Boot本质上是"约定大于配置"的快速开发框架,而SSM需要手动管理Spring和SpringMVC的配置整合,能更直观地展示IoC容器、Bean扫描、DispatcherServlet等底层机制,用于毕设这种教学场景更合适;理解了SSM的整合过程,再看Spring Boot就会非常轻松。这个说法既是事实,也能证明你没有回避问题。
2.2 开发环境的具体版本搭配
SSM最坑的不是写代码,而是版本不兼容导致的启动报错。我建议一个相对稳定的组合:
- JDK:1.8(201版本或更高一点,反正不要用JDK 17去跑老项目,Spring 4/5的很多反射逻辑和老ASM库会出问题);
- Maven:3.6.x;
- Tomcat:8.5或9.0,建议8.5,和Servlet 3.1规范匹配度更好;
- MySQL:5.7或8.0,如果学校机器装的是5.5,那你要注意驱动版本和utf8mb4配置;
- IDEA:2020.x以上都行,破解版的别拿出来说就行。
Maven依赖需要注意版本匹配。以Spring 5.1.x为例,spring-webmvc、spring-jdbc、spring-context这几个核心包版本必须一致,MyBatis用3.5.x,mybatis-spring用2.0.x,不然会出现各种NoSuchMethodError。
2.3 前端层怎么选:JSP还是前后端分离
这个话题我必须单独拎出来说,因为每年都有同学在选题阶段幻想着"我要用Vue + Element UI做一套漂亮的前端",然后卡在跨域、Token认证、Nginx配置上,两周出不了活。
如果你的目标是"顺利做完、顺利答辩",JSP + Bootstrap + jQuery依然是最稳的组合。JSP天然支持JSTL标签和EL表达式,后端传一个ModelAndView过去,页面上直接用${xxx}取值,和SpringMVC的兼容性天衣无缝,不需要解决跨域问题。SSM项目里Controller返回String视图名,视图解析器自动找WEB-INF下对应的JSP页面——这套机制本身就是教科书级的。
如果你一定要做前后端分离,请注意时间成本:你得解决CORS跨域(或配置代理)、统一返回Json格式、登录状态用Token还是Session、前端部署到Nginx之后的静态资源路径问题。不是不能做,但至少多花两周,而且答辩时老师更容易追问细节。我的建议很简单:除非你已经非常熟练,否则老老实实用JSP + Layui或Bootstrap,把精力留给后端业务。
2.4 关键技术点清单
为了让心里有底,我列一个"必须会讲清楚"的技术点清单,这些都是答辩高频提问区:
- Spring IoC:Bean是怎么被创建、被注入的,构造器注入与Setter注入的区别;
- Spring AOP:事务注解@Transactional的底层原理,以及声明式事务和编程式事务的区别;
- SpringMVC请求流程:从DispatcherServlet、HandlerMapping、HandlerAdapter到ViewResolver的完整链路;
- MyBatis中#{}和${}的区别,以及为什么#{}能防SQL注入;
- Mapper接口没有实现类,Spring是怎么把代理对象注入进来的;
- 拦截器(Interceptor)与过滤器(Filter)的区别,自定义拦截器如何登录校验。
这些点我会在后面的模块拆解中穿插演示。
3. 从实习申请到成绩评定:全流程业务闭环与状态机设计
3.1 完整的实习生命周期
这个系统的核心不是某一张表的增删改查,而是"实习单"这种主实体从创建到归档的完整生命周期。我把完整流程拆给你看:
- 管理员发布实习任务(设置实习周期、提交材料要求);
- 学生填写实习申请,填报实习单位、岗位、起止时间,上传接收函等材料;
- 企业导师或企业联系人确认接收意向(如果没有企业账号,可以由学院管理员代为确认);
- 校内指导老师审批通过或驳回,驳回时需要填写原因,学生可修改重提;
- 实习开始后,学生按周期提交周报/月报(这是过程记录的来源);
- 实习结束后,企业对学生的表现给出评价与评分;
- 校内指导老师结合平时过程和材料,给出校内评分;
- 管理员对最终成绩进行归档,可导出统计报表。
这里面最容易出错的就是"状态的跳转"。比如有的学生会跳过企业确认直接提交学院审批;有的老师点了个驳回,学生又重复提交了一份一模一样的申请,状态就乱了。所以,状态流转必须由代码统一控制,而不是在前端页面里想怎么调接口就怎么调。
3.2 状态机设计与实现
我把"实习单"的状态简单定义为一个枚举:
public enum InternshipStatus { DRAFT(0, "草稿"), PENDING_COMPANY(1, "待企业确认"), PENDING_TEACHER(2, "待指导老师审批"), REJECTED(3, "已驳回"), ONGOING(4, "实习中"), PENDING_EVALUATION(5, "待评价"), COMPLETED(6, "已完成"), ARCHIVED(7, "已归档"); private final int code; private final String desc; InternshipStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }然后在Service层统一写一个"状态变更"方法,而不是让每个Controller自己改状态字段。核心逻辑如下:
- 学生提交:DRAFT -> PENDING_COMPANY;
- 企业确认:只有PENDING_COMPANY才能到PENDING_TEACHER;
- 教师审批通过:只有PENDING_TEACHER才能到ONGOING;
- 教师审批驳回:只有PENDING_TEACHER才能到REJECTED,并且要记录驳回原因;
- 学生重新提交:REJECTED -> PENDING_COMPANY;
- 学生填写实习结束申请:ONGOING -> PENDING_EVALUATION;
- 企业评分:PENDING_EVALUATION -> 保持PENDING_EVALUATION,但要记录企业已评分;
- 教师评分:企业已评分且教师也评分后,状态转COMPLETED;
- 管理员归档:COMPLETED -> ARCHIVED。
把这套规则封装成一个方法:
public void changeStatus(Long internshipId, int fromStatus, int toStatus, Long operatorId) { Internship internship = internshipMapper.selectById(internshipId); if (internship == null) { throw new BusinessException("实习记录不存在"); } if (internship.getStatus() != fromStatus) { throw new BusinessException("当前状态不允许该操作,请刷新后重试"); } internship.setStatus(toStatus); internshipMapper.updateStatus(internship); // 追加流转日志 InternshipLog log = new InternshipLog(); log.setInternshipId(internshipId); log.setFromStatus(fromStatus); log.setToStatus(toStatus); log.setOperatorId(operatorId); log.setCreateTime(new Date()); internshipLogMapper.insert(log); }注意我随手加了一张流转日志表。答辩时这是亮点:说明你考虑了操作追溯和审计需求。哪怕代码里只是在关键节点记录几条数据,也体现了系统设计的完整度。
3.3 为什么必须做操作日志
很多学生做的版本里没有日志表,导致老师问"某个学生是谁在什么时候审批的""为什么这个单子状态变成这样了",你答不上来。加上internship_log之后,你只需要按实习单ID一查,就能看到完整的时间线,这在学校实际使用场景里是刚需,对答辩也是加分项。
操作日志的设计很简单:主键、实习单ID、操作前状态、操作后状态、操作人ID、操作人角色、操作时间。插入日志的方法和状态变更方法放在同一个事务里,保证不出现"状态改了但日志没记"的情况。这个事务可以由@Transactional注解保证,这个注解的作用范围是方法体,它会把方法内所有数据库操作纳入同一个事务。
4. 数据库设计:避免"一张大表走天下"的坑
4.1 核心表梳理
数据库是毕设的地基,也是答辩时老师喜欢重点看的东西。很多同学图省事,把学生、老师、企业、管理员全塞进一张user表,再靠一个role字段区分,这种做法不是完全不能用,但后期扩展特别痛苦。比如企业导师要维护"所属企业"信息、校内老师要维护"所属专业教研室",这些和用户本身关系不大,硬塞进user表会导致一堆冗余字段。
我建议的表设计如下,保持适度的规范化:
sys_user:系统用户表,包含id、username、password(BCrypt加密)、real_name、phone、email、role_type(0学生 1校内教师 2企业导师 3管理员)、college_id、major_id等;college_info:学院表,字段就不用多说了;major_info:专业表,关联college_id;enterprise_info:企业表,保存企业名称、统一社会信用代码、联系人、联系电话;internship:实习主表,这是全系统的核心,保存学生ID、企业ID、岗位名称、实习开始结束时间、当前状态、驳回原因、材料附件路径等;internship_log:操作日志表,对应上面的状态流转;weekly_report:周报/月报表,关联internship_id,包含标题、内容、提交时间、批阅状态、教师评语;evaluation:成绩评定表,关联internship_id,保存企业评分、教师评分、综合成绩、评语;notice:公告通知表;sys_dict:数据字典表,不一定每个人都需要,但对"实习类型""考核等级"这类有周期性的可选项非常合适。
4.2 实习主表结构示例
实习主表是整个系统最核心的表,它的字段设计直接决定了业务能不能跑通。我给一个可以照着用的SQL:
CREATE TABLE `internship` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `student_id` bigint(20) NOT NULL COMMENT '学生用户ID', `enterprise_id` bigint(20) DEFAULT NULL COMMENT '企业ID', `teacher_id` bigint(20) DEFAULT NULL COMMENT '校内指导老师ID', `position_name` varchar(100) NOT NULL COMMENT '实习岗位', `start_date` date DEFAULT NULL COMMENT '实习开始时间', `end_date` date DEFAULT NULL COMMENT '实习结束时间', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0草稿 1待企业确认 2待审批 3驳回 4实习中 5待评价 6已完成 7已归档', `reject_reason` varchar(500) DEFAULT NULL COMMENT '驳回原因', `apply_file_path` varchar(255) DEFAULT NULL COMMENT '申请材料附件路径', `company_confirm_time` datetime DEFAULT NULL COMMENT '企业确认时间', `teacher_approve_time` datetime DEFAULT NULL COMMENT '教师审批时间', `create_time` datetime NOT NULL COMMENT '创建时间', `update_time` datetime NOT NULL COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_student_status` (`student_id`,`status`), KEY `idx_enterprise` (`enterprise_id`), KEY `idx_teacher` (`teacher_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实习主表';这里有三点要注意:
第一,student_id和status建联合索引,因为学生端最常用的查询就是"我的实习列表"以及按状态过滤,联合索引可以显著加速。
第二,enterprise_id和teacher_id允许为空,因为学生最初提交申请的时候,企业可能还没确定。等企业确认后,再补上企业ID。
第三,所有字段尽量使用utf8mb4字符集。别问为什么,等你遇到学生填了"𠮷"字导致存储失败就会明白。
4.3 为什么把评价单独拆一张表
起初我也想过把企业评分、教师评分字段直接放在internship表里,毕竟一个实习单就对应两个评分,后来发现不行:评价过程是分阶段的,企业先评分,教师后评分,中间间隔可能好几周。如果放在主表里,就会有两种做法:要么让字段一直为空(主表里悬着空值,语义不好);要么等两个分数齐了再更新(那中间状态"企业已评,教师未评"就表达不了)。
拆出evaluation表就清爽多了:
CREATE TABLE `evaluation` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `internship_id` bigint(20) NOT NULL, `enterprise_score` decimal(5,2) DEFAULT NULL COMMENT '企业评分,百分制', `teacher_score` decimal(5,2) DEFAULT NULL COMMENT '教师评分', `enterprise_comment` varchar(500) DEFAULT NULL, `teacher_comment` varchar(500) DEFAULT NULL, `enterprise_submit_time` datetime DEFAULT NULL, `teacher_submit_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_internship` (`internship_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;两条独立的提交时间字段,可以清楚知道谁在什么时候打了分,也方便做"未及时评价"的催办统计。
4.4 权限设计:一表角色字段够不够
关于权限,我的建议是:对于本科毕设,一张sys_user表加一个role_type字段,再配合SpringMVC拦截器做菜单级和接口级的权限判断,完全够用。不要一上来就搞Spring Security + Shiro那套,学习成本高,配置量也大,而且在答辩时很难在五分钟内讲清楚。
实现思路是,登录成功后把用户对象和角色放进Session;自定义拦截器拦截所有需要登录的请求,根据请求路径前缀判断角色:
/student/**需要role_type=0;/teacher/**需要role_type=1;/company/**需要role_type=2;/admin/**需要role_type=3。
拦截器里做得简单一点,只判断角色类型是否匹配,越权直接重定向或返回403。至于更细粒度的数据权限(比如学生只能看到自己的数据),在Service层通过Session里的user_id进行过滤,就够了。
5. 核心模块实现:角色权限与关键业务代码怎么写
5.1 基于拦截器的登录与权限控制
我先说登录加密。密码一定要加密存储,至少用MD5加盐或BCrypt,我推荐BCrypt,因为MD5在脱库场景下几乎等于明文,答辩时老师只要问一句"用户密码怎么存的",你说"加了密"和"用的MD5",层次完全不一样。这里给一个Spring Security里的BCryptPasswordEncoder独立使用示例:
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; public class PasswordUtil { private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder(); public static String encode(String rawPassword) { return ENCODER.encode(rawPassword); } public static boolean matches(String rawPassword, String encodedPassword) { return ENCODER.matches(rawPassword, encodedPassword); } }只需要引入spring-security-crypto这一个依赖即可,不用把Spring Security全家桶引进来。
接下来是自定义拦截器,这个是SSM项目里的标配:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object loginUser = session.getAttribute("loginUser"); if (loginUser == null) { // 未登录,跳转登录页 response.sendRedirect(request.getContextPath() + "/login"); return false; } // 角色路径匹配 Integer roleType = ((SysUser) loginUser).getRoleType(); String uri = request.getRequestURI(); if (uri.startsWith("/student/") && roleType != 0) { response.sendError(403, "无权限访问"); return false; } // 其它角色类似... return true; } }然后在springmvc.xml里注册拦截器,并配置放行路径:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/logout"/> <mvc:exclude-mapping path="/static/**"/> <mvc:exclude-mapping path="/user/login"/> <bean class="com.yourproject.interceptor.AuthInterceptor"/> </mvc:interceptor> </mvc:interceptors>5.2 实习申请的提交与状态流转代码
现在看一个完整的Controller方法。这个方法对应学生的"提交实习申请"操作:
@Controller @RequestMapping("/student/internship") public class StudentInternshipController { @Autowired private InternshipService internshipService; @PostMapping("/submit") public String submit(@ModelAttribute InternshipForm form, @RequestParam(value = "file", required = false) MultipartFile file, HttpSession session) throws IOException { SysUser user = (SysUser) session.getAttribute("loginUser"); // 保存附件,返回存储路径 String filePath = null; if (file != null && !file.isEmpty()) { filePath = FileUploadUtil.save(file, "/uploads/internship"); } // 构造实体并调用Service层 Internship internship = new Internship(); internship.setStudentId(user.getId()); internship.setEnterpriseId(form.getEnterpriseId()); internship.setPositionName(form.getPositionName()); internship.setStartDate(form.getStartDate()); internship.setEndDate(form.getEndDate()); internship.setApplyFilePath(filePath); internship.setStatus(InternshipStatus.PENDING_COMPANY.getCode()); internshipService.applyInternship(internship); return "redirect:/student/internship/list"; } }Service层的逻辑关键在于事务和状态校验:
@Service @Transactional public class InternshipServiceImpl implements InternshipService { @Autowired private InternshipMapper internshipMapper; @Autowired private InternshipLogMapper internshipLogMapper; @Override public void applyInternship(Internship internship) { // 新增实习单,初始状态为待企业确认 internship.setCreateTime(new Date()); internship.setUpdateTime(new Date()); internshipMapper.insert(internship); // 记录日志 InternshipLog log = new InternshipLog(); log.setInternshipId(internship.getId()); log.setFromStatus(InternshipStatus.DRAFT.getCode()); log.setToStatus(InternshipStatus.PENDING_COMPANY.getCode()); log.setOperatorId(internship.getStudentId()); log.setCreateTime(new Date()); internshipLogMapper.insert(log); } @Override public void applyReSubmit(Long internshipId, Long studentId, Internship newInfo) { Internship old = internshipMapper.selectById(internshipId); if (old == null || !old.getStudentId().equals(studentId)) { throw new BusinessException("实习记录不存在或无权操作"); } if (!old.getStatus().equals(InternshipStatus.REJECTED.getCode())) { throw new BusinessException("只有驳回状态的实习申请才能重新提交"); } // 更新内容,并把状态改回PENDING_COMPANY old.setEnterpriseId(newInfo.getEnterpriseId()); old.setPositionName(newInfo.getPositionName()); old.setApplyFilePath(newInfo.getApplyFilePath()); old.setStatus(InternshipStatus.PENDING_COMPANY.getCode()); old.setRejectReason(null); old.setUpdateTime(new Date()); internshipMapper.updateById(old); // 日志... } }Service层把"状态校验 + 状态变更 + 日志记录"放在一个事务里,这是全系统的核心安全防线。Controller层不直接改状态,所有涉及状态的变更一律走Service方法,这样可以避免前端绕过校验。
5.3 MyBatis动态SQL:多条件分页查询实战
管理端和学生端都有"实习列表"页,通常要按状态、学生姓名、专业、企业名称等条件组合查询。如果每个条件写一个SQL,那页面加一个筛选条件你就要改一次,烦不烦?MyBatis的动态SQL就是干这个的。看一段Mapper XML:
<select id="selectInternshipPage" resultType="com.yourproject.entity.InternshipVO"> SELECT i.*, u.real_name AS student_name, e.enterprise_name AS enterprise_name FROM internship i LEFT JOIN sys_user u ON i.student_id = u.id LEFT JOIN enterprise_info e ON i.enterprise_id = e.id <where> <if test="status != null"> AND i.status = #{status} </if> <if test="studentName != null and studentName != ''"> AND u.real_name LIKE CONCAT('%', #{studentName}, '%') </if> <if test="enterpriseId != null"> AND i.enterprise_id = #{enterpriseId} </if> <if test="teacherId != null"> AND i.teacher_id = #{teacherId} </if> </where> ORDER BY i.create_time DESC </select>注意两个细节:一是<where>标签会自动去掉第一个多余的AND,这个特性要记住;二是左连sys_user和enterprise_info,是为了在列表页直接显示学生姓名和企业名称,避免在Java代码里二次查询。连表查询虽然会多几个LEFT JOIN,但在这个数据量级别下完全没有性能问题,反而代码更简洁。
分页我建议直接使用PageHelper,配置非常简单,在spring-mybatis.xml里加一个拦截器插件:
<bean class="com.github.pagehelper.PageInterceptor"> <property name="properties"> <value> helperDialect=mysql reasonable=true </value> </property> </bean>然后在Service里这样用:
public PageInfo<InternshipVO> queryPage(int pageNum, int pageSize, QueryCondition condition) { PageHelper.startPage(pageNum, pageSize); List<InternshipVO> list = internshipMapper.selectInternshipPage(condition); return new PageInfo<>(list); }5.4 文件上传:实习材料附件的最佳存储方式
材料附件(实习接收函、安全承诺书等)的存储,不要去搞分布式文件系统,那不现实。最稳妥的方式是把文件保存在Tomcat部署目录下的某个upload目录,然后在数据库里存相对路径。
我用一个封装好的工具类示例:
public class FileUploadUtil { private static final String UPLOAD_DIR = "/uploads"; public static String save(MultipartFile file, String subDir) throws IOException { // 生成唯一文件名,避免中文乱码和重复 String originalFilename = file.getOriginalFilename(); String ext = StringUtils.getFilenameExtension(originalFilename); String newFileName = System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + "." + ext; // 部署目录下的绝对路径 String realPath = UPLOAD_DIR + subDir; File dir = new File(realPath); if (!dir.exists()) { dir.mkdirs(); } File dest = new File(dir, newFileName); file.transferTo(dest); return subDir + "/" + newFileName; } }这里要特别注意transferTo的坑:很多人会用file.transferTo(new File("D:\\upload\\xxx")),在IDE里调试没问题,但部署到Linux服务器上就不行了,因为路径是写死的。正确做法是通过ServletContext.getRealPath()获取应用部署的绝对路径,或者定义一个可配置的全局上传路径,并在Spring配置里注入。如果后面遇到"本地能保存,Linux保存失败",九成是路径问题。
用SpringMVC上传文件还需要配置multipart解析器:
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <property name="defaultEncoding" value="UTF-8"/> <property name="maxUploadSize" value="10485760"/> </bean>maxUploadSize按字节计算,10MB足够实习材料用了。
5.5 成绩评定与统计:让数据真正可用
实习结束后,企业评分和教师评分都进入evaluation表,综合成绩可以按权重计算,比如企业分占40%、教师分占60%(这个权重可以根据学校规则调整)。在Service里写一个计算综合成绩的方法:
public BigDecimal calcFinalScore(Long internshipId) { Evaluation eva = evaluationMapper.selectByInternshipId(internshipId); if (eva == null || eva.getEnterpriseScore() == null || eva.getTeacherScore() == null) { throw new BusinessException("企业或教师评分未提交,无法计算"); } // 企业分40%,教师分60% BigDecimal enterpriseWeight = new BigDecimal("0.4"); BigDecimal teacherWeight = new BigDecimal("0.6"); BigDecimal finalScore = eva.getEnterpriseScore().multiply(enterpriseWeight) .add(eva.getTeacherScore().multiply(teacherWeight)); return finalScore.setScale(2, RoundingMode.HALF_UP); }统计报表这块别写得太复杂,管理员的"成绩汇总"页面无非就是按学院、按专业统计人数、平均分、最高分、最低分、通过率。用一条聚合SQL就能搞定:
SELECT m.major_name, COUNT(*) AS total_count, AVG(e.final_score) AS avg_score, MAX(e.final_score) AS max_score, MIN(e.final_score) AS min_score FROM internship i JOIN evaluation e ON i.id = e.internship_id JOIN sys_user u ON i.student_id = u.id JOIN major_info m ON u.major_id = m.id WHERE i.status IN (6, 7) GROUP BY m.major_name;至于图表展示,ECharts是通用方案,后端把统计结果封装成JSON返回,前端用echarts.init展示柱状图或饼图。这属于锦上添花,不是核心考察点,但做好了会很加分。
6. 实测踩坑合集与答辩高频追问:按这个顺序能少熬夜
6.1 SSM常见配置与版本问题排查
SSM项目的坑,我按出现频率排个序,这些全是带队时真真实实遇到过的:
Tomcat启动后404或启动失败:先查
spring-webmvc的Servlet版本和Tomcat版本,Spring 4用Servlet 3.0,如果你放到Tomcat 10上极可能直接起不来。我习惯用Tomcat 8.5 + Spring 5.1.x,稳。Could not open JDBC Connection:先ping一下数据库地址,再用Navicat或命令行连一下,排除数据库本身没启动;然后看驱动包是不是放到了WEB-INF/lib下;最后看
spring-mybatis.xml里的driverClass是com.mysql.jdbc.Driver还是com.mysql.cj.jdbc.Driver,MySQL 8一定要用带cj的驱动类。中文乱码:三个地方要统一,页面编码utf-8、SpringMVC请求编码过滤器(CharacterEncodingFilter)、数据库连接URL里的
characterEncoding=utf8。缺一个就可能出现插入正常但查询乱码的问题。#{}和${}用混:排序字段、表名只能用${},但要注意防注入;而所有作为查询条件的值都应该用#{}。我最怕学生在Mapper里写WHERE status = ${status},只要数据库里有几条脏数据,状态查询就全乱套。上传文件为空或传不上:检查multipartResolver是否配置、表单是否加了
enctype="multipart/form-data"、文件名有没有中文。第三个问题尤其隐蔽,Tomcat默认会对非ASCII字符做解码处理,所以我在文件上传工具里强制重命名了。AOP事务没生效:确保
<tx:annotation-driven>配置在SpringMVC的父容器(applicationContext.xml)里,而不是在springmvc.xml里。如果事务注解放在Controller层的方法上,而扫描路径又把Controller配进了父容器,事务会在代理机制下失效。前端资源加载不出来:SpringMVC默认拦截
/,所以静态资源要放行。在springmvc.xml里配:
<mvc:resources location="/static/" mapping="/static/**"/>同时JSP里引用资源一定要用${pageContext.request.contextPath}拼接上下文路径,不然部署到带项目名的路径上就全挂。
6.2 答辩演示前的检查清单
一个能顺利答辩的演示流程,我建议按这个顺序准备,每次演练都走一遍:
- 管理员登录,先创建班级和专业目录;
- 管理员发布一条实习任务公告;
- 学生登录,填写实习申请并上传材料;
- 企业导师登录,确认接收;
- 指导教师登录,审批通过(顺便演示一次驳回再修改的完整闭环);
- 学生提交周报;
- 教师批阅周报并给评语;
- 学生申请结束实习;
- 企业导师评分;
- 指导教师评分;
- 管理员查看最终成绩统计,导出报表。
整个流程下来大概五到八分钟,但覆盖了所有角色的核心操作。
6.3 答辩现场的高频问题
最后整理几个答辩时老师大概率会问的问题,提前准备一下:
为什么用SSM而不用Spring Boot?
参考答案框架在第二章提过:SSM手动整合的过程能清晰展示Spring IoC/AOP、SpringMVC路由、MyBatis ORM的底层机制,教学价值更高;Spring Boot则是封装好了这些细节的快速开发框架。
数据库为什么这样设计?为什么评价表要单独拆分?
从业务流程出发:企业评分和教师评分时间上是异步的,状态上需要独立追踪,拆表能清晰表达"部分提交"状态,也方便后续扩展多个评价维度。
事务你是怎么控制的?
在Service层使用
@Transactional,并配合具体业务(如状态流转、日志记录)说明事务边界,顺带提一下事务隔离级别和回滚条件。权限控制怎么实现的?
说明Session存储登录用户,自定义HandlerInterceptor实现登录校验和角色判定,Service层再加数据归属校验(学生只能操作自己的数据),三层控制。
如何防止SQL注入?
MyBatis中
#{}会被解析为PreparedStatement占位符,能够有效防止SQL注入;${}是字符串拼接,有注入风险,只在排序字段等特殊位置使用并做好白名单校验。系统有什么缺陷或可改进的地方?
别把系统吹得十全十美,可以说:目前实习流程的通知提醒依赖页面查看,后续可以用WebSocket做实时通知;成绩统计目前是定时导出的方式,后续可以做自动报表推送。这种回答既诚实又显得你思考过。
6.4 一个帮你提分的扩展方向
如果时间和精力允许,我强烈建议在系统里加一个"导出Excel"的功能。用Apache POI封装一个导出工具,把实习成绩列表导出成.xlsx,几十行代码就能实现,但演示效果拔群——尤其是管理员那个"一键导出上报"的按钮,非常贴合真实使用场景,老师会觉得你是认真做过需求分析的。
// 简化版Excel导出 XSSFWorkbook workbook = new XSSFWorkbook(); XSSFSheet sheet = workbook.createSheet("实习成绩"); Row header = sheet.createRow(0); header.createCell(0).setCellValue("学生姓名"); header.createCell(1).setCellValue("专业"); // ... 遍历数据写入行 response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=internship_report.xlsx"); workbook.write(response.getOutputStream()); workbook.close();把Office导出、ECharts统计、状态机日志这几个点凑在一起,你的系统在技术上就已经超过"普通CRUD毕设"一大截了。
我个人在带毕设时最深的体会是:这个题目最大的价值不在于"做出来",而在于让你用一套标准的分层架构,把真实业务中的角色、状态、事务和权限完整跑一遍。哪怕毕业以后你不写Java,这套"先理清业务闭环,再设计数据结构,最后落代码"的思路,走到哪个方向都通用。如果你正在做这个题目,建议先把流程图画明白,再把状态机写出来,第三件事才是动手建表——顺序反了,后面大概率要翻工。最后再给一个实用小技巧:开发时把每个角色的登录账号提前整理好,比如学生1、教师1、企业1、管理员admin,密码统一设为"123456",在演示和自测时能省掉大量录入数据的时间,也方便答辩现场快速切换角色展示。