
每年毕业季计算机专业的选题清单里几乎都能看到高校毕业实习管理系统的身影。说实话这个题目被选中的频率高到有点烂大街但它确实是练手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最坑的不是写代码而是版本不兼容导致的启动报错。我建议一个相对稳定的组合JDK1.8201版本或更高一点反正不要用JDK 17去跑老项目Spring 4/5的很多反射逻辑和老ASM库会出问题Maven3.6.xTomcat8.5或9.0建议8.5和Servlet 3.1规范匹配度更好MySQL5.7或8.0如果学校机器装的是5.5那你要注意驱动版本和utf8mb4配置IDEA2020.x以上都行破解版的别拿出来说就行。Maven依赖需要注意版本匹配。以Spring 5.1.x为例spring-webmvc、spring-jdbc、spring-context这几个核心包版本必须一致MyBatis用3.5.xmybatis-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 IoCBean是怎么被创建、被注入的构造器注入与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、passwordBCrypt加密、real_name、phone、email、role_type0学生 1校内教师 2企业导师 3管理员、college_id、major_id等college_info学院表字段就不用多说了major_info专业表关联college_identerprise_info企业表保存企业名称、统一社会信用代码、联系人、联系电话internship实习主表这是全系统的核心保存学生ID、企业ID、岗位名称、实习开始结束时间、当前状态、驳回原因、材料附件路径等internship_log操作日志表对应上面的状态流转weekly_report周报/月报表关联internship_id包含标题、内容、提交时间、批阅状态、教师评语evaluation成绩评定表关联internship_id保存企业评分、教师评分、综合成绩、评语notice公告通知表sys_dict数据字典表不一定每个人都需要但对实习类型考核等级这类有周期性的可选项非常合适。4.2 实习主表结构示例实习主表是整个系统最核心的表它的字段设计直接决定了业务能不能跑通。我给一个可以照着用的SQLCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两条独立的提交时间字段可以清楚知道谁在什么时候打了分也方便做未及时评价的催办统计。4.4 权限设计一表角色字段够不够关于权限我的建议是对于本科毕设一张sys_user表加一个role_type字段再配合SpringMVC拦截器做菜单级和接口级的权限判断完全够用。不要一上来就搞Spring Security Shiro那套学习成本高配置量也大而且在答辩时很难在五分钟内讲清楚。实现思路是登录成功后把用户对象和角色放进Session自定义拦截器拦截所有需要登录的请求根据请求路径前缀判断角色/student/**需要role_type0/teacher/**需要role_type1/company/**需要role_type2/admin/**需要role_type3。拦截器里做得简单一点只判断角色类型是否匹配越权直接重定向或返回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 classcom.yourproject.interceptor.AuthInterceptor/ /mvc:interceptor /mvc:interceptors5.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 XMLselect idselectInternshipPage resultTypecom.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 teststatus ! null AND i.status #{status} /if if teststudentName ! null and studentName ! AND u.real_name LIKE CONCAT(%, #{studentName}, %) /if if testenterpriseId ! null AND i.enterprise_id #{enterpriseId} /if if testteacherId ! 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 classcom.github.pagehelper.PageInterceptor property nameproperties value helperDialectmysql reasonabletrue /value /property /bean然后在Service里这样用public PageInfoInternshipVO queryPage(int pageNum, int pageSize, QueryCondition condition) { PageHelper.startPage(pageNum, pageSize); ListInternshipVO 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 idmultipartResolver classorg.springframework.web.multipart.commons.CommonsMultipartResolver property namedefaultEncoding valueUTF-8/ property namemaxUploadSize value10485760/ /beanmaxUploadSize按字节计算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.DriverMySQL 8一定要用带cj的驱动类。中文乱码三个地方要统一页面编码utf-8、SpringMVC请求编码过滤器CharacterEncodingFilter、数据库连接URL里的characterEncodingutf8。缺一个就可能出现插入正常但查询乱码的问题。#{}和${}用混排序字段、表名只能用${}但要注意防注入而所有作为查询条件的值都应该用#{}。我最怕学生在Mapper里写WHERE status ${status}只要数据库里有几条脏数据状态查询就全乱套。上传文件为空或传不上检查multipartResolver是否配置、表单是否加了enctypemultipart/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; filenameinternship_report.xlsx); workbook.write(response.getOutputStream()); workbook.close();把Office导出、ECharts统计、状态机日志这几个点凑在一起你的系统在技术上就已经超过普通CRUD毕设一大截了。我个人在带毕设时最深的体会是这个题目最大的价值不在于做出来而在于让你用一套标准的分层架构把真实业务中的角色、状态、事务和权限完整跑一遍。哪怕毕业以后你不写Java这套先理清业务闭环再设计数据结构最后落代码的思路走到哪个方向都通用。如果你正在做这个题目建议先把流程图画明白再把状态机写出来第三件事才是动手建表——顺序反了后面大概率要翻工。最后再给一个实用小技巧开发时把每个角色的登录账号提前整理好比如学生1、教师1、企业1、管理员admin密码统一设为123456在演示和自测时能省掉大量录入数据的时间也方便答辩现场快速切换角色展示。