每年实习季一到,辅导员那边的Excel表格满天飞,学生不知道去哪找岗位,企业收上来的报名表半天统计不完,指导老师批改周报也没有统一入口。我基于 Java 生态里的 Spring Boot,配合前端 Vue,把高校学生实习管理从岗位发布、学生报名、过程跟踪到成绩评定整条链路做成了线上系统,也就是“高校学生实习综合服务系统”。整套内容比较适合正在做毕业设计,或者帮学院做小型信息系统的同学参考,里面每一步用什么、为什么、踩过哪些坑,我都会讲清楚。
实习管理这种系统,规模不大但场景很典型。它不涉及高并发、不需要微服务,但对流程状态、角色权限、数据统计的要求非常具体。把单体架构下的 Spring Boot + Vue 组合吃透,基本就能应付大部分校内管理系统。下面我从需求设计、后端、前端、数据库、上线排障五个方面拆开讲。
1. 需求分析与整体设计:先画流程,再写代码
1.1 实习管理到底卡在哪些环节
动手写代码之前,我建议先用一周时间做需求调研,去教务、学工、各教研室问一圈。实习管理表面简单,实际痛点很集中:岗位信息发布靠群通知,学生报名靠共享表格,最后到底谁报了名、谁被录了没人能说清;实习过程中学生要交周报,但周报交在哪、老师批没批,全凭口头沟通;实习结束后的成绩评定更是重灾区,企业打分、老师打分、周报情况、出勤情况分散在多个渠道,最后汇总全靠手工。
所以这个系统的核心不是“做一个网站”,而是把一条业务主线跑顺:企业或校内管理员发布实习岗位,学生浏览并报名,指导老师审核确认,实习期间学生提交周报、老师批阅,实习结束后按照规则计算综合成绩,最后生成统计报表供学院管理使用。流程清晰了,功能模块自然就出来了。
1.2 技术栈选型:为什么是Spring Boot + Vue
技术选型要回答两个问题:为什么后端选 Spring Boot,为什么前端选 Vue。
后端用 Spring Boot,核心价值在于“开箱即用”和“生态成熟”。Spring Boot 内置了 Tomcat,不用再单独部署外部容器;配置简化了很多,一条spring-boot-starter-web依赖就能把 Web 应用跑起来;事务管理、参数校验、Redis 缓存、MyBatis 集成这些都有现成方案。对做校内管理系统来说,团队协作成本低,维护也简单,网上能查到的资料覆盖了你 90% 的常见需求。
前端选 Vue,是因为它学习曲线比 React 平缓,中文资料多,模板语法直观。Vue 的单文件组件开发方式很适合中后台系统,配合 Element 系列组件库,像表格、表单、弹窗、分页这些高频组件可以直接复用,开发效率非常高。如果说需求侧还有别的备选,比如 JSP 老方案、Freemarker 模板渲染,那都不是不行,但前后端分离以后,接口调试、部署扩展、页面展示灵活性都会明显更好,所以现在的主流选择仍然是 Spring Boot + Vue。
1.3 功能模块与角色权限顶层设计
角色方面我分了三种:学生、指导老师/学院管理员、企业账号。为了保证权限可控,系统不能只靠前端页面显隐来判断,后端每一个接口都要校验角色。
| 角色 | 核心权限 | 主要功能 |
|---|---|---|
| 学生 | 浏览岗位、报名、提交周报、查看成绩 | 实习大厅、我的实习、周报管理、个人中心 |
| 指导老师/管理员 | 审核岗位、审核报名、批阅周报、录入成绩、统计报表 | 学生管理、岗位管理、报名审核、成绩管理、数据统计 |
| 企业 | 发布岗位、管理岗位、接收报名 | 企业信息维护、岗位发布、报名名单查看 |
顶层功能模块我建议拆成六大块:基础信息管理(学生、班级、专业、企业),岗位发布与报名管理,实习过程管理(周报、打卡、沟通记录),成绩评定管理,统计报表中心,系统管理(用户、角色、菜单)。每一步都是承上启下的,前面没做好的后面全都会出问题。
1.4 为什么放弃微服务和分布式中间件
这个问题我一定要单独说。校内实习系统最常见的技术滥用,就是上来就上 Spring Cloud、注册中心、分布式事务那一套。一个学院几千人、并发峰值也就几十上百,微服务只会带来额外的部署成本和排障难度。我当时连 Redis 都只用来做验证码缓存和 JWT 黑名单,没有强依赖。系统设计越贴近真实规模越可靠,分布式方案留到真有并发和大流量再说,这个阶段用 单体 + 合理分层 就是性价比最高的选择。
2. 后端核心:Spring Boot项目怎么落地
2.1 骨架搭建与统一返回规范
项目骨架我用的标准四层结构:Controller 接收请求、Service 处理业务、Mapper 对接数据库、Entity 映射表结构。为了让接口响应格式一致,我定义了一个统一的返回体Result<T>,包含code、msg、data三个字段。所有接口都返回这个结构,前端判断 code 等于 200 才认为成功,否则统一弹错误提示。这样处理的好处是:错误信息、业务异常、参数校验失败都能走同一个通道,前端不用为每个接口单独写判断。
我还会配置一个全局异常处理器,用@RestControllerAdvice捕获BusinessException、参数校验异常和系统异常,转换成统一格式返回。这样 Service 层只要专注业务逻辑,遇到问题就抛异常,不用在每个 Controller 里 try-catch。
2.2 JWT登录认证与权限拦截
登录模块我没有用传统的 Session 方案,而是选了 JWT。原因很简单:前后端分离以后,Session 模式要处理跨域携带 Cookie、CSRF 防护、多端会话同步这些问题;JWT 模式是无状态的,客户端把 token 放在请求头里,后端解析就能拿到用户身份,部署多个实例也不受影响。
实现上有几个关键步骤:用户登录成功后,用用户 ID 和角色生成 token,有效期设置 24 小时;前端把 token 存在 localStorage 里,请求拦截器自动加到Authorization头;后端写一个拦截器,先校验 token 是否合法、是否过期,再解析出用户信息和角色,塞到当前线程上下文中。涉及权限控制的方法上加自定义注解或直接判断角色,不通过就返回 403。
这里的经验是:JWT 的密钥不要写死在代码里,要放到配置文件并通过环境变量注入;token 时间也不要设太长,实习生季的项目排期通常持续几个月,用户一天登录一次问题不大,但 token 过期以后要提供刷新接口,否则用户用着用着突然被踢下线,体验很差。
2.3 岗位发布、报名流程与状态机设计
岗位模块看起来是简单的增删改查,但报名环节的状态变化很考验设计。我的状态模型是这样的:岗位有“草稿、发布中、已招满、已下架”四种状态;报名记录有“待审核、已通过、已驳回、已取消”四种状态。
学生报名时,系统要检查岗位状态必须是发布中,还要检查报名人数是否已满。这里我加了一个signed_count字段在岗位表上,报名通过时给这个字段加一。防止重复报名的方式是:数据库层面在报名表(student_id, post_id)上建唯一索引,程序层面插入前再查一次,双保险。岗位已经招满时,前端要能实时看到“已满”的标识,这个状态由后端接口返回,不能靠前端自行推断。
2.4 实习成绩计算的业务逻辑
成绩评定是最容易被低估的业务模块。要评分的维度有企业评价、指导教师评分、周报完成度、出勤情况,这四块普遍各占不同权重。我先跟学院确认了统一规则,再把规则落到代码里:
最终成绩 = 企业评分 0.4 + 指导老师评分 0.3 + 周报完成度 0.2 + 出勤得分 0.1
每个维度都按百分制计分,汇总时用BigDecimal计算,避免浮点数误差。周报完成度是从已提交周报数和应提交周报数算出来的:完成比例大于 80% 记 90 分,60% 到 80% 记 70 分,不足 60% 按比例折算。出勤得分由企业端维护的考勤记录生成。
这里有个容易忽略的点:成绩一旦发布,学生端立即能看到,所以一定要加“确认发布”这个状态。成绩录入阶段可以让老师反复修改,但调成已发布以后就锁定,所有变更记录都要留痕,防止成绩纠纷。
2.5 文件上传与静态资源处理
实习申请经常要传简历、学生证照片、企业资质文件,上传模块绕不开。我用的是 Spring Boot 自带的MultipartFile,把文件保存到服务器本地磁盘的 upload 目录,文件名用UUID + 原文件名后缀重新生成,避免中文文件名和重名覆盖问题。上传接口要限制文件大小和类型,一般图片限 5MB 以内,PDF、Word 限 10MB,在配置里设置spring.servlet.multipart.max-file-size。
保存到本地的文件,前端要能通过 URL 访问。必须配置静态资源映射,把/files/**路径映射到磁盘目录,否则上传成功但图片 404,这个坑非常常见。
3. 前端实现:Vue工程化与页面交互
3.1 项目初始化和依赖安装
前端工程我是用 Vite + Vue 3 搭的,组件库用的 Element Plus。对比 Vue 2 + Element UI 的老组合,Vue 3 组合式 API 对逻辑复用更友好,Element Plus 的组件也比二点零时代完善很多。不过如果你所在团队都更熟 Vue 2,直接用 Vue 2 + Element UI 也完全没问题,关键是要统一,不要混着用。
创建项目以后,要装的依赖包括:axios发请求、vue-router管理路由、pinia管理用户状态和角色信息、element-plus提供 UI 组件、dayjs处理日期格式化。项目目录建议按功能划分,views放页面,components放公共组件,api放接口请求封装,router放路由配置,store放状态管理。一开始就把目录规范好,后面加页面、加接口都省事。
3.2 Axios封装与登录态管理
所有接口请求我统一走一个request.js,核心就是两个拦截器。请求拦截器里加 token,响应拦截器里处理业务状态码和 HTTP 状态码。
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( res => { const data = res.data if (data.code !== 200) { ElMessage.error(data.msg || '请求失败') return Promise.reject(new Error(data.msg)) } return data }, err => { if (err.response && err.response.status === 401) { localStorage.removeItem('token') localStorage.removeItem('userInfo') router.push('/login') } ElMessage.error(err.response?.data?.msg || '网络异常') return Promise.reject(err) } ) export default service这样封装以后,页面里只关心业务逻辑,不用重复处理 token 过期、接口报错这些通用问题。401 的情况统一跳登录页,用户再重新登录即可。
3.3 路由守卫和动态菜单权限
前端不能只靠“页面有没有入口”来控制权限,还要在路由层面做拦截。我的做法是:静态路由只配置登录页、首页、公共页面;动态路由根据后端返回的菜单权限生成。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else if (token) { if (to.path === '/login') { next('/') } else { next() } } else { next() } })对于按钮级权限,比如“发布岗位”按钮只有企业和管理员能看到,我写了一个自定义指令v-perm,根据当前用户的角色集合判断按钮是否渲染。这里的经验是:后端返回给用户的菜单和按钮权限一定要保持单一数据源,前端不要自己在 store 里写死角色清单,否则后端改了权限,前端还停在上一个版本。
3.4 实习大厅和我的实习页面拆解
重点页面有三个:实习大厅、报名管理、周报详情。
实习大厅是一个列表页面,用来展示所有发布中的岗位,筛选条件包括专业、企业名称、岗位名称。我用的核心组件是 Element Plus 的el-card+el-pagination。列表要接分页接口,参数是current和size,后端返回total总条数。这里有个交互细节:学生点击报名后按钮要立即变灰并显示“已报名”,防止连续点击产生重复提交,等接口返回成功后再刷新状态。
我的实习页面是学生视角的“控制台”。它展示当前学生的所有报名记录,每条记录包含岗位信息、企业信息、报名状态。状态为通过的学生,可以在这里进入周报管理,新增本周的周报并提交。周报列表按照提交时间倒序排列,老师批阅以后会显示批语状态。
管理端的报名审核页面虽然数据量不大,但列表查询条件一定要加好。按状态筛选是最常用的,其次按批次或学院筛选。审核按钮要带二次确认弹窗,防止误操作。页面好做,但“操作可追溯”才是管理系统的关键。
4. 数据库设计与权限模型
4.1 核心表的字段设计
数据库设计直接决定开发效率。我这次放了九张核心表:用户表、角色表、菜单表、用户角色关联表、角色菜单关联表、企业表、岗位表、报名表、周报表,外加成绩表。
岗位表是最核心的一张表,字段大概这样设计:
CREATE TABLE `internship_post` ( `id` bigint NOT NULL AUTO_INCREMENT, `company_id` bigint NOT NULL COMMENT '企业ID', `title` varchar(100) NOT NULL COMMENT '岗位名称', `major_requirement` varchar(255) DEFAULT NULL COMMENT '专业要求', `head_count` int DEFAULT '1' COMMENT '招聘人数', `signed_count` int DEFAULT '0' COMMENT '已报名人数', `status` tinyint DEFAULT '1' COMMENT '1发布中 0已下架 2已招满', `duty_desc` text COMMENT '岗位职责', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_company_id` (`company_id`), KEY `idx_status_time` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4报名表一定要设计成唯一索引绑定学生和岗位,根本不需要在程序层纠结重复报名,数据库直接拦住它:
CREATE TABLE `internship_signup` ( `id` bigint NOT NULL AUTO_INCREMENT, `student_id` bigint NOT NULL COMMENT '学生ID', `post_id` bigint NOT NULL COMMENT '岗位ID', `status` tinyint DEFAULT '0' COMMENT '0待审核 1通过 2驳回 3取消', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_student_post` (`student_id`, `post_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4周报表设计的时候,我加了一个batch_no字段,用于区分第几周。这个字段很有用。学院经常问“这周有多少学生提交了周报”,一条 SQL 就能统计出来,不需要在代码里循环判定。
4.2 RBAC权限模型与五表关联
权限设计我采用的是标准的 RBAC(基于角色的访问控制)模型。用户表不直接存角色,而是通过用户-角色关联表和角色表连接,角色再通过角色-菜单关联表和菜单表连接来控制可访问的菜单和按钮。这样做的最大优势是灵活:一个学生角色以后想加上“查看企业简介”的权限,只要在菜单表里给角色绑定新菜单,代码不用动。
后端接口做权限校验时,我是在 JWT 里放用户 ID 和角色编码,每进入需要权限的接口,从当前上下文中取出角色编码,再用@RequireRole注解判断。注解方式比在每个方法里手写 if 判断要清晰很多,也方便统一维护。
4.3 常用查询SQL与统计口径
统计报表是这类系统价值最高的部分。学院最关心的指标是实习率,我的统计口径是:有实习记录且状态为通过或结束的学生人数除以应实习学生总数。SQL 可以这样写:
SELECT d.dept_name, COUNT(s.id) AS total_student, COUNT(CASE WHEN si.status IN (1, 2) THEN 1 END) AS internship_count, ROUND( COUNT(CASE WHEN si.status IN (1, 2) THEN 1 END) / COUNT(s.id) * 100, 2 ) AS rate FROM student s LEFT JOIN dept d ON s.dept_id = d.id LEFT JOIN internship_signup si ON s.id = si.student_id AND si.status IN (1, 2) GROUP BY d.id这个统计的“坑”在于,如果直接LEFT JOIN报名表,学生会因为有多条报名记录而重复计数,所以要么先子查询去重,要么像我上面这样在 JOIN 条件上限定状态。统计结果对不上号,十有八九都是这里出了问题。
5. 上线前后的典型坑与排查思路
5.1 跨域导致接口通不了一查一个准
前后端分离部署最常见的第一个坑就是跨域。开发环境里,Vite 默认端口是 5173,后端接口在 8080,直接从前端发请求会被浏览器拦截。我的做法不是在后端代码里开 CORS,而是在 Vite 配置里加代理,把/api前缀的请求转发到后端服务:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }生产环境用 Nginx 处理,同样是把/api反向代理到后端。注意代理配置里千万不要把/api前缀直接去掉,会跟后端接口路径对上不接。我的习惯是后端所有接口统一加/api前缀,最省心。
5.2 Vue history路由刷新404
前端 Vue Router 用 history 模式时有个经典现象:在首页点进去没问题,一刷新页面就 404,或者地址栏输入二级路径直接白屏。原因是 Nginx 没有配置 vue-router 的 fallback,服务器找不到对应的物理文件。解决办法是在 Nginx 的 location 里加一行:
location / { try_files $uri $uri/ /index.html; }如果项目部署在子路径下,还需要设置router base和 Vite 的base配置,否则静态资源路径全是 404。
5.3 事务不生效的典型写法
报名和成绩录入都是要写多张表的操作,事务必须保证要么全部成功要么全部失败。@Transactional 注解看着简单,但踩过的坑真不少。最容易翻车的是:同一个 Service 内部方法调用,比如 A 方法加了事务,B 方法是私有的也加了事务,从 A 里调用 B,事务实际上不生效,因为 Spring 事务是基于 AOP 代理的,私有方法内部调用不会被代理拦截。
还有三种情况也常见:异常被 try-catch 吃掉,事务不知道要回滚;方法被final修饰,代理根本拦截不了;数据库表用的是 MyISAM,不支持事务。我的建议是:事务方法里不要吞异常,统一往上抛;事务尽量放在 Service 入口方法;回滚指定rollbackFor = Exception.class。别迷信默认配置,事务要显式处理。
5.4 MyBatis-Plus分页不生效
用了 MyBatis-Plus 之后,很多人发现selectPage返回的数据还是全量列表,以为分页失效。其实是因为分页插件没有配置进去。MyBatis-Plus 3.x 之后,分页要注册一个拦截器:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }不注册这个拦截器,selectPage只是接口形式上的“分页”,实际 SQL 还是查全部。另外分页的current和size记得做参数校验,size限制最多 100 条,防止有人传 9999 直接拖垮数据库。
5.5 时间格式和上传文件访问问题
前后端时间格式不一致,也是个遇到必头大的问题。Java 后端返回的LocalDateTime默认是2024-01-01T10:30:00,前端直接显示很丑,而且传参也容易报解析错误。我的处理方式是在后端统一配置 Jackson 的日期格式为yyyy-MM-dd HH:mm:ss,前端再配合 dayjs 做展示格式化。两边都定好标准,接口里就不要再折腾字符串转来转去了。
文件上传常见的坑是能传但访问不到。上传成功后后端返回的是“/files/xxx.jpg”,但前端拿到这个路径直接请求,却提示 404。原因就是没有配置静态资源映射:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + System.getProperty("user.dir") + "/upload/"); }还要注意:上传目录要提前创建,否则第一次上传会报FileNotFoundException;生产环境不要把文件放在临时目录或项目目录下面,重启后文件就丢了,最好是独立的数据盘或对象存储。
5.6 时间格式和上传文件访问问题
还有一个隐蔽问题需要单独说:把实体类直接返回给前端时,如果字段中包含大文本字段,比如周报内容、岗位职责,JSON 序列化和网络传输都会变慢。我当时是专门写了 VO 对象,列表页只返回摘要字段,详情页才返回完整字段,避免一次查出来几百条岗位信息全都带着大文本字段走网络。这个优化对体验提升非常明显。
我个人在实际开发中的体会是:高校实习管理系统这种项目,最大的难点从来不是某个技术点,而是业务流程实现得够不够细。把状态变化理清楚、把权限控制做严、把统计口径对准确,系统就成功了一大半。Spring Boot + Vue 这套组合,不是为了追新,而是因为从开发效率、团队熟悉度、后期维护这几个维度看,它始终是这一类系统最稳的答案。
最后再分享一个小经验:上线之前,一定要用“学生、老师、企业”三个账号各走一遍完整流程,从岗位发布到成绩发布,一个操作一个操作地过。很多问题都是在自己实际点一遍之后才暴露出来的,比如某个按钮权限配错了、某个状态跳转没写、某条统计口径差了一条数据。流程验证通过,系统才能真正交给别人用。