开箱即用的一套实习生管理系统,SpringBoot后端、Vue前端、MySQL做数据存储,三件套组合已经被无数个中小型项目验证过了。这套源码有完整的用户登录、角色权限、实习过程记录、日报周报、考勤统计、成绩评定,能直接跑起来,也适合拿来二次开发。这篇内容我不打算贴一堆官方文档,而是把整个系统的设计逻辑、核心模块的实现思路、数据库表怎么建、前后端怎么联调、部署时最容易翻车的几个点全部拆开讲一遍。无论你是打算拿这套系统做课程设计、企业内部工具,还是想学一下前后端分离项目的完整结构,后面的内容都值得认真看一遍。
1. 项目全景:一个实习管理系统到底解决了什么问题
1.1 核心需求拆解
实习生管理在很多企业里是一个“看似简单,实际人人头疼”的事情。实习生来了一波又一波,信息登记靠Excel,日报周报靠邮箱,考勤靠微信群里手动签到,最后实习结束要给个评价,又要去翻聊天记录。这套系统的出现就是把这些散落的信息收拢到一个平台上,让实习生、指导老师、企业管理员三方都在一个页面里完成自己的工作。
从角色上看,系统至少要拆成三类用户:实习生本人、指导老师(或者部门主管)、系统管理员。实习生要能维护个人资料、查看实习单位信息、打卡签到、提交日报周报、查看自己的得分;指导老师要能看到名下带的学生、审核日报周报、登记考勤异常、录入评语和分数;管理员则是综合管理,包括创建用户、分配角色、维护单位信息、查看统计报表。
从业务流程上看,实习生进入企业之后,管理员先开通账号,分配实习单位和指导老师。实习生每天登录系统签到,定期提交日报或者周报,指导老师对这些内容进行审核。实习期结束,指导老师和单位负责人打分,系统汇总成最终成绩。这个过程如果全靠线下,任何一个环节断掉都会出问题,而信息化的核心就是把流程中的每一步变成可跟踪的数据记录。
1.2 技术选型的逻辑:为什么是SpringBoot + Vue + MySQL
很多人一上来会问,为什么用SpringBoot而不是SSH或者Servlet + JSP,为什么用Vue而不是JQuery + Bootstrap。这个问题其实得结合项目交付和后续维护来看。
SpringBoot最大的优势是“配置简化 + 生态成熟”。实习生管理系统属于典型的管理信息系统,涉及用户登录、权限控制、CRUD、文件上传、报表统计,这些在Spring生态里都有非常成熟的解决方案。用SpringBoot可以迅速把项目骨架搭起来,内嵌Tomcat,打成jar包就能跑,部署成本极低。相比传统的SSH那一套,省掉了大量XML配置,对新手更友好,对老手来说写代码也更清爽。
Vue在前端的价值体现在组件化和数据驱动的页面更新。管理后台这种界面,左边菜单、顶部栏、中间内容区,加上一堆表格和表单,用Vue写起来比JQuery直接操作DOM要舒服太多。尤其是表格数据要和后端接口联动,Vue的响应式机制能自动刷新视图,不需要手动拼接HTML字符串。再加上Element UI这类组件库,表格、弹窗、表单验证开箱即用,界面能在一两天内搭出专业感。
MySQL在这个场景下完全是性价比最高的选择。实习生管理系统的数据量不大,事务要求也不高,MySQL的单机性能足够支撑几千用户的规模。而且MySQL的生态工具链非常完整,Navicat、DBeaver、mysqldump,备份、迁移、调试都方便。用PostgreSQL当然也可以,但项目要“可直接运行”,MySQL的普适性明显更强。
2. 系统设计:从数据库到接口,把业务理清楚
2.1 数据库设计:核心表结构解析
一个信息管理系统的地基在数据库。如果表建得稀烂,后面写代码全是补丁。这套系统的库名建议直接用intern_manage,字符集用utf8mb4,排序规则用utf8mb4_general_ci,因为要存中文,也可能会存表情符号,utf8mb4是最稳的。
核心表离不开这么几张:用户表、实习生信息表、实习单位表、日报/周报表、考勤表、成绩评定表。下面逐个说:
用户表sys_user是所有登录账号的根基,字段包括用户ID、用户名、密码、真实姓名、角色类型、手机号、邮箱、状态、创建时间。密码必须存加密后的密文,千万不要明文存。角色类型可以用一个字符串字段区分STUDENT、TEACHER、ADMIN,也可以用角色关联表,但小项目里直接一个字段最省事。
实习生信息表intern_info和用户表是一对一的关系,额外记录学号、学校、专业、实习单位ID、指导老师ID、实习开始日期、实习结束日期、实习状态。学校和专业用普通字符串就行,不需要单独建字典表,除非你的字典项特别多。
实习单位表company_info记录单位名称、统一社会信用代码、单位地址、联系人、联系电话、单位性质。这个表可以被实习生表和成绩评定表引用。
日报/周报表intern_report是业务流水表,字段包括ID、实习生用户ID、报告类型(日/周)、报告日期、所属周、工作内容、问题与收获、指导老师评语、审核状态、提交时间、审核时间。审核状态用数字0/1/2表示待审核、已通过、已驳回,比用字符串更有效率。
考勤表attendance_record字段包括ID、实习生用户ID、签到日期、签到时间、签到状态、备注。每天一人一条记录,当天重复签到就做更新而不是插入新记录。
成绩评定表score_assessment字段包括ID、实习生用户ID、单位评分、导师评分、自评分、综合得分、评价内容、评定时间。综合得分可以前端算,也可以后端算,建议后端算,保持逻辑统一。
这里有一个容易忽略的点:所有业务表都要带上create_time和update_time,哪怕你觉得没必要。后面排查数据问题、写统计SQL的时候,时间字段会让你省掉一大半麻烦。
2.2 后端工程结构与接口分层
后端包结构建议按模块分,不要一个包下面堆一百个类。我建议这样拆:
com.example.intern ├── common │ ├── Result.java │ ├── ResultCode.java │ └── GlobalExceptionHandler.java ├── config │ ├── CorsConfig.java │ └── WebMvcConfig.java ├── controller │ ├── AuthController.java │ ├── InternController.java │ ├── ReportController.java │ ├── AttendanceController.java │ └── ScoreController.java ├── service │ ├── AuthService.java │ ├── InternService.java │ ├── ReportService.java │ └── ScoreService.java ├── mapper │ ├── UserMapper.java │ ├── InternMapper.java │ ├── ReportMapper.java │ └── ... ├── entity │ ├── SysUser.java │ ├── InternInfo.java │ └── ... └── utils └── JwtUtils.javaController层只做参数接收和结果返回,Service层写业务逻辑,Mapper层通过MyBatis操作数据库。这样分层的好处是,如果前端字段变了,你只需改Controller;如果业务规则变了,你只需改Service;如果数据库表改了,你只需改Mapper。
统一返回结果类是前后端联调的基础。每个接口返回的JSON结构要一致,比如:
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "操作成功"; result.data = data; return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } }前端只要判断code === 200就取data,其余情况弹message。这个约定看上去简单,但能省掉大量因返回结构不一致导致的联调时间。
全局异常处理也很重要。写代码的时候难免出现空指针、参数不合法,如果不做兜底,系统会直接返回Tomcat默认错误页,前端根本没法解析。用@RestControllerAdvice拦截所有异常,统一返回Result.error(500, e.getMessage()),这样后端崩了前端也能看到一条明确提示,而不是白白刷新页面。
2.3 前端页面与交互逻辑
Vue前端工程目录按页面模块划分,整个后台采用经典布局:左侧菜单、顶部栏、中间内容区。路由表用动态渲染,根据用户角色过滤可见菜单。
src ├── api │ ├── auth.js │ ├── intern.js │ ├── report.js │ └── score.js ├── router │ └── index.js ├── store │ └── user.js ├── views │ ├── login.vue │ ├── layout.vue │ ├── intern/info.vue │ ├── report/list.vue │ ├── attendance/calender.vue │ └── score/table.vue └── utils └── request.jsaxios请求封装建议统一放在request.js,设置基础URL、超时时间、拦截器。请求拦截器里带上token,响应拦截器里统一处理401跳转登录和错误提示。
import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.clear() window.location.href = '/login' } else { ElMessage.error('网络异常,请稍后重试') } return Promise.reject(error) } ) export default request这个封装几乎是所有Vue后台管理项目的通用模板,拿来直接用就行。注意baseURL要配合开发环境的代理,否则会出现跨域。
3. 核心功能实现:几个关键模块的实战拆解
3.1 权限控制:如何做到不同角色各看各的
权限控制是信息系统绕不开的环节。这套系统最简单可靠的方案是“登录认证 + 角色拦截”。
后端使用JWT做登录认证。用户登录成功后,根据用户名查询用户信息,把用户ID和角色塞进token,设置有效期,返回给前端。前端把token存在localStorage里,每次请求在请求头带上。后端通过一个拦截器验证token,解析出用户信息后存入ThreadLocal,后面的Controller和Service直接从ThreadLocal取当前用户,不用每个接口都传用户ID。
JWT工具类的核心代码大致是这样:
public class JwtUtils { private static final String SECRET = "your-secret-key-change-me"; private static final long EXPIRE = 7 * 24 * 60 * 60 * 1000L; public static String createToken(String userId, String role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }拦截器里对不需要认证的接口(比如登录接口、验证码接口)放行,其余接口全部校验。校验通过后,可以根据角色再做接口级控制:比如管理员可以调用GET /api/intern/list查看所有实习生,而实习生只能拿到自己的数据。最简单的做法是在Service层加一个判断:
if ("STUDENT".equals(userRole)) { query.setInternUserId(currentUserId); }前端页面同样要做角色控制。路由表里给每个路由配置meta.roles,比如:
{ path: '/score', component: Layout, meta: { roles: ['ADMIN', 'TEACHER'] }, children: [ { path: 'list', component: ScoreList, meta: { roles: ['ADMIN', 'TEACHER'] } } ] }在路由守卫里读取当前用户的角色,如果不在可访问列表里,就重定向到首页。不要只靠前端隐藏菜单,后端必须做校验,否则别人直接调接口就能越权。
3.2 实习过程管理:从签到到日报的流转
实习过程管理是整个系统的业务核心。实习生每天登录系统后的第一件事就是签到。签到接口的逻辑是:先判断当前用户是否是实习生角色,再查今天是否已经签到。如果已签到,直接返回“今日已签到”;如果没签到,插入一条记录。
为了避免重复插入,数据库层面可以给attendance_record表加一个唯一索引uk_intern_date(intern_user_id, sign_date),这样即使并发请求,数据库也会拒绝重复记录,而不是让代码去判断。这个做法看起来简单,但很多新手会忽略,导致并发情况下数据有两条。
日报提交的逻辑类似。实习生填写工作内容、遇到问题、明日计划,提交后状态是“待审核”。指导老师打开审核列表,看到自己名下的实习生提交的报告,可以点击通过或者驳回,驳回时必须填写原因,实习生端会看到驳回原因并重新编辑提交。这里的状态流转可以用数字字段控制:0待审核 1已通过 2已驳回。
写一个提交日报的接口示例:
@PostMapping("/report/submit") public Result<Void> submitReport(@RequestBody ReportSubmitDTO dto) { SysUser currentUser = UserContext.getCurrentUser(); if (!"STUDENT".equals(currentUser.getRole())) { return Result.error(403, "只有实习生可以提交报告"); } Report report = new Report(); report.setInternUserId(currentUser.getId()); report.setReportType(dto.getReportType()); report.setReportDate(dto.getReportDate()); report.setContent(dto.getContent()); report.setStatus(0); reportService.save(report); return Result.success(null); }实际代码里还要校验报告日期是否在实习期内、同一日期的报告是否重复提交等细节。这些校验逻辑一旦漏掉,就会在真实使用中冒出一堆脏数据。
3.3 成绩评定与统计报表
成绩评定涉及三个维度:实习生自评、指导老师评分、实习单位评分。每个维度满分100分,最后综合得分按权重计算:自评20%,导师评价50%,单位评价30%。权重可以在系统参数表里配置,也可以写死在代码里,为了演示方便先写死,但设计时最好留一个配置项。
后端在计算综合得分时,先查出三个分数,然后加权:
double finalScore = selfScore * 0.2 + teacherScore * 0.5 + companyScore * 0.3;成绩表和实习考核表可以通过实习记录ID关联,不过更简单的方式是直接挂在实习生ID下,一个实习生只有一个最终成绩。
统计报表部分需要写SQL做分组聚合。比如查询每个实习单位的平均分:
SELECT c.company_name, ROUND(AVG(s.final_score), 2) AS avg_score FROM score_assessment s LEFT JOIN intern_info i ON s.intern_user_id = i.user_id LEFT JOIN company_info c ON i.company_id = c.id GROUP BY c.company_name ORDER BY avg_score DESC;后端用MyBatis写一个对应的Mapper方法,返回一个Map列表,前端用ECharts展示柱状图或者饼图。这里要注意SQL中的LEFT JOIN一定不能在连接条件上放多余过滤条件,比如想过滤实习生状态,要写在WHERE中,否则会丢数据。
4. 环境准备与直接运行:让人少踩坑的部署全过程
标题里写了“可直接运行”,这背后其实藏着不少环境坑。下面按我实测的流程走一遍。
4.1 基础环境要求
后端部分需要JDK 1.8及以上,推荐1.8,因为SpringBoot 2.x在1.8下最稳定,不会出现新版本特性带来的兼容问题。Maven 3.6+,用来拉取依赖。前端部分需要Node.js 14+,npm 6+,如果你装的是Node 18,也没关系,但是很多旧依赖的版本会冲突,所以建议用Node 14.21.3这种稳定版本。数据库用MySQL 5.7或8.0,5.7在老设备上兼容性更好,8.0功能更新但需要对应驱动版本,如果项目里mysql-connector-java是5.x,连8.0会报错,所以最好按源码的配置走。
4.2 初始化数据库
拿到源码后,在源码目录下找到一个类似sql/intern_manage.sql的文件。用Navicat或者命令行创建数据库并导入:
mysql -u root -p -e "CREATE DATABASE intern_manage DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -u root -p intern_manage < sql/intern_manage.sql导入成功后,打开后端的application.yml,修改数据库连接信息:
spring: datasource: url: jdbc:mysql://localhost:3306/intern_manage?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver这里最需要注意的是URL里的serverTimezone=Asia/Shanghai。没有这个参数,Java连接MySQL 8.0会报时区异常,页面上的时间也会差8小时。
4.3 启动后端服务
在项目根目录下打开终端,先执行:
mvn clean install -DskipTests然后启动:
mvn spring-boot:run如果没有报错,日志里会看到Tomcat started on port(s): 8080。如果你想打包部署,执行mvn clean package -DskipTests,然后:
java -jar target/intern-management-1.0.0.jar提醒一句:检测端口是否被占用时,注意有些机器的8080端口被其他服务占着。可以用netstat -ano | findstr 8080查看,如果占用就改配置。
4.4 启动前端工程
前端目录一般是frontend或vue-web。进入目录后:
npm install npm run serve第一次npm install会很慢,因为要拉几十个依赖包。如果遇到网络问题,可以使用国内镜像源:
npm config set registry https://registry.npmmirror.com安装成功后,运行npm run serve,默认端口是8080,但后端已经占用了8080,Vue的脚手架会检测到端口冲突,自动改用8081。所以前端访问地址通常是http://localhost:8081。
开发环境下跨域需要在根目录创建vue.config.js:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }这个配置的含义是:前端把请求发给/api开头的地址,开发服务器转发到后端的8080,并且把路径中的/api前缀去掉。如果你的后端接口本身就带/api前缀,就不用pathRewrite。这个细节很容易搞错,一旦配置错,接口会404。
4.5 验证系统可访问
打开浏览器,输入http://localhost:8081,正常情况会跳到登录页。源码里一般预置了三个账号:admin/admin123、teacher/123456、student/123456。先用admin登录,左侧菜单应该能看到全部功能模块。点击“实习生管理”,可以新增一条实习生数据,然后给该实习生关联一个账号;切换到实习生账号登录,能看到自己的信息、签到功能和日报提交流程。如果这几个流程走通,说明系统运行正常。
5. 实际运行中常见问题与排查经验
5.1 数据库连接失败的 5 种可能
后端启动或者登录时报数据库连接错误,基本逃不出下面几种情况:
| 现象 | 原因 | 解决方式 |
|---|---|---|
Access denied for user | 用户名或密码不对 | 检查application.yml中的账号密码和数据库实际密码是否一致 |
Unknown database 'intern_manage' | 数据库没有创建或名字写错 | 执行CREATE DATABASE后重新导入SQL |
The server time zone value ... | 缺少时区参数 | URL加上serverTimezone=Asia/Shanghai |
Public Key Retrieval is not allowed | MySQL 8.0的认证插件导致 | URL加上allowPublicKeyRetrieval=true |
Communications link failure | 数据库服务没启动或端口不对 | 检查MySQL服务是否运行、端口是3306还是其他 |
每一条都是真实踩出来的。尤其是allowPublicKeyRetrieval这个参数,MySQL 8.0默认情况下连接会直接被拒绝,很多人查半天查不到原因。
5.2 跨域问题的定位与处理
前端的请求发出去了,但浏览器控制台报No 'Access-Control-Allow-Origin' header is present,这就是跨域问题。
解决跨域有两个办法。开发环境优先使用Vue的proxy代理,前面已经写了配置。生产环境如果前端和后端不在一起,需要后端开启CORS:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .maxAge(3600); } }还有一个容易忽略的点:使用了JWT后,前端请求头带了Authorization,那allowedHeaders("*")必须写,否则浏览器会以预检请求的形式拦掉带自定义头的请求。
5.3 前端页面空白或接口404
页面空白大概率是路由问题。如果你用的是history模式,并且部署到Nginx,没有配置try_files,刷新页面就会404。开发环境很少遇到,但打包部署后很常见。Nginx配置需要加:
location / { try_files $uri $uri/ /index.html; }如果是接口404,先看请求路径。开发环境下,前端proxy代理的target写错,或者pathRewrite把不该去掉的前缀去掉了,都会导致后端找不到接口。最简单的排查方式:打开前端控制台,看Network里的请求URL,复制出来在浏览器直接访问,看返回什么。
5.4 端口冲突与配置修改
后端端口、前端端口、数据库端口都可能撞车。最常见的场景:启动了两个无关的Java服务,8080被占用,导致SpringBoot启动失败。这时候别急着改端口,先查占用。
如果确实需要修改后端端口,在application.yml里写:
server: port: 8082同时前端proxy的target也要改成http://localhost:8082。前端端口则通过vue.config.js的devServer.port修改。记住一个原则:端口改动一定要同时检查后端配置、前端代理、以及页面里的接口地址,漏一个就废。
6. 项目扩展方向:从可用到好用的升级建议
6.1 增加消息通知与定时任务
现在的系统都是用户主动刷新才能看到新数据。比如指导老师审核了日报,实习生要自己点进详情页才能看到结果。更好的做法是加入站内消息通知:审核状态一变,实习生登录后首页就有未读消息。
后端可以用Spring自带的Scheduled定时任务,也可以引入消息队列,但小系统没必要上重量级框架。用一个消息表记录通知,前端每隔30秒拉一次未读数量,这个方案足够简单可靠。定时任务还有一个典型用途:每天晚上自动把当天未签到的实习生生成提醒列表,第二天早上推送给指导老师。
6.2 引入Excel导入导出
管理员经常需要批量导入实习生名单,而不是一个个在网页上添加。用Apache POI或者阿里开源的EasyExcel都能实现。EasyExcel性能更好,API也清爽得多。导入时先解析Excel,逐行校验必填项、学号重复、用户是否已存在,最后批量插入。导出功能则可以把实习名单、成绩表一键生成Excel文件下载,这个功能在真实企业场景里几乎天天用到。
6.3 部署方式升级
现在直接在服务器上java -jar和npm run build拷贝静态文件,已经能应付大部分需求。但如果换机器或者多环境部署,环境差异会带来麻烦。建议用Docker Compose把MySQL、后端、前端全部编排起来:
version: '3' services: mysql: image: mysql:5.7 container_name: intern-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: intern_manage ports: - "3306:3306" volumes: - ./sql:/docker-entrypoint-initdb.d backend: build: ./backend container_name: intern-backend ports: - "8080:8080" depends_on: - mysql frontend: build: ./frontend container_name: intern-frontend ports: - "80:80" depends_on: - backend这样在一台新服务器上只需一条docker-compose up -d就能全部跑起来,初始化SQL也会在MySQL容器首次启动时自动执行。
6.4 安全增强与日志监控
这套系统已经有JWT认证,但距离生产标准还差几步。密码如果存的是明文,赶紧改成BCrypt加密;接口层要加上参数校验注解,比如@NotNull、@Size;对于频繁调用的登录接口、接口日志输出,建议引入Logback的按天滚动日志,并且把登录失败记录、异常堆栈单独输出到文件。线上环境还要加一个健康检查接口,配合运维监控系统定时探测,系统挂了第一时间告警。
我在实际维护这类系统时最大的体会是:功能开发只占一半精力,另一半全在环境配置、边界条件校验和数据一致性上。比如日报同日重复提交、成绩权重变更、用户被删除后历史数据保留,这些细节看起来不起眼,却决定了系统能不能从演示Demo走向真正长期使用。把这套源码跑起来只是第一步,后续的每一次需求变更都会逼着你把设计想得更完整。如果你准备在这个基础上做二次开发,建议先在数据库层把所有外键关系梳理清楚,再动手改代码,会少走很多弯路。