简介:这是一套基于SpringBoot与Vue.js前后端分离架构的校园招聘系统完整源码包,面向高校毕业生求职场景,适合Java Web学习者、毕业设计开发者及需要搭建在线招聘平台的技术团队。系统覆盖职位发布、简历投递、面试通知、企业信息管理与个人中心等核心模块,并可根据实际需求扩展数据分析和智能推荐功能。资源共904个文件,以java后端代码、vue前端组件、js脚本、html页面和css样式为主,附带svg图标、gif演示图、sql或xml配置及部署bat脚本,压缩包整体约32.72MB,目录结构清晰,便于按模块查阅与二次开发。目前已有51人学习下载。对于想快速跑通一套完整前后端分离项目的读者,这份源码能提供可直接运行的工程骨架与开发思路,配套说明文档也有助于理解系统架构、技术选型和部署细节,可作为课设项目或企业级招聘平台的起步参考。
1. 基于 SpringBoot 和 Vue 的校园招聘系统:双端三角色项目到底值不值得拆
如果你的毕业设计题目已经落在“招聘系统”或“就业平台”上,那这一套基于 SpringBoot 和 Vue 的校园招聘系统就是成体系的那种“能闭环”的项目。它不是单机 CMS 那种只做增删改查的后台,而是把学生投简历、企业发职位、HR 发面试通知、管理员审核企业资质这几条真实业务线串到了一起。这个难度的项目恰好卡在“会写 CRUD”和“能设计一套带权限状态机的业务”之间,也是面试官最爱问的“你是怎么设计投递状态流转”的最佳素材。想拿它当毕设底稿的人、想练手多角色权限的后端初学者、甚至想把它改造成实习平台的人,都值得把代码拆开看一遍。下面我直接从工程结构和最容易翻车的细节说起。
2. 拆工程之前先把需求画成数据模型:三端角色不是一个字段能解决的
2.1 登录体系拆解:学生、企业、管理员为什么不能共用同一张 User 表
我拿到这类 SpringBoot+Vue 项目的第一步,会先打开application.yml和resources/mapper下的表结构,因为后面所有页面都是建立在表关系上的。很多新手图省事,在一张 user 表里加role字段,然后通过role=1/2/3区分学生、企业和管理员。这种做法在管理后台项目里没问题,但在招聘系统里会迅速失控:学生有学号、专业、毕业年份、简历附件;企业有公司名、信用代码、营业执照、企业简介。这些字段完全不重叠,硬塞进一张表,要么大量字段冗余为空,要么就得为每个实体额外开扩展表,最后还是回到分表老路。
这套项目里更合理的做法是:sys_user只保存登录凭证和公共字段,比如user_id, username, password, role, status, create_time,然后通过student_info和company_info两张扩展表去存各自的业务字段。登录时 Spring Security 只认证sys_user,认证成功后把这些基本信息塞进 JWT Token,后续接口再根据 Token 里的role决定查询哪张扩展表。这么做的好处是登录逻辑不需要跟着业务字段变动,以后扩展“学校管理员”或“校友企业”角色,只要加一张扩展表,不用动认证链路。
表关系上也需要注意一个细节:企业用户是“账号绑定公司”还是“公司直接当账号用”。常见的校园招聘系统里,一个公司可以注册多个招聘账号,所以company_info主键是company_id,而sys_user里用company_id做外键,不建议把公司 ID 直接当登录用户名。学生则是一对一关系,直接用user_id关联即可。如果资源包里的表设计没有拆这两块,你在复用前最好自己补上,因为后续所有企业端查询都会围绕company_id做数据隔离。
2.2 投递状态机:职位、简历、投递记录三张核心表的状态字段怎么定
招聘系统的核心不只是“发职位、投简历”,而是状态机设计。职业里的status字段通常用0 草稿、1 招聘中、2 已下架、3 已关闭四个值表达,这个很直觉。真正容易设计错的是“投递记录”这张表。很多人会把状态写成纯字符串,比如'已投递'、'被查看'、'面试中'、'已录用',然后在 Java 代码里反复字符串比较。这种写法维护成本极高,而且数据库排序和统计都别扭。
建议参照这套项目的常见设计:投递表job_application里用status数字表示状态,配合一个update_time作为状态变更时间。具体可以是0 待查看、1 已查看、2 面试邀请、3 已录用、4 不通过、5 已取消。状态流转通过后端 Service 层统一控制,比如学生取消投递必须满足status=0 或 1,企业发送面试邀请必须满足status=1。这种设计的好处是,前端菜单里“我的投递”“面试邀请”“录取结果”三个页面,本质上只是对同一张表按不同status做条件查询。
职位表还需要一个application_count字段或者独立统计表,用来记录职位收到的简历数。这是个很容易被忽略的冗余字段,但校园招聘场景里,企业端“职位列表”页需要展示每个职位的投递量。如果实时COUNT(*)去查投递表,数据量上去后分页性能会快速恶化。先冗余一个计数,再把job_application表里加uniq_idx(user_id, job_id)唯一索引,既能抗住列表页压力,也能在数据库层杜绝重复投递。后面所有“这个学生投过没有”的判断,都不用再单独发一条 SQL。
3. SpringBoot 后端落地:职位发布、简历投递与面试通知的实现路径
3.1 Controller 只做参数接收,Service 用状态机逻辑写业务闭环
后端这块,核心不是把接口调通,而是别让 Service 层变成“裸 SQL 拼接中心”。我见过太多基于 SpringBoot 的项目,Controller 里直接注入JdbcTemplate或者BaseMapper,一个方法里塞几十行 SQL,面试时被问“如果投递状态要从 1 改成 2,哪些接口要受影响”就直接愣住。这套校园招聘系统比较理想的结构是三段式:Controller接收参数并校验,Service写业务流程和事务,Mapper只做单表查询。
以职位发布为例,Controller 层只负责把前端传过来的 DTO 转成实体,然后调用 service:
@RestController @RequestMapping("/api/job") public class JobController { private final JobService jobService; public JobController(JobService jobService) { this.jobService = jobService; } @PostMapping("/publish") public Result<Long> publish(@RequestBody @Valid JobPublishDTO dto) { // 从 SecurityContext 拿到当前登录用户,Spring Security 在认证后会存入 Authentication 对象 LoginUser loginUser = (LoginUser) SecurityUtils.getLoginUser(); if (!loginUser.getRole().equals("COMPANY")) { return Result.error(403, "只有企业账号可以发布职位"); } Long jobId = jobService.publish(loginUser.getCompanyId(), dto); return Result.success(jobId); } }这里有两个细节值得说:第一,接口里的company_id一定不能相信前端传参,必须从后端登录态里取,否则你只要随便改请求体就能冒充别的公司发职位。第二,@Valid配合 DTO 里@NotBlank、@Positive这类校验注解,能在进入 Service 前挡掉一批非法参数。常见项目里很多人把参数校验写在 Service 里,不是不行,但会让业务代码越堆越脏。
再看 Service 层的实现重点。职位发布这个操作要写入职位主表,同时可能还要初始化一份招聘统计记录,所以方法上要加事务:
@Service public class JobServiceImpl extends ServiceImpl<JobMapper, Job> implements JobService { @Autowired private RecruitmentStatsMapper statsMapper; @Override @Transactional(rollbackFor = Exception.class) public Long publish(Long companyId, JobPublishDTO dto) { Job job = new Job(); job.setCompanyId(companyId); job.setTitle(dto.getTitle()); job.setCategory(dto.getCategory()); job.setSalaryMin(dto.getSalaryMin()); job.setSalaryMax(dto.getSalaryMax()); job.setRequirement(dto.getRequirement()); job.setStatus(1); // 招聘中 job.setCreateTime(new Date()); this.save(job); RecruitStats stats = new RecruitStats(); stats.setJobId(job.getId()); stats.setViewCount(0); stats.setApplicationCount(0); statsMapper.insert(stats); return job.getId(); } }@Transactional(rollbackFor = Exception.class)这里的rollbackFor不能省。Spring 默认只在遇到RuntimeException时回滚,如果你业务里抛的是自定义的ServiceException,不写rollbackFor会导致前半段插入成功、后半段失败但数据没回滚。回答 SpringBoot 面试题时“事务默认回滚规则是什么”经常被追问,这行参数就是最好的答案。还有,statsMapper.insert(stats)这种冗余表初始化建议放在同一个事务里,职位一旦创建,统计行就必须跟着存在,避免后面查询不用LEFT JOIN就少数据。
3.2 简历投递的幂等与状态变更:索引挡重复,事务保一致
简历投递是另一个高频雷区。学生端点击“投递简历”时,前端通常会做按钮置灰,但网络超时后用户可能连续点两次,后端必须自己做幂等。常见做法是先查一次job_application表,判断该学生对当前职位是否已有记录;如果存在就直接返回“已投递”,而不是再插一条。
@Transactional(rollbackFor = Exception.class) public boolean apply(Long studentUserId, Long jobId) { Job job = jobService.getById(jobId); if (job == null || !job.getStatus().equals(1)) { throw new ServiceException("职位不存在或不在招聘中"); } Long existed = applicationMapper.selectCount( new LambdaQueryWrapper<JobApplication>() .eq(JobApplication::getStudentUserId, studentUserId) .eq(JobApplication::getJobId, jobId) ); if (existed != null && existed > 0) { throw new ServiceException("请勿重复投递"); } JobApplication application = new JobApplication(); application.setStudentUserId(studentUserId); application.setJobId(jobId); application.setStatus(0); // 待查看 applicationMapper.insert(application); // 更新职位的投递统计 jobService.lambdaUpdate() .eq(Job::getId, jobId) .setSql("application_count = application_count + 1") .update(); return true; }这里两个点值得复用。第一个是“先查后插”并不是绝对安全的,两个并发请求可能同时通过查询。真正兜底的方案是在job_application表建uniq_idx_user_job (student_user_id, job_id)唯一索引,一旦出现并发插入,数据库会抛DuplicateKeyException,再在 Service 外层用try-catch把这条异常翻译成“已投递过”。第二个点是用setSql("application_count = application_count + 1")做数据库自增,不要先查数量、加一再update,那种写法在并发行下会丢失更新。
面试通知和投递状态变更的逻辑也类似。企业查看一份简历后把投递状态从0改成1,然后发面试通知时再改成2。这里我建议所有状态变更都走同一个updateApplicationStatus方法,方法内部判断“当前状态是否允许变成目标状态”,而不是让每个 Controller 各自去 update。这样以后加一个“HR 撤回录用”的需求,你只需要在一个方法里补状态映射。
public void updateStatus(Long applicationId, Integer fromStatus, Integer toStatus) { int updated = applicationMapper.update(null, new LambdaUpdateWrapper<JobApplication>() .eq(JobApplication::getId, applicationId) .eq(JobApplication::getStatus, fromStatus) .set(JobApplication::getStatus, toStatus) ); if (updated == 0) { throw new ServiceException("状态已变化,请刷新后重试"); } }这条 SQL 里最关键的是eq(JobApplication::getStatus, fromStatus),它把状态变更做成了乐观锁。两个用户同时操作同一条记录时,只有一个能成功,另一个更新行数为 0,直接提示“状态已变化”。我习惯在任何状态机场景里都用这种方式,而不是先select再update,因为它把并发安全下推到了数据库层,简单可靠。面试通知这类操作,除了改投递状态,通常还要往“系统消息表”里插一条通知记录,这两步同样要放在同一事务里。
4. Vue 前端对接:从请求封装到动态菜单的权限控制
4.1 Axios 拦截器统一处理 Token 和 401,别让每个页面各自判断登录态
前端这端,最容易看出项目作者水平的地方不在页面好不好看,而在请求封装和路由守卫。很多套壳项目里,每个 Vue 页面自己写一遍axios.get(url, { headers: { Authorization: localStorage.getItem('token') } }),这种代码一旦遇到“Token 过期”就会在各处重复处理。这个 SpringBoot+Vue 项目里比较清爽的做法是:在src/utils/request.js里封装一个统一的 Axios 实例。
import axios from 'axios' import router from '@/router' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器:每次请求自动携带 Token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = 'Bearer ' + token } return config }, error => Promise.reject(error)) // 响应拦截器:统一拆包、跳登录 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.removeItem('token') localStorage.removeItem('userInfo') router.push({ path: '/login' }) } ElMessage.error(error.message || '网络异常') return Promise.reject(error) }) export default request这里把baseURL设成/api而不是写死http://localhost:8080,是因为后端网关或 Nginx 通常会做路径转发,写死地址在部署到服务器后还得改来改去。常见的配置是本地开发走 Vite 的proxy,把/api代理到localhost:8080;生产环境由 Nginx 把/api反向代理到后端服务。前端代码里只要出现写死 IP 和端口,基本可以断定项目没有考虑过部署。401 处理也是一样,集中在拦截器里做一次“清 Token 踢回登录页”,后面你加任何页面都不需要重复写这段逻辑。
响应拦截器里我习惯按code判断业务成功,而不是看 HTTP 状态码。比如简历重复投递时后端返回 HTTP 200 但业务code=500,这种场景在招聘系统里很多,因为提交失败本来就是要给用户看提示的,不需要触发全局 401。拦截器把res.data直接 return 出去之后,页面代码里拿到的就是后端真正的业务数据,不会再出现.data.data.data这种三层结构。
4.2 路由守卫按角色生成菜单,刷新页面不能丢用户状态
前端另一大坑是页面刷新后用户信息丢失。很多人把userInfo只放在 Vuex/Pinia 内存里,一刷新就变成undefined,然后路由守卫放行逻辑全乱套。正确做法是登录成功时把用户基础信息写入 localStorage,刷新后再从 localStorage 恢复。配合 Pinia 或者 Vuex 的初始化逻辑,在main.js里创建 store 之前先重新设置 state。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token) { if (to.path === '/login') { next() } else { next('/login') } return } const userInfo = JSON.parse(localStorage.getItem('userInfo') || 'null') if (!userInfo) { store.dispatch('fetchUserInfo').then(() => { next() }) return } // 根据角色控制首页重定向 if (to.path === '/') { if (userInfo.role === 'STUDENT') next('/student/jobs') else if (userInfo.role === 'COMPANY') next('/company/jobs') else next('/admin/dashboard') } else { next() } })这份守卫代码只做两件事:登录态检查和角色首页定向。更深的菜单权限控制则用动态路由:登录后,根据角色把对应的路由addRoute注册进 Router,然后把可访问的菜单列表存到 Pinia。静态路由写法简单,但所有角色的菜单都打包进前端路由,学生手动输入/company/jobs也能打开企业页面,只能靠后端接口返回 403 拦截。动态路由的好处是菜单天然隔离,但刷新后路由列表会重置,所以刷新时必须先从 localStorage 读角色,再重新注册这些路由。
更稳妥的方案是“静态路由只放公共页(登录、注册、首页),业务路由全部动态挂载”。在学生端动态挂载/student子路由,企业端挂载/company子路由,管理员端是/admin子路由。初始化时根据userInfo.role先过滤出该角色可访问的路由表,再router.addRoute。这套写法在“基于SpringBoot Vue的商品管理”或“图书借阅系统”这类项目里都通用,本质上是从“页面可见性”升级成“路由注册级隔离”。如果你最后要做毕业答辩,这段动态路由可以作为前端亮点单独讲。
4.3 打包后放进 SpringBoot:前端产物与后端静态资源一体化部署
另一个常被问的部署问题是“vue 打包放进 springboot 中怎么实现”。学校机房服务器通常没有 Nginx,只有一台 Java 应用服务器。最简单方案是把前端npm run build生成的dist目录里的内容,复制到后端src/main/resources/static下,然后 SpringBoot 启动后直接访问http://ip:8080/index.html。
这里有个必踩坑:前端路由是 history 模式时,直接访问/student/jobs会返回 404,因为后端没有这个路径的 Controller。解决方式是让 SpringBoot 把所有非/api开头的请求都转发到index.html。可以加一个简单的WebMvcConfigurer:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{spring:[^\\.]*}") .setViewName("forward:/index.html"); } }注意这个配置只匹配不带.的路径,所以js/css静态资源不会被拦截。如果你连这个配置都不想写,还有更省事的招:直接把前端改为 hash 模式路由,vue-router里用createWebHashHistory(),打包后丢进 static 目录也能跑,只是地址栏会多个#。两种方案我都在项目里验证过,如果是毕设演示,hash 模式最省时间,但面试官问到 history 模式刷新 404,你答得出上面这段 forwarding 配置就是加分项。
5. 部署与避坑:SpringBoot 版本、跨域、文件上传的典型翻车记录
5.1 本地跑通前先核对四样东西:JDK、Maven、npm、MySQL
拿到压缩包后,不要急着mvn spring-boot:run。我踩过最多的坑就是“版本太高”导致的连锁报错。SpringBoot 3.x 要求 JDK 17,但很多校园项目的代码还是基于 JDK 8 写的,如果你电脑默认 JDK 17,启动会报Error creating bean with name 'requestMappingHandlerMapping'或者ClassNotFoundException: javax.servlet.*。先打开pom.xml确认 SpringBoot parent 版本,再看本地java -version,不一致就先装对应 JDK。Maven 建议用 3.6+ 版本,太老的 Maven 在拉取新依赖时会频繁报Non-resolvable parent POM。
数据库配置也得先改。application.yml里的spring.datasource.url大概率写的是jdbc:mysql://localhost:3306/recruitment?useUnicode=true&characterEncoding=utf8,你要确认两点:本地 MySQL 是不是 8.x 版本,8.x 需要使用com.mysql.cj.jdbc.Driver,并且 URL 里加serverTimezone=Asia/Shanghai,否则连接时报时区错误。然后去resources/db目录找 SQL 脚本,按文件名顺序导入,导入前先建库。
前端部分要看package.json里锁定的 Node 和 Vue 版本。老项目常见 Vue 2 + Element UI,新项目是 Vue 3 + Vite + Element Plus。如果你用的是新版 Node,直接npm install可能碰到依赖版本冲突。我的习惯是先删除node_modules和package-lock.json,再执行npm install --registry=https://registry.npmmirror.com,装完再启动。
5.2 常见问题清单:三次翻车后我总结的排错顺序
下面这几条是这套校园招聘系统里反复出现的高频问题,按现象、原因、解决方式记录一下,排查时可以直接对照。
| 现象 | 原因 | 解决 |
|---|---|---|
前端请求/api/xxx报 404,但后端自测接口正常 | 前端开发服务器没有配置代理 | 在vite.config.js里加server.proxy,把/api转发到http://localhost:8080 |
| 带 Token 请求仍然返回 401 | Spring Security 放行规则没写明白 | 在SecurityConfig里放行/api/login、/api/register、/doc.html,其他路径都要认证 |
上传简历图片/PDF 报MaxUploadSizeExceededException | spring.servlet.multipart.max-file-size默认 1MB 不够 | yml 里配置max-file-size: 10MB和max-request-size: 20MB,同时查 Nginxclient_max_body_size |
| 学生端列表页能看到其他公司的职位 | 查询职位时没有按company_id过滤 | 企业端列表 SQL 必须加企业条件,不能默认查全部 |
| 前后端联调时跨域报错 | 后端没有允许前端来源 | 本地开发优先用 Vite 代理,生产部署让 Nginx 同源转发,尽量不用后端@CrossOrigin |
第一条再说细一点:Vite 代理配置长这样,放在vite.config.js:
server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, } } }配置完必须重启 Vite 开发服务器,proxy不会热更新。另外,后端 SpringSecurity 如果使用了 CORS 配置,Vite 代理也照样需要后端放行 OPTIONS 预检请求。常见做法是在SecurityConfig里直接.cors().and(),再单独定义一个CorsConfigurationSourceBean。我在本地联调时基本只用代理,因为@CrossOrigin一旦没控制好,写进生产环境会把接口暴露给任意域,这属于安全设计的退化。
文件上传的坑也要单独提一下。简历 PDF 上传成功后,后端的保存路径如果配置的是相对路径(比如./upload/),项目通过java -jar启动时,上传目录会跑到当前工作目录下,而不是 jar 包旁边。你辛辛苦苦传了文件,重启就找不到了。更好的做法是在application.yml里配置一个绝对路径前缀,upload.path: E:/recruitment/file/,然后通过配置类读进来。如果项目要部署到宝塔 Docker,还得把宿主机目录挂载进容器,否则容器重启文件就丢。文件上传接口里也务必校验文件扩展名,至少做一层白名单过滤,我见过不少系统因为允许任意文件上传被打进 shell。
如果你打算把系统放到宝塔环境里跑,我建议用 Docker Compose 而不是直接java -jar裸跑。SpringBoot 应用容器化后,把 MySQL 也一起用 Compose 编排,数据卷挂载在宿主机上。这样换服务器时只要导出 SQL 和上传目录,服务环境基本一键恢复。但新手要注意 Compose 里mysql容器首次启动会初始化数据库,你的 SQL 脚本要用docker-entrypoint-initdb.d机制放进去,不是进去手动执行。中间任何步骤失败,先docker-compose logs -f看日志,而不是反复重建容器。
6. 验收环节:按一条完整招聘链路跑通,才算真的装好了
整套系统跑起来后,不应该只验证“首页能看到职位列表”就算完事。我每次拿到这类代码,都会强制自己走一遍完整的招聘链路,这个过程能暴露 80% 的逻辑问题。你可以按下面这套流程验收。
第一,准备三个测试账号:学生、企业和管理员。如果没有现成的,就在注册页面分别注册,然后去数据库把新注册账号的role字段改对。注意密码在代码里大概率是 BCrypt 加密的,千万不要直接改数据库里的password字段,否则你注册的账号密码登录不上。管理员账号一般在 SQL 脚本里有默认数据,先查sys_user表确认。
第二,以企业账号登录,发布一个测试职位,薪资区间、职位类别、任职要求都填完整。进入职位列表,确认新职位显示为“招聘中”,且投递数为 0。这一步是验证企业端职位 CRUD 和状态默认值。
第三,切换学生账号,浏览职位列表,找到刚发的测试职位,点击投递。投递成功后,进“我的投递”页面确认记录状态为“待查看”。此时再点一次投递按钮,页面应该提示“不可重复投递”,而不是跳转报错。如果发现按钮还能继续投,检查后端唯一索引是否建上,以及前端有没有在投递成功后做状态刷新。
第四,回到企业账号,进入职位收到的简历列表,点开该学生的简历详情。此时学生端的投递状态应该自动变成“已查看”。然后点击“发送面试通知”,填好面试时间、地点,提交后切回学生账号,系统消息或投递状态里应该能看到“面试邀请”。目的是验证状态机和消息通知的联动。
第五,以管理员账号登录后台,确认能看到普通用户和企业的列表,把测试企业设置为“审核通过”或“禁用”。禁用后,该企业发布的全部职位应该从前端展示中移除。如果职位还在,说明查询语句里没有把企业状态关联进来,这是数据隔离没做干净。
验收完这套链路后,再顺手验证两个边界场景:关闭职位的操作是否会被“投递中”的简历卡住;面试时间传过去的时间格式是否出现了 8 小时时差问题。前者可以在 Service 层做约束,后者多半在实体类@JsonFormat和数据库datetime时区不一致导致,统一在后端 yml 里配spring.jackson.time-zone=GMT+8就能解决。
另外有一点我一直建议:把项目里所有使用LocalDateTime的字段列出来,看一眼前端到底传的是字符串还是时间戳。Vue 项目经常会用dayjs格式化后提交,后端如果用的是Date类型接收,解析失败会直接报 400。最省事的做法是把这类字段统一用字符串接收,在 Service 里转成LocalDateTime,避免前端和后端对格式的理解不一致。
最后,如果这项目带resources/mapper下的 XML 文件,你可以在最后一个验收步骤里,把job_application表的数据导出来,核对每个状态的记录数和前端页面展示是否一致。这一步能锻炼“拿查询结果反推代码逻辑”的能力,也是我在这个领域积累最深的一条经验:从那以后我每接手一个未知工程,第一件事永远是先打通一条核心业务闭环,再看界面细节,而不是在登页面细节上反复调 CSS。希望这一套拆解和验收流程能帮到你,值得把源码下载后按这份笔记跑一遍。
本文还有配套的精品资源,点击获取