
接手这种“毕业生实习与就业管理系统”的项目第一反应是技术栈怎么选。很多同学私信我说学校布置的课题或者公司接到类似外包上来就不知道该用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消灭重复CRUDVue3组件化让前端代码可以复用这是单兵作战或者小团队开发最看重的事学习曲线平缓每一个环节都有大量中文资料和现成案例遇到问题基本能搜到答案对毕业生做毕设非常友好扩展性适中后面前端要加图表大屏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里不加serverTimezoneAsia/Shanghai插入日期数据时大概率会报“The server time zone value”相关错误。我一般会在JDBC连接串里写成这样jdbc:mysql://localhost:3306/internship_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrueallowPublicKeyRetrievaltrue这个参数在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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.17/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesLombok在当前项目里强烈建议加上实体类几十个字段手动写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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 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代码生成与通用CRUDMyBatis-Plus官方有代码生成器虽然说是“代码生成器”但其实只是一个main方法用起来非常简单。我个人喜欢把代码生成器单独写成一个类放在test目录下需要生成的时候跑一次public class CodeGenerator { public static void main(String[] args) { FastAutoGenerator.create(jdbc:mysql://localhost:3306/internship_db?serverTimezoneAsia/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继承了IServiceServiceImpl继承了ServiceImpl这样一来save、updateById、getById、list、page这些基础方法全部开箱即用。实际开发中通用CRUD只是第一步。真正的业务逻辑还得自己写比如“学生投递岗位”这个操作需要同时判断岗位是否还在招聘、学生是否存在重复投递然后把申请记录写入实习申请表Service public class InternshipApplyServiceImpl extends ServiceImplInternshipApplyMapper, 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. 校验该学生是否已经投递过该岗位 LambdaQueryWrapperInternshipApply 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 PageInternshipApplyVO getApplyPage(ApplyQueryDTO query) { PageInternshipApply page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperInternshipApply 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); PageInternshipApply 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) ListMajorStatsVO 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 vitelatest 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/, ) } } }第二层是请求拦截器统一在请求头加Tokenservice.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 classapply-page SearchForm :modelqueryParams searchhandleQuery resethandleReset / el-table :datatableData v-loadingloading border stripe el-table-column propstudentName label学生姓名 / el-table-column proppositionName label岗位名称 / el-table-column propenterpriseName label企业名称 / el-table-column propstatus label状态 template #default{ row } el-tag :typestatusTagType(row.status){{ statusText(row.status) }}/el-tag /template /el-table-column el-table-column label操作 width180 template #default{ row } el-button v-ifcanReview(row) typeprimary link clickhandleReview(row)审批/el-button el-button typedanger link clickhandleDelete(row)删除/el-button /template /el-table-column /el-table el-pagination v-model:current-pagequeryParams.pageNum v-model:page-sizequeryParams.pageSize :totaltotal :page-sizes[10, 20, 50, 100] layouttotal, sizes, prev, pager, next, jumper size-changefetchList current-changefetchList / /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() }) /script3.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}前端同学不断适配效率极低。我强制要求所有接口统一返回ResultT对象Data public class ResultT { private Integer code; // 200成功500业务失败401未登录 private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }所有Controller返回类型都是ResultT包括分页查询的Page对象data就是分页后的记录列表加total。这也对应前端Axios拦截器里判断res.code 200的逻辑。统一格式之后前后端联调效率提升非常明显前端不用再关心每个接口的“个性”结构。4.4 角色权限的数据隔离同一个接口不同角色看到不同数据这是我在做这个项目时觉得最值得分享的一点。毕业生实习与就业管理系统里“岗位列表”这个接口学生看到的是“所有正在招聘的岗位”企业看到的是“该企业自己发布的岗位”管理员看到的是“所有岗位含已下架”。同一个接口三种返回结果。我采用的方法是在后端根据当前登录用户的角色动态拼接查询条件Override public PagePositionVO getPositionPage(PositionQueryDTO query) { LoginUser loginUser UserContext.get(); LambdaQueryWrapperPosition 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 rootlocalhost密码错误或用户不允许当前主机登录检查密码或在MySQL中执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 密码Unable to load authentication plugin caching_sha2_password驱动版本低于8.0不支持新认证插件将mysql-connector-j升级到8.0.xThe server time zone value Öйú±ê׼ʱ¼ä is unrecognized数据库与时区未对齐JDBC URL添加serverTimezoneAsia/ShanghaiPublic Key Retrieval is not allowed客户端无法获取服务端公钥JDBC URL添加allowPublicKeyRetrievaltrueUnknown database internship_db数据库未创建执行CREATE DATABASE internship_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci5.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.htmllocation / { try_files $uri $uri/ /index.html; }5.4 中文乱码问题三处编码必须统一前后端联调出现中文乱码一般集中在三处后端数据库连接串没加characterEncodingutf8写入的中文在数据库里变问号数据库表和字段的字符集不是utf8mb4如果你建表时直接用MySQL默认字符集老版本默认latin1中文必乱前后端交互时SpringBoot的server.servlet.encoding.forcetrue需要显式开启我的工程化做法是在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) { PageStudent page studentService.page(new Page(pageNum, pageSize)); ListStudentExcelVO 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.enabledfalse或springfox.documentation.enabledfalse关闭文档删除或注释掉初始化代码中的默认管理员密码初始化逻辑改成首次启动必须修改密码所有接口统一加/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_modeSTRICT_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把接口测试用例留存下来后面前端联调和系统验收时你会感激当时存下来的这些用例。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。