接手这种“毕业生实习与就业管理系统”的项目,第一反应是技术栈怎么选。很多同学私信我,说学校布置的课题或者公司接到类似外包,上来就不知道该用Spring Boot还是Spring Cloud,前端是选Vue2还是Vue3,数据库用MySQL5.7还是8.0。这其实是个典型的全栈业务系统,核心需求就是学生、企业、岗位、实习记录、就业统计这些常规模块,技术选型上没必要过度设计。我这次用SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0跑完整套,从数据库建表、后端接口开发到前端页面联调,一趟下来整套组合的体验相当顺,特别是MyBatis-Plus配合MySQL8.0,业务开发效率确实高。这套方案适合毕设、课程设计和中小型外包项目,也适合想系统了解Java Web全栈开发流程的读者照着落地。
注意:本文以一个真实可运行的毕业生实习与就业管理系统为背景,所有代码只在开发环境验证过,生产部署还需要根据实际服务器配置做调整。
1. 项目整体思路与技术选型拆解:为什么是SpringBoot2而不是3,为什么一定要配Vue3
很多第一次接触这个项目的人都会问,既然Spring Boot 3都出了,为什么还用SpringBoot2?这不是因为我不会用新的,而是项目本身的技术约束和生态兼容决定的。毕业生实习与就业管理系统这种体量,核心功能是信息管理、流程审批、统计报表,对性能和并发的要求并没有那么极端,SpringBoot2.7.x已经非常稳定,加上很多企业级中间件、旧版依赖和教程资料都还停留在2.x时代,踩坑成本更低。另外,如果你要在此基础上接工作流引擎、对接学校统一身份认证,SpringBoot2的兼容性反而更省心。
Vue3这边没什么好纠结的,既然是新项目,直接上Vue3。Vue3的Composition API把逻辑复用这件事做得比Vue2舒服太多,尤其在管理系统这种表格多、表单多、弹窗多的场景下,用setup函数把数据请求、分页逻辑、表单校验拆得清清楚楚。配合Element Plus组件库(Vue3专用版Element UI),整个后台管理界面的开发速度非常快,我实测一个包含学生管理、企业信息管理、岗位发布、实习申请审批的页面,从零开始写核心逻辑大概两天就能成型。
MyBatis-Plus在这个项目里的角色是“不用写SQL的数据库访问层”。它底层还是MyBatis,但提供了一套通用Mapper和通用Service,单表的增删改查完全不用手写XML,分页查询一行代码搞定。毕业生管理系统里,学生表、教师表、企业表、实习记录表这些都是典型的单表操作,用MyBatis-Plus可以把大量CRUD代码省掉,代码量至少少写40%。
MySQL8.0的选择更简单。8.0是当前应用最广的稳定版本,窗口函数、公共表表达式(CTE)、更好的JSON支持功能,在写就业统计报表的时候非常有用。比如统计各专业就业率、按月统计实习转正人数这类查询,用窗口函数一条SQL就能写完,换成MySQL5.7你得做好几次子查询嵌套。
这套技术组合的核心优势我总结为三点:
- 开发效率高:MyBatis-Plus消灭重复CRUD,Vue3组件化让前端代码可以复用,这是单兵作战或者小团队开发最看重的事
- 学习曲线平缓:每一个环节都有大量中文资料和现成案例,遇到问题基本能搜到答案,对毕业生做毕设非常友好
- 扩展性适中:后面前端要加图表大屏,Vue3配合ECharts没问题;后端要接Redis缓存、RabbitMQ消息队列,SpringBoot2也能直接集成
这套组合适合的场景很明确:中小型业务管理系统、学校/企业信息化项目、毕业设计。如果要支撑百万级用户、海量并发,那就得上Spring Cloud微服务全家桶了,但那种复杂度对于这个项目来说完全是过度设计。
1.1 系统功能模块划分
我把毕业生实习与就业管理系统的功能拆成了三个阶段:
- 实习生阶段:学生投递岗位、企业发布实习岗位、教师审核实习申请、记录实习周报
- 就业阶段:企业发布校招信息、学生填写就业意向、提交就业协议、管理员审核就业信息
- 统计阶段:按专业/班级统计就业率、实习转正率、企业用人需求分析
这三大模块对应数据库里大约十四张表,核心的表包括:用户表、学生信息表、教师信息表、企业信息表、岗位表、实习申请记录表、实习周报表、就业信息登记表、三方协议信息表、通知公告表。
功能模块划分清楚之后,前后端的接口设计就有的放矢了。比如“实习申请审批”这个功能,后端只需要设计三个接口:学生提交申请、教师查询待审批列表、教师提交审批结果。前端对应三个页面组件或者一个页面的三种状态。
1.2 技术卡点:MySQL8.0.Caused by: java.sql.SQLException的坑位预判
项目开始之前,我把可能遇到的环境坑提前列出来,尤其是MySQL8.0和旧版驱动的兼容问题。很多人第一次用MySQL8.0,拿着MySQL5.x时代的JDBC驱动去连接,启动SpringBoot直接报java.sql.SQLException: Unable to load authentication plugin 'caching_sha2_password'。这是8.0默认认证插件从mysql_native_password换成了caching_sha2_password导致的,解决办法也简单,要么在pom.xml里用mysql-connector-java的8.x版本,要么在数据库里把用户的认证插件改回去。我们项目里直接用8.0.33版本的驱动,没有任何问题。
时区问题也必须提前处理。MySQL8.0对时间类型更较真,URL里不加serverTimezone=Asia/Shanghai,插入日期数据时大概率会报“The server time zone value”相关错误。我一般会在JDBC连接串里写成这样:
jdbc:mysql://localhost:3306/internship_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=trueallowPublicKeyRetrieval=true这个参数在MySQL8.0中也需要提醒一下,如果使用caching_sha2_password认证且未配置SSL,客户端需要获取服务器公钥,没有这个参数会收到连接失败的错误。
2. 后端核心实现:SpringBoot2 + MyBatis-Plus从建表到分页条件查询一次打通
后端开发这块,我按“配置环境 → 设计数据库 → 用MyBatis-Plus搭CRUD → 写业务接口”的顺序一步步来说,里面包含关键的参数配置、代码片段和实际踩坑过的细节。
2.1 项目初始化与核心依赖配置
用IDEA新建SpringBoot项目时,我习惯用Spring Initializr直接选依赖再导入,使用SpringBoot2.7.x版本。选依赖这一步我只选了Spring Web、MySQL Driver、Lombok这三个起步依赖,MyBatis-Plus和JWT这类第三方库手动加到pom.xml里。这样的好处是起步依赖保持最小化,不用的东西别往项目里塞。
核心的依赖配置如下,需要注意MyBatis-Plus版本选择,用3.5.3以上会省掉很多旧版本的坑:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.17</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>Lombok在当前项目里强烈建议加上,实体类几十个字段,手动写getter/setter能写到怀疑人生。@Data注解搞定一切,但注意Lombok和JDK版本之间的兼容问题,如果用的是高版本JDK,建议把Lombok版本升级到1.18.30以上。
2.2 application.yml配置:数据源、日志、MyBatis-Plus映射
接下来是核心配置文件。我给出的配置是在开发环境直接可用的,生产环境只需要把数据库密码和地址换成实际值:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/internship_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:mapper/**/*.xml这里有三个容易被忽视的细节:
map-underscore-to-camel-case: true:数据库字段是student_name,实体属性是studentName,自动完成驼峰映射,不用手动写resultMapid-type: assign_id:默认使用雪花算法生成分布式ID,比数据库自增更灵活,将来分库分表不用改代码logic-delete-field: deleted:逻辑删除配置,删除记录不是真的DELETE,而是将deleted字段置为1,查询时会自动追加WHERE deleted = 0条件。学生就业信息这种数据,逻辑删除比物理删除安全得多
在开发阶段我开启了StdOutImpl日志打印,每执行一条SQL都会在控制台输出,方便排查问题。生产环境记得关掉。
2.3 MyBatis-Plus代码生成与通用CRUD
MyBatis-Plus官方有代码生成器,虽然说是“代码生成器”但其实只是一个main方法,用起来非常简单。我个人喜欢把代码生成器单独写成一个类,放在test目录下,需要生成的时候跑一次:
public class CodeGenerator { public static void main(String[] args) { FastAutoGenerator.create("jdbc:mysql://localhost:3306/internship_db?serverTimezone=Asia/Shanghai", "root", "123456") .globalConfig(builder -> builder.author("yourname").outputDir("/path/to/src/main/java")) .packageConfig(builder -> builder.parent("com.example.internship").entity("entity").service("service").serviceImpl("service.impl").mapper("mapper").controller("controller")) .strategyConfig(builder -> builder.addInclude("student", "enterprise", "position", "internship_apply", "employment_info") .entityBuilder().enableLombok() .controllerBuilder().enableRestStyle()) .execute(); } }跑完之后会生成实体类、Mapper接口、Service接口、ServiceImpl实现类、Controller,一套完整的CRUD就出来了。生成出来的Controller直接就是REST风格接口,@RestController加@RequestMapping("/student")。生成的Service继承了IService,ServiceImpl继承了ServiceImpl,这样一来save、updateById、getById、list、page这些基础方法全部开箱即用。
实际开发中,通用CRUD只是第一步。真正的业务逻辑还得自己写,比如“学生投递岗位”这个操作,需要同时判断岗位是否还在招聘、学生是否存在重复投递,然后把申请记录写入实习申请表:
@Service public class InternshipApplyServiceImpl extends ServiceImpl<InternshipApplyMapper, InternshipApply> implements InternshipApplyService { @Override public boolean submitApply(InternshipApplyDTO dto) { // 1. 校验岗位是否存在且状态为“招聘中” Position position = positionService.getById(dto.getPositionId()); if (position == null || !"OPEN".equals(position.getStatus())) { throw new BusinessException("岗位不存在或已停止招聘"); } // 2. 校验该学生是否已经投递过该岗位 LambdaQueryWrapper<InternshipApply> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(InternshipApply::getStudentId, dto.getStudentId()) .eq(InternshipApply::getPositionId, dto.getPositionId()) .eq(InternshipApply::getDeleted, 0); long count = this.count(wrapper); if (count > 0) { throw new BusinessException("您已投递过该岗位,请勿重复提交"); } // 3. 写入申请记录 InternshipApply apply = new InternshipApply(); BeanUtils.copyProperties(dto, apply); apply.setStatus("PENDING_REVIEW"); return this.save(apply); } }注意这里我用的是LambdaQueryWrapper而不是传统字符串列名的QueryWrapper,好处是类型安全,如果字段名写错了编译期直接报错。eq(InternshipApply::getStudentId, ...)这种写法可读性也比eq("student_id", ...)好得多。
2.4 分页查询与多表条件筛选的实现方式
后台管理系统里分页查询是高频中的高频。MyBatis-Plus分页必须先注册一个分页插件拦截器,不注册的话page方法只会在内存中假分页,数据量一大直接内存溢出。这是新手最容易踩的坑之一。我用一个配置类来注册:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页插件的DbType.MYSQL参数一定不能省。如果不指定数据库类型,部分分页SQL生成的limit语法会出问题。
分页查询的Service层写法如下,我以“按条件分页查询实习申请列表”为例:
@Override public Page<InternshipApplyVO> getApplyPage(ApplyQueryDTO query) { Page<InternshipApply> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<InternshipApply> wrapper = new LambdaQueryWrapper<>(); // 动态拼接查询条件,只拼接非空条件 wrapper.eq(StrUtil.isNotBlank(query.getStatus()), InternshipApply::getStatus, query.getStatus()); wrapper.like(StrUtil.isNotBlank(query.getStudentName()), InternshipApply::getStudentName, query.getStudentName()); wrapper.eq(query.getPositionId() != null, InternshipApply::getPositionId, query.getPositionId()); wrapper.orderByDesc(InternshipApply::getCreateTime); Page<InternshipApply> result = this.page(page, wrapper); // 转换成VO返回给前端,避免把实体所有字段暴露出去 return convertToVO(result); }这里wrapper.like(StrUtil.isNotBlank(query.getStudentName()), ...)这个写法值得多说一句。第一个参数是boolean类型,如果条件为false,这个条件不会拼接到SQL里,这是处理“非必填查询条件”的标准做法。很多小白写动态SQL时用if判断拼接字符串,在MyBatis-Plus里完全没必要。
2.5 就业统计报表:MySQL8.0窗口函数实战
到就业统计报表这个环节,MySQL8.0的窗口函数就派上大用场了。比如统计各专业最新就业率,传统写法是子查询加GROUP BY分组,非常繁琐,而且容易把分组数据搞错。8.0里直接用窗口函数:
SELECT major_name, total_count, employed_count, ROUND(employed_count / total_count * 100, 2) AS employment_rate, RANK() OVER (ORDER BY employed_count / total_count DESC) AS rate_rank FROM ( SELECT s.major_name, COUNT(*) AS total_count, SUM(CASE WHEN e.employment_status = 'EMPLOYED' THEN 1 ELSE 0 END) AS employed_count FROM student s LEFT JOIN employment_info e ON s.id = e.student_id GROUP BY s.major_name ) t在MyBatis中,我通过@Select注解直接把这个SQL写进Mapper接口,如果是一个较复杂的SQL语句,可以在注解中传入比“直接写XML”更简洁的方式。复杂SQL建议还是及时放到XML里管理,避免注解拼接可读性太差:
@Mapper public interface StatsMapper { @Select("SELECT major_name, total_count, employed_count, " + "ROUND(employed_count / total_count * 100, 2) AS employment_rate " + "FROM (...) t") List<MajorStatsVO> getMajorEmploymentStats(); }关于窗口函数,我要特别强调一个使用场景:计算“就业率环比变化”。如果用MySQL5.7,你得在应用层循环遍历或者多次查询对比,代码又长又容易错。MySQL8.0的LAG()函数可以直接取上一行数据的值,一条SQL就解决。
这类“用对了版本的特性就能大幅简化代码”的点,正是选择MySQL8.0而不是5.7的核心原因。
3. 前端工程落地:Vue3 + Element Plus从搭建到列表页面实现
后端接口设计好了,前端部分如何快速构建也至关重要。Vue3生态目前已经很成熟,我用的是Vite + Vue3 + JavaScript + Element Plus + Pinia + Vue Router + Axios这套组合。TypeScript是另一个选项,但考虑到业务系统的学习成本和开发速度,我选择JavaScript,适合更多初中级开发者直接上手。
3.1 用Vite创建项目与目录结构规划
创建项目的命令很简单:
npm create vite@latest internship-web -- --template vue cd internship-web npm install创建完成后,我建议直接把src目录结构调整为modules模式,按业务模块组织而不是按文件类型组织:
src/ ├── api/ # 所有接口请求定义 │ ├── auth.js │ ├── student.js │ ├── enterprise.js │ └── stats.js ├── assets/ # 静态资源 ├── components/ # 通用组件 │ ├── TablePage.vue # 通用列表页封装 │ └── SearchForm.vue # 通用搜索表单 ├── layout/ # 后台布局 ├── router/ # 路由配置 ├── store/ # Pinia状态管理 ├── views/ # 页面组件 │ ├── student/ │ ├── enterprise/ │ └── stats/ ├── utils/ │ └── request.js # Axios封装这种组织方式最大的好处是:当你需要改学生模块的某个功能时,API、页面、请求封装都在相邻位置,不用在api目录和views目录来回跳。
3.2 路由与权限控制:Vue Router4的三种拦截方式
管理系统的路由权限是个绕不开的话题。毕业生实习与就业管理系统里有三种角色:学生、教师(含院系管理员)、企业HR、系统管理员。每个角色能看到的菜单和能访问的页面不同,页面级权限控制我采用动态路由 + 路由守卫的方式。
在router/index.js中,先定义公共路由(登录页、注册页),再根据登录返回的角色信息动态添加对应路由:
const router = createRouter({ history: createWebHistory(), routes: constantRoutes }) // 白名单:无需登录即可访问 const whiteList = ['/login', '/register'] router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (token) { if (to.path === '/login') { next({ path: '/' }) } else { const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}') if (userInfo.role && userInfo.routesLoaded) { next() } else { // 从后端获取当前用户的菜单/路由权限,动态添加路由 generateRoutes(userInfo.role).then(accessRoutes => { accessRoutes.forEach(route => router.addRoute(route)) next({ ...to, replace: true }) }) } } } else { if (whiteList.includes(to.path)) { next() } else { next('/login') } } })这里有一个容易忽略的点:动态添加路由之后,一定要next({ ...to, replace: true })重新导航一次,否则页面会因为路由表还没更新而跳到空白页。这是Vue Router4动态路由最常见的坑。
按钮级权限(比如“审批”按钮只有教师角色才显示)我直接用自定义指令v-permission控制,实现简单,代码复用性高,不必把一个按钮也做成动态组件。
3.3 Axios请求封装与Token刷新逻辑
Axios封装这部分,管理系统的前端工程基本都能通用。我在utils/request.js里做了三层封装:
第一层是实例化Axios,设置基础URL和超时时间:
const service = axios.create({ baseURL: '/api', timeout: 15000 })注意这里设置了baseURL: '/api',实际后端接口是/api/student/list,在开发环境下通过Vite代理转发到后端8080端口,避免开发时跨域:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } }第二层是请求拦截器,统一在请求头加Token:
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config })第三层是响应拦截器,统一处理HTTP状态码和业务状态码,同时处理Token过期自动跳转登录页:
service.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res } if (res.code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject('登录已过期') } ElMessage.error(res.message || '请求失败') return Promise.reject(res) }, error => { ElMessage.error(error.message || '网络异常,请稍后重试') return Promise.reject(error) } )Token过期自动跳转这块,我在实际项目中吃过亏。原来只是弹出提示,不跳转,用户需要手动刷新页面才能重新登录。后来改成401时清空本地存储并跳转登录页,体验顺畅很多。
3.4 列表页面的Vue3组件实现:Composition API实战
列表页面是后台管理系统最常见的页面形态。我以“实习申请管理列表”为例,展示Vue3的Composition API怎么组织搜索、分页、表格、弹窗这些逻辑:
<template> <div class="apply-page"> <SearchForm :model="queryParams" @search="handleQuery" @reset="handleReset" /> <el-table :data="tableData" v-loading="loading" border stripe> <el-table-column prop="studentName" label="学生姓名" /> <el-table-column prop="positionName" label="岗位名称" /> <el-table-column prop="enterpriseName" label="企业名称" /> <el-table-column prop="status" label="状态"> <template #default="{ row }"> <el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag> </template> </el-table-column> <el-table-column label="操作" width="180"> <template #default="{ row }"> <el-button v-if="canReview(row)" type="primary" link @click="handleReview(row)">审批</el-button> <el-button type="danger" link @click="handleDelete(row)">删除</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="queryParams.pageNum" v-model:page-size="queryParams.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="fetchList" @current-change="fetchList" /> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { getApplyPageApi, deleteApplyApi } from '@/api/apply' const loading = ref(false) const tableData = ref([]) const total = ref(0) const queryParams = ref({ pageNum: 1, pageSize: 10, status: '', studentName: '', positionId: undefined }) async function fetchList() { loading.value = true try { const res = await getApplyPageApi(queryParams.value) tableData.value = res.data.records total.value = res.data.total } finally { loading.value = false } } function handleQuery() { queryParams.value.pageNum = 1 fetchList() } function handleReset() { queryParams.value = { pageNum: 1, pageSize: 10, status: '', studentName: '', positionId: undefined } fetchList() } async function handleDelete(row) { await ElMessageBox.confirm('确定删除该条申请记录吗?', '提示', { type: 'warning' }) await deleteApplyApi(row.id) ElMessage.success('删除成功') fetchList() } onMounted(() => { fetchList() }) </script>3.5 Element Plus按需自动导入与主题定制
Element Plus在Vue3项目里的集成有两种方案:全量引入和按需自动导入。管理后台这种实际使用组件很多的项目,我选全量引入也没什么问题,因为打包后项目本身就几MB,这点体积对于后台项目来说完全能接受。但如果讲究优化,可以选按需自动导入方案:
npm install -D unplugin-vue-components unplugin-auto-import然后在vite.config.js中配置:
import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] })按需自动导入后,在组件中使用<el-button>不需要手动import,代码更干净,打包体积也更小。但有一个坑位必须提前说明:按需自动导入模式,ElMessage、ElMessageBox这类命令式组件,需要在对应函数前手动引入样式,否则弹出的提示没有样式,看起来像裸的HTML。解决方法是单独创建一个utils/element-plus.js,集中注册几个常用的全局组件:
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus' import 'element-plus/es/components/message/style/css' import 'element-plus/es/components/message-box/style/css' import 'element-plus/es/components/notification/style/css' export { ElMessage, ElMessageBox, ElNotification }这个问题如果不在开发阶段处理,上线后会出现各种按钮都能点、提示信息却完全失控的诡异现象,排查半天发现就是样式没引入。
4. 前后端联调、跨域与鉴权这“三座大山”
把后端和前端分别跑起来容易,联调阶段才是真正考验人耐心的时候。毕业生实习与就业管理系统涉及多个角色权限,联调时跨域问题、登录鉴权问题、接口数据格式对齐问题几乎一定会遇到。我把这段时间踩过坑和最终稳定的做法整理出来,供大家直接参考。
4.1 跨域问题:前后端分离的第一个拦路虎
前端跑在5173端口,后端跑在8080端口,浏览器的同源策略会直接拦截所有请求。解决跨域问题的方案很多,后端加@CrossOrigin注解、全局CORS配置、前端代理转发都行。我推荐前后端开发阶段都采用“前端代理转发”的方式,也就是上一节提到的Vite proxy配置。
为什么要优先选代理转发而不是后端CORS?因为在开发环境下,代理方式更贴近生产环境的行为——生产环境前端和前端构建产物由Nginx托管,Nginx同样会把/api路径转发给后端Java服务,整个链路是“浏览器 → Nginx → SpringBoot”,不存在跨域。而开发阶段用Vite proxy模拟这条链路,等部署时不需要改代码。
如果你一定要用后端CORS,全局配置类写法如下:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)时,allowedOrigins不能写"*",要使用allowedOriginPatterns("*"),否则浏览器会拒绝携带Cookie的跨域请求。
4.2 JWT登录鉴权流程:后端过滤器 + 前端路由守卫
登录鉴权这块,我采用的是JWT + SpringBoot拦截器方案。用户在登录页输入账号密码,后端校验通过后签发一个Token返回给前端。前端将Token存在localStorage中,每次请求在请求头附上Authorization: Bearer <token>。后端通过拦截器统一解析Token,获取当前登录用户信息。
后端核心拦截器如下:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (handler instanceof HandlerMethod) { String uri = request.getRequestURI(); if (uri.contains("/auth/login") || uri.contains("/auth/register")) { return true; } String token = request.getHeader("Authorization"); if (StrUtil.isBlank(token) || !token.startsWith("Bearer ")) { throw new BusinessException("未登录或Token已过期"); } String realToken = token.substring(7); // 解析Token并放入ThreadLocal LoginUser loginUser = JwtUtil.parseToken(realToken); UserContext.set(loginUser); return true; } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }拦截器注册到WebMvcConfigurer中,注意要添加排除路径,比如登录接口、验证码接口和Swagger文档等。否则项目刚启动,登录页面连个验证码都刷不出来。
JWT本身是无状态的,这给业务系统带来的一个麻烦是“无法在服务端主动让某个Token失效”。如果管理员的账号被退出了,他手里的Token在过期之前依然有效。解决方式不复杂,在用户表和Token表中关联一个token_version字段,用户修改密码或退出登录时让版本号加一,JWT中携带这个版本号,解析时候比对新旧版本号,不一致则视为非法。这个方案比引入Redis做黑名单简单,适合中小项目。
4.3 数据字典与统一返回格式
前后端联调最烦的事情是接口返回的数据结构不一致。今天这个接口返回{code: 0},明天那个接口返回{"success": true},前端同学不断适配,效率极低。我强制要求所有接口统一返回Result<T>对象:
@Data public class Result<T> { private Integer code; // 200成功,500业务失败,401未登录 private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }所有Controller返回类型都是Result<T>,包括分页查询的Page对象,data就是分页后的记录列表加total。这也对应前端Axios拦截器里判断res.code === 200的逻辑。统一格式之后前后端联调效率提升非常明显,前端不用再关心每个接口的“个性”结构。
4.4 角色权限的数据隔离:同一个接口,不同角色看到不同数据
这是我在做这个项目时觉得最值得分享的一点。毕业生实习与就业管理系统里,“岗位列表”这个接口,学生看到的是“所有正在招聘的岗位”,企业看到的是“该企业自己发布的岗位”,管理员看到的是“所有岗位(含已下架)”。同一个接口,三种返回结果。
我采用的方法是在后端根据当前登录用户的角色,动态拼接查询条件:
@Override public Page<PositionVO> getPositionPage(PositionQueryDTO query) { LoginUser loginUser = UserContext.get(); LambdaQueryWrapper<Position> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Position::getDeleted, 0); // 企业用户只能看自己发的岗位 if ("ENTERPRISE".equals(loginUser.getRole())) { wrapper.eq(Position::getEnterpriseId, loginUser.getEnterpriseId()); } // 学生只能看状态为“招聘中”的岗位 if ("STUDENT".equals(loginUser.getRole())) { wrapper.eq(Position::getStatus, "OPEN"); } // 管理员不过滤状态 // ... }核心原则是“数据权限永远在后端控制,前端只能通过接口被动接收数据”。很多初学者图省事,把角色判断写在前端,说“只有管理员才显示删除按钮”,可实际上恶意用户仍然可以直接用工具调用删除接口。所以按钮隐藏只是体验细节,接口层面的角色校验和数据隔离才是安全底线。
5. 常见问题与排查技巧实录
这章节把我实际开发过程中遇到的高频问题按“问题现象 → 问题原因 → 解决方案 → 复盘提示”的格式整理出来,方便大家遇到同类问题时直接对照排查。
5.1 MySQL8.0连接报错问题速查表
下面的表格覆盖了我开发中遇到最多的几种MySQL8.0连接错误:
| 问题现象 | 原因分析 | 解决方案 |
|---|---|---|
Access denied for user 'root'@'localhost' | 密码错误或用户不允许当前主机登录 | 检查密码,或在MySQL中执行ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '密码' |
Unable to load authentication plugin 'caching_sha2_password' | 驱动版本低于8.0,不支持新认证插件 | 将mysql-connector-j升级到8.0.x |
The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized | 数据库与时区未对齐 | JDBC URL添加serverTimezone=Asia/Shanghai |
Public Key Retrieval is not allowed | 客户端无法获取服务端公钥 | JDBC URL添加allowPublicKeyRetrieval=true |
Unknown database 'internship_db' | 数据库未创建 | 执行CREATE DATABASE internship_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci |
5.2 MyBatis-Plus分页归来仍是全库数据:分页插件未生效
这是MyBatis-Plus最常见的问题,具体表现为Page对象返回的记录数正确但实际SQL没有limit语句,或者total显示的是当前页记录数而不是总记录数。出问题的原因基本都是没有注册PaginationInnerInterceptor。注意我之前在2.4节写的配置类不只是“配置”这么简单,它是分页功能生效的必要条件。
另外一个分页相关的坑是:如果实体类主键没有加@TableId注解并且数据库主键不是id字段,MyBatis-Plus可能会把某些字段误认为是主键,分页排序可能以这个错误字段排序,导致Page数据混乱。建议每一个实体类都显式标注主键:
@TableId(type = IdType.ASSIGN_ID) private Long id;5.3 Vue3项目请求后端一直报404或跨域错
404问题要先区分是“前端路由404”还是“接口404”。如果是Failed to load resource: the server responded with a status of 404 ()而且Network面板中请求URL是http://localhost:8080/api/student/list,那很可能是Vite的proxy没有匹配到/api前缀,而你的后端接口路径并没有/api前缀。解决方案是调整proxy配置,把请求路径重写后再转发到SpringBoot的/student/list。
如果是“前端页面刷新就404”,这个场景多发生在Vue Router使用history模式时。本地开发还好,部署到Nginx后刷新任意子页面就会404,因为Nginx找不到对应的静态文件。必须在Nginx配置中把所有请求都try_files到index.html:
location / { try_files $uri $uri/ /index.html; }5.4 中文乱码问题:三处编码必须统一
前后端联调出现中文乱码,一般集中在三处:
- 后端数据库连接串没加
characterEncoding=utf8,写入的中文在数据库里变问号 - 数据库表和字段的字符集不是
utf8mb4,如果你建表时直接用MySQL默认字符集(老版本默认latin1),中文必乱 - 前后端交互时,SpringBoot的
server.servlet.encoding.force=true需要显式开启
我的工程化做法是在application.yml里加上:
server: servlet: encoding: charset: UTF-8 enabled: true force: true建表语句也统一指定字符集,避免继承默认值:
CREATE TABLE student ( id BIGINT PRIMARY KEY COMMENT '主键', student_name VARCHAR(50) NOT NULL COMMENT '姓名', major_name VARCHAR(100) COMMENT '专业名称', deleted TINYINT DEFAULT 0 COMMENT '逻辑删除', create_time DATETIME COMMENT '创建时间' ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '学生信息表';5.5 大数据量导出Excel时内存溢出
管理系统里经常要导出学生列表、实习记录,如果直接用EasyExcel同步查询全量数据然后写入文件,几万条数据可能就把堆内存打爆。我采用的分批查询导出方案很有效:每次从数据库取5000条,写一批,内存里最多只保留5000条数据。
public void exportStudents(HttpServletResponse response) { int pageSize = 5000; int pageNum = 1; // 使用EasyExcel流式写入 ExcelWriter writer = EasyExcel.write(response.getOutputStream(), StudentExcelVO.class).build(); WriteSheet sheet = EasyExcel.writerSheet("学生数据").build(); while (true) { Page<Student> page = studentService.page(new Page<>(pageNum, pageSize)); List<StudentExcelVO> voList = convertToVO(page.getRecords()); writer.write(voList, sheet); if (page.getRecords().size() < pageSize) { break; } pageNum++; } writer.finish(); }5.6 部署上线后的首个高危操作:修改默认密码和关闭Swagger
项目能跑了,第一件事不是炫功能,而是把系统的安全底线重新收拾一遍。SpringBoot项目如果引入了springfox或springdoc,默认会把所有Controller接口通过Swagger暴露出去。这在一台公网服务器上是极其危险的事,别人扫描到/swagger-ui.html就能看到你全部的接口定义,直接调用修改密码接口。部署前必须做到三件事:
springdoc.api-docs.enabled=false或springfox.documentation.enabled=false关闭文档- 删除或注释掉初始化代码中的默认管理员密码初始化逻辑,改成首次启动必须修改密码
- 所有接口统一加
/api前缀,配合Nginx只开放必须的路径
说句实际经验,很多网络安全事故不是被什么高深攻击打穿的,而是错在“默认配置直接上线”。
6. 从代码仓库到完整交付:项目文档与后续维护建议
项目源码里包含了完整的设计文档,这是这个系统很加分的地方。实际上很多人的毕设或者外包项目,代码写得再花哨,如果没有一份能讲清楚“为什么这样设计”的文档,评审或者验收的时候也会被各种追问。
6.1 哪些文档应该在项目里被包含
毕业实习与就业管理系统的文档结构,我建议按下面几种类型组织:
- 需求规格说明:角色分析、功能模块划分、核心业务流程说明,特别是“实习申请审批流程”和“就业信息填报流程”这两个核心流程,建议附上文字版流程描述
- 数据库设计说明:ER图、表结构说明、核心字段的字典含义
- 接口文档:建议用Apifox联调并导出,标注每个接口的入参、出参、鉴权要求
- 部署文档:从环境要求、数据库初始化、后端打包运行、前端构建到Nginx配置,全程截图说明
部署文档里有一个经常被忽略的地方:MySQL8.0的sql_mode配置。某些服务器默认的sql_mode里包含ONLY_FULL_GROUP_BY,如果你的SQL里SELECT字段没有被GROUP BY包含,直接报错。这个问题在本地开发环境可能没暴露,一上服务器就崩,排查起来很头疼。我的建议是在MySQL配置的[mysqld]段里显式指定:
[mysqld] sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION也就是去掉ONLY_FULL_GROUP_BY,兼容日常开发中较为宽松的分组查询写法。
6.2 后续维护的进阶思路
如果这个项目后续要扩展成学校级别的正式平台,我个人建议的升级路径是:
- 引入Redis缓存岗位热点数据和登录会话,减少数据库压力
- 将文件上传从本机磁盘改为对象存储(比如MinIO或云OSS),实习证明材料上传会更可靠
- 增加消息通知模块,用WebSocket推送实习审核结果和招聘动态
- 在统计报表中引入定时任务,每天凌晨自动生成前一天的就业数据快照,避免大范围定时扫描业务表
但以上每一项都要在项目已经稳定运行的前提下按需推进。管理系统最怕的不是功能少,而是功能堆得过多、代码腐败,到最后谁都不敢改哪一行。
回到这个项目本身,SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0这四件套,解决毕业生实习与就业管理系统的核心业务,属于各司其职、恰到好处。后端接口开发效率有MyBatis-Plus兜底,前端组件化开发有Vue3支撑,数据统计有MySQL8.0窗口函数加持,整个流程从零到交付,体感是“顺”。如果你正准备动手写类似的Java Web全栈管理系统,直接照着这套技术栈和编码风格往下走,不敢说代码能惊艳到谁,但一定能让开发过程少折腾、少返工。最后再分享一个小技巧:在每个模块开发完成后,花10分钟用Apifox把接口测试用例留存下来,后面前端联调和系统验收时,你会感激当时存下来的这些用例。