大四那会儿我做过一个很典型的课设级项目:高校实习管理系统。说它是管理系统,其实更像把学校里学生找实习、报企业、交材料、打成绩这一套线下流程搬到线上。当时选型就是SpringBoot + Vue,数据库用MySQL,ORM用MyBatis,没有任何花哨中间件。做完之后我发现,这类系统看起来功能简单,但真要把流程串顺、让老师、学生、企业三方都觉得好用,里面值得抠的细节非常多。这篇文章就完整复盘一下我的设计与实现过程,从需求拆解到数据库设计、后端接口、前端页面、部署上线,尽量把每一处的选型理由和踩坑点都写清楚。适合正在做毕设或者课程设计、想快速搭一套前后端分离管理系统的人参考。
1. 项目背景与功能定位
1.1 为什么需要实习管理系统
很多高校的实习管理还停留在“填表-盖章-交纸质”阶段。学生找好实习单位后要打印申请表,找导师签字,再交到学院;实习过程中要定期汇报,最后还要把实习手册、考核表、鉴定表往系统里录一遍。老师这边更头疼,几十上百份材料要逐个审核,记录成绩,归档存证。这套流程如果做成线上系统,核心价值就是让状态可追踪、材料可沉淀、审批有留痕。
我做的这套系统瞄准的正是这个痛点。它不是一个大而全的校园服务平台,而是专门围绕“实习全生命周期”设计的小型业务系统。适合的场景包括:学生提交实习申请、企业发布实习岗位、导师审核并分配、学生提交周报与总结、导师评定成绩、学院管理员统一查看统计。整个系统内部闭环,不依赖第三方服务,部署成本低,也符合课程设计或毕业设计的复杂度要求。
1.2 核心角色与业务流程
系统里必须有四种角色,缺一个流程都跑不通。
- 学生:查看实习岗位,申请岗位,提交实习申请,填写周报、月报和实习总结,查看成绩。
- 企业:发布岗位,接收学生申请,确认实习接收关系,给出简要评价。
- 导师(校内指导老师):审核学生实习申请,查看学生填写的周报总结,评定实习成绩。
- 管理员:维护班级信息、专业信息,管理用户账号,配置实习时间段,查看全校实习数据统计。
业务流程上,我设计了这样一条主线:学生登录后先完善个人简历,然后浏览企业发布的实习岗位;点击申请后,岗位所属企业进行确认;企业确认后,学生需要再向导师提交正式实习申请(这一步保留学校审批环节),导师审核通过后形成有效实习记录;实习期间学生按周期提交周报,实习结束后提交总结和实习单位盖章证明;导师根据周报完成度、总结质量和企业反馈综合给出成绩。整体状态机是:待申请 → 企业待确认 → 导师待审核 → 实习中 → 待评定 → 已结束。
1.3 功能清单
把角色拆开后,功能点就很清晰了:
- 学生端:注册登录、个人信息维护、简历编辑、岗位浏览与申请、实习申请提交、周报填写、实习总结提交、成绩查看。
- 企业端:岗位发布与下架、实习申请处理、学生状态查看、实习评价。
- 导师端:学生实习申请审核、周报查看与批注、成绩评定、所带学生列表。
- 管理端:用户管理、班级管理、专业管理、统计报表、实习时间段配置。
这个功能清单看着不大,但已经覆盖了一个典型业务系统的全部要素:用户权限、核心业务流转、文件上传、状态变更、数据统计。做课程设计时,功能数量不是越多越好,而是要把这些点做扎实。
2. 技术选型与架构设计
2.1 为什么是SpringBoot + Vue 前后端分离
如果你只是做一个小系统,JSP + Servlet也能实现,但维护起来真的很痛苦。SpringBoot的好处在于内置Tomcat、自动配置、起步依赖管理,写一个main方法就能启动,基本上不需要XML配置。配合MyBatis,SQL都是自己写的,逻辑透明,调试也方便。这种组合在高校项目里几乎成了标配,一方面确实够用,另一方面遇到问题时网上能参考的资料最多。
Vue这边我选择了Vue2生态(因为当时稳定),配合Element UI组件库。Vue的核心价值是数据驱动视图,做表单、列表、弹窗、分页这类操作特别顺手。前后端分离后,后端只提供JSON接口,前端通过axios调用,两者各自独立开发、独立部署,后续如果想把系统改成小程序或者App端,后端接口可以复用,这也是我坚持前后端分离的主要原因。
2.2 MyBatis与MySQL的搭配
数据库选MySQL纯粹是通用性考虑,学过的、见过的、部署过的基本都是它。MyBatis相比JPA,胜在SQL可控。举个例子:统计每个导师带了多少个实习学生,JPA你得写JPQL或者用Specification,MyBatis直接写一行GROUP BY查询就可以了,性能如何、索引有没有命中,自己心里特别清楚。对于实习管理这种报表需求较多的系统,MyBatis显然更顺手。
另外,MyBatis的动态SQL也非常有用。筛选学生列表时,管理员可能按班级、姓名、状态等组合条件查询,直接在Mapper XML里用where + if标签拼接查询条件,比自己写StringBuilder拼SQL干净得多。
项目依赖我放在pom.xml里,关键部分如下:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>2.3 项目整体目录结构
后端采用常见的分层结构:controller接收请求,service处理业务,mapper操作数据库。前端采用Vue CLI标准结构,按view组织页面。整体如下:
backend ├── src/main/java/com/example/internship │ ├── controller # 接口层 │ ├── service # 业务逻辑层 │ ├── mapper # MyBatis Mapper接口 │ ├── entity # 数据实体 │ ├── dto # 请求/响应对象 │ ├── config # 拦截器、跨域配置 │ ├── common # 统一结果、异常、工具类 │ └── InternshipApplication.java └── src/main/resources ├── mapper # Mapper XML文件 └── application.yml frontend ├── src/api # axios请求封装 ├── src/assets ├── src/components # 公共组件 ├── src/router # 路由配置 ├── src/store # Vuex状态管理 ├── src/views # 页面 └── src/utils # 工具函数这种结构重点分明,业务代码不会堆在一个包里,排查问题的时候能快速定位。
3. 数据库设计与核心表结构
3.1 实体关系梳理
数据库设计我一般先画实体关系。这个系统的核心实体有:用户、学生信息、教师信息、企业信息、岗位、实习申请、实习记录、周报、成绩。整体关系不算多,但要注意几个关键点:
- 用户表是所有角色登录的凭证,通过role字段区分角色;学生和教师分别关联用户ID。
- 企业和教师是独立实体,不挂在用户体系里也行,但企业登录也需要账号,所以企业表也存账号密码。
- 实习申请是业务的核心,它关联学生、岗位、导师、企业多方。
- 周报和成绩都关联到一条实习记录上,实习记录从审核通过的申请中产生。
3.2 核心表字段设计
这里我只列几张最核心的表。用户表要包含登录所需字段,不能明文存密码,我用的是BCrypt加密后的密文。学生表要关联班级和专业,方便管理端按条件筛选。岗位表主要是标题、描述、地点、薪资范围、状态等。实习申请表的状态字段是整个流程的驱动,我习惯用字符串状态加时间戳。
学生表的设计如下:
CREATE TABLE `student` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL COMMENT '关联用户ID', `student_no` varchar(20) NOT NULL COMMENT '学号', `name` varchar(50) NOT NULL COMMENT '姓名', `gender` varchar(10) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `major` varchar(50) DEFAULT NULL, `class_name` varchar(50) DEFAULT NULL, `resume` text COMMENT '简历简介', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_student_no` (`student_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;实习申请表是整个系统的枢纽,字段设计时重点考虑状态流转和历史记录的沉淀:
CREATE TABLE `internship_apply` ( `id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `position_id` int NOT NULL, `company_id` int NOT NULL, `teacher_id` int DEFAULT NULL, `status` varchar(20) DEFAULT 'PENDING' COMMENT '状态:PENDING-待企业确认 COMPANY_CONFIRMED-企业已确认 TEACHER_PENDING-待导师审核 ACTIVE-实习中 FINISHED-已结束 REJECTED-驳回', `apply_time` datetime DEFAULT NULL, `company_confirm_time` datetime DEFAULT NULL, `teacher_audit_time` datetime DEFAULT NULL, `start_date` date DEFAULT NULL, `end_date` date DEFAULT NULL, `reject_reason` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;3.3 建表SQL示例
周报表要注意约束,一条实习记录只能填某个时间范围的周报,不然学生会乱填。我用唯一索引限制周次:
CREATE TABLE `weekly_report` ( `id` int NOT NULL AUTO_INCREMENT, `internship_id` int NOT NULL, `week_no` int NOT NULL COMMENT '第几周', `content` text NOT NULL COMMENT '周报内容', `work_summary` text COMMENT '工作总结', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_internship_week` (`internship_id`, `week_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;成绩表则要冗余一下学生、课程(这里指实习项目)和导师信息,方便后期报表查询,避免到处关联:
CREATE TABLE `internship_score` ( `id` int NOT NULL AUTO_INCREMENT, `internship_id` int NOT NULL, `student_id` int NOT NULL, `teacher_id` int NOT NULL, `score` decimal(5,2) DEFAULT NULL, `comment` varchar(500) DEFAULT NULL, `level` varchar(20) DEFAULT NULL COMMENT '优秀/良好/及格/不及格', `audit_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;数据库设计的教训:表字段宁可拆细,不要塞一个大字段。比如“企业评价”,一开始我就放在实习记录表里,后来发现导师评分、企业评价、学生自评三个维度混在一起,查询很别扭。拆成单独的评价表和成绩表后,逻辑清爽了很多。
4. 后端核心模块实现
4.1 统一返回结果与异常处理
前端和后端约定接口风格是一件非常重要的事。我这里定义了一个Result类,包含code、message、data三个字段,code为200表示成功,400为参数错误,401未登录,500系统异常。所有Controller都返回这个对象,前端只需要在拦截器里统一判断code即可,不用每个接口单独写错误处理。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> fail(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }全局异常处理用@RestControllerAdvice,拦截业务异常和未知异常,统一返回Result。这样一来,Controller里面就不需要写一堆try-catch了,代码非常简洁。
4.2 登录认证与拦截器
登录我用了JWT。流程是用户提交账号密码,后端验证通过后生成token返回给前端;前端把token存在localStorage中,每次请求在请求头带上Authorization字段;后端写一个拦截器,解析token并把用户信息放入ThreadLocal,方便Service获取当前登录人。
JWT的生成代码很简单,使用io.jsonwebtoken的JJWT库:
public String createToken(int userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }拦截器要注意放行登录、注册、接口文档等路径,其他路径全部校验。另外我处理跨域问题是在项目里加了CorsConfiguration,而不是在Nginx层处理,这样本地调试前后端分离环境更方便。
这里我踩过一个坑:SpringBoot拦截器对OPTIONS预检请求也会拦截,导致前端axios跨域预检被返回401。解决办法是在拦截器里直接放行OPTIONS请求,否则前后端联调时所有带token的复杂请求都会失败。
4.3 实习申请流程实现
实习申请的接口是整个系统的核心,它不是一个简单的插入操作,而是一连串状态迁移。我的实现方式是单独写一个InternshipApplyService,方法名直接表达业务动作,比如applyInternship、companyConfirm、teacherAudit、startInternship等,这样看代码就像读需求文档。
以学生提交申请为例:
@Transactional public void applyInternship(ApplyRequest request) { Student student = studentMapper.findByUserId(CurrentUser.getId()); if (student == null) { throw new BizException("请先完善学生信息"); } InternshipApply apply = new InternshipApply(); apply.setStudentId(student.getId()); apply.setPositionId(request.getPositionId()); apply.setCompanyId(request.getCompanyId()); apply.setStatus("PENDING"); apply.setApplyTime(new Date()); internshipApplyMapper.insert(apply); }注意这里加了@Transactional,因为后续还会有插入日志、扣减岗位名额等操作,事务保证不会出现半个成功半个失败的情况。
企业确认后再提交导师审核,实际上我是在一个申请记录上连续修改状态。用多个表去记录申请副本会更规范,但对于课设来说,单表状态流转更直观,也更容易讲清楚。
4.4 实习成绩评定逻辑
成绩评定不能简单存一个数字就完事。我设计了三个维度:周报提交质量、实习总结质量、企业评价。导师端接收这三个维度打分,系统按权重计算综合分,再映射为等级。比如周报占30%,总结占30%,企业评价占40%。为了控制总分不超过100,所有输入限制在0到100分范围内。
计算逻辑:
public BigDecimal calcScore(Integer weekly, Integer summary, Integer companyScore) { BigDecimal result = BigDecimal.valueOf(weekly * 0.3 + summary * 0.3 + companyScore * 0.4); return result.setScale(1, RoundingMode.HALF_UP); }等级映射就很简单了:90以上优秀,80到90良好,60到80及格,低于60不及格。这里我觉得比较重要的点是:成绩不能由导师随意改,要改就要走审核接口并记录修改日志,否则答辩时老师问你“成绩怎么保证真实性”,你会答不上来。
4.5 文件上传与预览
学生需要上传实习证明或盖章扫描件,这类场景我直接用本地存储,不上云。因为课设项目没有域名和对象存储,本地存储是最务实的做法。
上传接口使用SpringMVC的MultipartFile,保存路径配置在application.yml里:
file: upload-dir: ./upload/文件保存时用UUID生成新文件名,避免中文名和重复名带来的问题。返回给前端的是文件访问相对路径,前端把这个路径和业务绑定。预览则通过一个ResourceHandler映射虚拟路径:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceHandler("file:" + uploadDir + "/"); } }这里要特别注意路径分隔符,Windows和Linux不一样,直接用File.separator拼接最稳妥。我用相对路径./upload,部署时在jar包同级目录产生upload文件夹,简单也直观。
5. 前端核心模块实现
5.1 Vue工程搭建与路由权限
前端我选择用Vue CLI快速初始化项目,安装vue-router、vuex、axios、element-ui。路由权限通过导航守卫控制,根据本地存储的roles字段判断当前用户能访问哪些页面。比如管理员页面路径为/admin,如果角色是student访问,直接跳转到首页并提示无权限。
路由设计:
const routes = [ { path: '/login', component: Login }, { path: '/', component: Layout, children: [ { path: 'jobs', name: 'JobList', component: JobList, meta: { roles: ['student'] } }, { path: 'apply', name: 'MyApply', component: MyApply, meta: { roles: ['student'] } }, { path: 'reports', name: 'ReportList', component: ReportList, meta: { roles: ['student', 'teacher'] } }, { path: 'students', name: 'StudentManage', component: StudentManage, meta: { roles: ['admin'] } } ]} ];导航守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); return; } const roles = JSON.parse(localStorage.getItem('roles')); if (to.meta.roles && !to.meta.roles.some(r => roles.includes(r))) { next('/403'); return; } next(); });5.2 页面组件与API封装
前端所有请求都走一个封装后的request.js,在axios实例里统一设置baseURL,并给每个请求加上token请求头。响应拦截器里对code非200的情况统一ElMessage提示,对401则跳到登录页。
service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = token; } return config; }); service.interceptors.response.use(res => { const result = res.data; if (result.code !== 200) { Message.error(result.message); if (result.code === 401) { router.push('/login'); } return Promise.reject(result); } return result.data; });页面组件以列表页为例,通常包含搜索表单、表格、分页、操作按钮四部分。我用Element UI的el-table和el-pagination结合,搜索条件绑定在search对象里,点击查询时重新请求列表,分页参数变化时也重新请求。
岗位列表页最关键的是状态展示,我用el-tag根据status显示不同颜色。例如:正常绿色、已停用灰色。这类小封装能大大提升后台管理页的观感,答辩时也能加分。
5.3 关键交互场景
有几个交互做得不好,会让系统显得很业余。
第一个是学生选择岗位后,需要回显企业信息。前端在提交申请前先弹一个确认框,展示岗位名称、企业名称、实习地点,用户确认后才提交。这能防止误操作。
第二个是导师审核时,常见操作是通过、驳回。我设计成审核弹窗,通过时可以直接编辑成绩权重,驳回时必须填写原因。后端如果检查到驳回原因为空,直接抛参数异常。这样可以倒逼用户填完整信息,保证数据质量。
第三个是周报编辑页。周报内容较长,我做成富文本保存纯文本,并限制字数3000。前端用el-input的type="textarea"加maxlength,实时显示剩余字数。提交时按实习记录+周次查重,已经填过的周次不允许再次填写,直接提示“该周周报已提交”。
6. 部署与运行
6.1 本地环境准备
本地跑这套系统需要准备:JDK 8以上、Maven 3.6+、MySQL 5.7以上、Node.js 14+。版本方面,SpringBoot我用的2.5版本,前端Vue CLI用的4.5,兼容性比较稳妥,不需要踩新版本那些builder配置坑。
环境准备的关键是MySQL初始化。我新建了一个internship_db数据库,直接把SQL脚本导入。需要注意数据库账号密码和application.yml中保持一致,否则后端启动时会报连接拒绝。
6.2 打包配置与启动步骤
后端打包很直接,在项目根目录执行:
mvn clean package -DskipTests生成target目录下的jar包,然后运行:
java -jar internship-system.jar前端打包前先修改.env.production里的API地址,让请求指向后端实际地址,然后执行:
npm run build生成dist目录,里面是纯静态资源,放到Nginx的html目录,配置Nginx把/api开头的请求反向代理到后端端口,同时把非/api请求指向dist下的index.html。
Nginx配置片段:
server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }我把后端接口前缀统一加上了/api,这样代理规则写起来很舒服,前后端联调时也不会出现路径拼错的问题。
部署时最容易出的问题,是前端打包后直接双击index.html打开,结果一片空白。这是因为路由用了history模式。解决办法是改成hash模式,或者像上面这样在Nginx配置try_files。我实际项目里改成了hash模式,省去很多部署环境差异问题。
7. 常见问题与排查技巧实录
在这个项目上我遇到并解决了不少问题,挑几个最有代表性的写出来,后面写着类似管理系统的同学可以直接照方抓药。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 后端启动时报数据库连接失败 | application.yml里的账号密码或数据库名错误 | 核对MySQL连接配置,使用show databases确认库是否存在 |
| 前端请求接口跨域报错 | 后端未配置CORS,或拦截器拦截了OPTIONS | 后端配置跨域,并在拦截器中放行预检请求 |
| 登录成功后刷新页面跳回登录页 | 路由守卫只判断token,但未判断token是否过期 | 在路由守卫中增加向后端校验token有效性的接口调用 |
| 前端表格数据不更新 | 没有使用key绑定或没强制刷新列表 | 在操作回调中重新调用列表接口 |
| 图片上传后访问404 | 静态资源映射路径配置错误 | 检查addResourceHandlers中的路径,注意斜杠与目录拼写 |
| MyBatis查询结果字段为null | 实体类驼峰字段与数据库下划线字段未匹配 | 在application.yml开启map-underscore-to-camel-case: true |
| 分页数据总数不对 | SQL写错,用了LIMIT但是没有统计总数 | 使用PageHelper插件或手写count查询 |
还有一个我印象很深的问题是:MyBatis在写多表关联查询时,结果集的映射经常对不上字段。后来我习惯在Mapper XML里为每一个查询字段指定别名,例如:
<select id="selectApplyDetail" resultType="map"> SELECT a.id, s.student_no AS studentNo, s.name AS studentName, c.name AS companyName, p.title AS positionTitle, a.status AS status FROM internship_apply a LEFT JOIN student s ON a.student_id = s.id LEFT JOIN company c ON a.company_id = c.id LEFT JOIN position p ON a.position_id = p.id WHERE a.id = #{id} </select>因为返回的是Map,不需要定义专门的VO,写起来最快,但下游取值必须严格按别名大小写来,否则取不到。这个方法适合快速实现联表查询,如果长期维护,建议还是定义清晰的VO类。
排查技巧方面,我强烈建议从一开始就在后端加上统一日志。每个Controller入口打一行请求日志,参数、执行时间都打出来。定位问题时先看日志,再断言请求是否到达后端,顺着链路一层层查,效率非常高。前端打开开发者工具,重点是Network面板里请求的状态码和响应体,很多所谓“后端毛病”其实是前端参数没传对。
最后分享一个我在实际调试中的体会:不要一上来就写全部功能,先撸一个最小的闭环——管理员登录、创建学生账号、学生登录、提交申请、导师审核、打分。这个闭环跑通后,再往里加周报、上传、统计等功能。小闭环跑通了,你心里就有底,后面新增内容基本上就是在一个稳定的骨架上贴肉。我在这个项目里就是先把主线流程打通,再扩充边角功能,整个过程基本没有返工。
这套系统做完后,我最大的收获其实不是学会了某个框架,而是理顺了一个业务需求如何一步步变成可运行的代码。实习管理说起来不起眼,但把角色、状态、权限、数据关系理清楚,你能迁移到很多类似的管理系统上。如果你正在做类似的课设或毕设,建议先把核心流程的数据库表和状态设计画明白,再动手写代码,这条路会顺利很多。