☰
SpringBoot+Vue校园招聘系统实战:从数据库设计到前后端部署
2026/10/7 13:26:17 网站建设 项目流程

简介:一份基于SpringBoot和Vue.js的校园招聘系统完整项目包,面向高校毕业生与企业两端用户,覆盖职位发布、简历投递、面试通知、企业信息管理与个人中心等核心功能,适合用作毕业设计、课程项目或前后端分离开发的学习范例。包内共904个文件,压缩后约32.72MB,以77个Vue组件、161个JavaScript脚本、137个Java源文件、58个HTML页面和52个CSS样式为主,同时包含SVG图标、图片素材、启动部署脚本及doc/docx/xls说明文档,可直接搭建运行环境并查看完整实现。目前已有51人学习浏览。压缩包内还包含一键构建与启动脚本,文档部分补充了系统架构、功能说明和常见配置,便于二次开发与排错。整体代码结构清晰,前端与后端分层明确,是理解招聘业务流程和SpringBoot+Vue集成方法的直观案例。

1. 校园招聘系统到底在解决什么:职位发布、简历投递、面试通知组成的对接闭环

一到招聘季,高校就业办的邮箱就会被两种邮件塞满:学生问企业为什么没有回音,企业问学生简历为什么格式打不开。基于 SpringBoot 和 Vuejs 的校园招聘系统,把职位发布、简历投递、面试通知、企业信息管理、个人中心装进同一个 Web 应用,解决的就是这个“双方高效对接”的问题。它不只是增删改查,真正的复杂度在状态流转:一份简历投出去后,要经历被查看、邀面试、不合适、入职,每一步都得让学生端和企业端看到一致的结果。对 Java 全栈入行者和拿它做毕业设计的人来说,这个方向也是把 SpringBoot 自动装配、Maven modules、Vue 路由和组件化整合起来的现成样本。接下来我从数据模型拆起,把能照着改的表结构、接口和前端写法讲清楚。

2. 先立数据模型再写 SpringBoot 模块:六张核心表与目录结构的选型依据

2.1 从学生、企业、职位、投递、面试拆出六张核心表

业务不复杂,但表结构容易拆碎。常见的错误是学生一张表、企业一张表、管理员一张表,每个表都带 username 和 password,后面登录逻辑要写三套。我一般把账号体系收成一张user表,用role区分学生、企业和管理员;学生和企业再各拿一张扩展表,存业务资料。这样登录、改密码、封禁账号都只有一条路,代码里也只要一个UserMapper。

职位、投递、面试是三张核心业务表。投递表必须建唯一键,否则同一学生对同一职位可以重复投递;面试表要和投递表关联,而不是直接对职位,因为一次投递只会有一个面试流程,这样状态更清晰。下面这套 SQL 是在这类招聘系统里反复验证过的最小结构:

CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL COMMENT 'BCrypt 密文', `role` tinyint NOT NULL DEFAULT 3 COMMENT '1-管理员 2-企业 3-学生', `status` tinyint NOT NULL DEFAULT 1 COMMENT '0-禁用 1-正常', `phone` varchar(20), `create_time` datetime, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ); CREATE TABLE `student_profile` ( `id` bigint PRIMARY KEY AUTO_INCREMENT, `user_id` bigint NOT NULL, `real_name` varchar(30), `school` varchar(60), `major` varchar(60), `education` varchar(10), `graduation_year` varchar(10), `resume_url` varchar(255), `create_time` datetime, KEY `idx_school_major` (`school`, `major`) ); CREATE TABLE `company` ( `id` bigint PRIMARY KEY AUTO_INCREMENT, `user_id` bigint NOT NULL, `company_name` varchar(100) NOT NULL, `industry` varchar(30), `scale` varchar(20), `intro` text, `address` varchar(200), `verify_status` tinyint DEFAULT 0 COMMENT '0-待核验 1-已认证', `create_time` datetime ); CREATE TABLE `job` ( `id` bigint PRIMARY KEY AUTO_INCREMENT, `company_id` bigint NOT NULL, `title` varchar(100) NOT NULL, `category` varchar(30), `city` varchar(30), `salary_min` int, `salary_max` int, `requirement` text, `description` text, `status` tinyint DEFAULT 0 COMMENT '0-草稿 1-待审核 2-招聘中 3-已下线 4-驳回', `deadline` date, `create_time` datetime ); CREATE TABLE `delivery` ( `id` bigint PRIMARY KEY AUTO_INCREMENT, `student_id` bigint NOT NULL, `job_id` bigint NOT NULL, `resume_url` varchar(255), `status` tinyint DEFAULT 0 COMMENT '0-已投递 1-已查看 2-邀面试 3-不合适 4-已入职', `create_time` datetime, `update_time` datetime, UNIQUE KEY `uk_student_job` (`student_id`, `job_id`) ); CREATE TABLE `interview` ( `id` bigint PRIMARY KEY AUTO_INCREMENT, `delivery_id` bigint NOT NULL, `company_id` bigint NOT NULL, `student_id` bigint NOT NULL, `job_id` bigint NOT NULL, `interview_time` datetime NOT NULL, `location` varchar(200), `mode` tinyint DEFAULT 0 COMMENT '0-线上 1-线下', `content` varchar(500), `status` tinyint DEFAULT 0 COMMENT '0-待确认 1-已接受 2-已拒绝 3-已完成', `create_time` datetime );

唯一键uk_student_job是防重复投递的最后一道闸,业务代码里先判断一次,数据库这里再兜底一次。delivery.resume_url是投递时的简历快照,而不是去读学生当前简历的实时地址——学生在个人中心改简历后,企业看到的应该是投递那天的版本,这个字段能避免很多“简历怎么变了”的纠纷。job.status里我预留了“待审核”和“驳回”,是因为大多数校园招聘平台要有校方就业办审核,不是企业一发就上线。如果项目只做演示,可以直接用 0 草稿、1 招聘中、2 下线三档,但字段空间先留够,比后面加枚举值代价小得多。

2.2 SpringBoot Modules 与 Vue 目录结构:按业务域拆而不是按角色拆

拿到标题里这种带 Web 前端的项目,很多人会按“学生端、企业端、管理员端”拆,结果三个模块互相依赖,一个公共类改了,三个端全编译失败。更稳的做法是前后端分目录,后端按业务域拆 Maven modules,前端按页面职责拆目录。

后端目录一般我会这样摆:

recruit/backend ├── pom.xml ├── recruit-common # 统一返回体、异常、工具类 ├── recruit-system # 用户、认证、学生资料、企业信息 ├── recruit-recruit # 职位、投递、面试、定时任务 └── recruit-admin # SpringBoot 启动模块,放 WebConfig 和 Application

recruit-common只放无业务依赖的通用代码;recruit-system依赖 common;recruit-recruit依赖 system。recruit-admin是打包入口,其他模块作为依赖被引入。这个结构对应到 SpringBoot 的自动配置机制上有一个需要注意的点:启动类所在的recruit-admin必须扫描到所有模块的@Service、@Mapper组件,否则启动后接口能调通但 Service Bean 找不到。常见做法是在启动类上写@SpringBootApplication(scanBasePackages = "com.recruit"),把根包统一掉。

前端用 Vite 加 Vue3,目录按接口和页面聚拢:

recruit/frontend ├── src │ ├── api # 按后端 controller 分组,比如 job.js、delivery.js │ ├── assets │ ├── components │ ├── router # 路由,区分 student/company/admin 的父级路由 │ ├── store # 登录态、角色、菜单 │ ├── utils # axios 封装、日期格式化 │ └── views # 页面 vue 文件 ├── vite.config.js └── package.json

api目录和后端接口一一对应,而不是在页面上到处写axios.get。这样做的好处是后端接口路径变了,只改一个文件;面试问起“前端怎么管理接口”,也能说清楚封装思路。router里要用路由守卫控制角色跳转,否则学生登录后直接改 URL 也能进企业后台,权限就形同虚设。

2.3 SpringBoot 自动装配与配置文件:招聘系统的 yml 少了哪几个关键配置

SpringBoot 项目启动时,@SpringBootApplication组合注解里的@EnableAutoConfiguration会按 classpath 下的依赖自动生成 DataSource、SqlSessionFactory、DispatcherServlet 这些 Bean。很多新手以为“配置是玄学”,其实它只是按约定批量装配:你加了spring-boot-starter-web,它就帮你配好内嵌 Tomcat;你加了数据库驱动,它就尝试创建数据源。招聘系统里最容易翻车的是同时引入 MySQL 和 H2 驱动,自动装配不知道该用哪个,启动报一堆奇怪的错。

实际项目里,我会在application.yml里把数据源、上传限制、JWT 参数全部显式写出来:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/campus_recruit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB max-request-size: 30MB mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 custom: jwt: secret: campus-recruit-2024-secret expire-minutes: 120

spring.datasource.url里三个参数一个都不能少:useUnicode=true防止中文乱码,characterEncoding=utf8指定编码,serverTimezone=Asia/Shanghai解决 MySQL 8 的时区报错。multipart配置直接决定简历上传能不能用,默认 1MB 连一张扫描版 PDF 都装不下。custom.jwt是我用来读取密钥和过期时间的自定义前缀,用@ConfigurationProperties绑定到属性类,比把 secret 硬编码在代码里安全。文件上传目录、静态资源路径这些也建议在 yml 里预留,后面部署时改配置就能动,不用重新打包。

3. SpringBoot 后端接口:职位发布、简历投递、面试通知完整链路与参数校验

3.1 JWT 拦截与登录:学生和企业角色怎么区分权限

这套系统有三类角色,登录后请求必须带上 token。最省事的方案是引入 JWT,登录成功时生成 token,前端每次请求放在Authorization头里,后端用一个拦截器统一校验。拦截逻辑里真正要做的只有三件事:token 能不能解析、用户是否还存在、角色是否匹配当前接口。

下面这个JwtUtil是核心,它负责生成和解析 token。依赖引入 jjwt 后,代码可以写成这样:

public class JwtUtil { private static SecretKey key; private static long expireMinutes; public static void init(String secret, long expireMinutes) { key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public static String createToken(Long userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expireMinutes * 60 * 1000)) .signWith(key) .compact(); } public static Claims parse(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }

setSubject里放的是用户 ID,claim("role", role)放角色,解析时根据role就能判断学生、企业还是管理员。init方法在启动类里读 yml 中的custom.jwt.secret和custom.jwt.expire-minutes调用。密钥长度不能太短,否则 jjwt 的hmacShaKeyFor会直接抛异常,这是个很常见的配置坑。

拦截器层面用HandlerInterceptor的preHandle,从请求头拿出来 token 解析,把 userId 和 role 放到ThreadLocal或请求属性里,Controller 直接取:

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { Claims claims = JwtUtil.parse(token.substring(7)); request.setAttribute("userId", Long.valueOf(claims.getSubject())); request.setAttribute("role", claims.get("role")); return true; } response.setStatus(401); return false; } }

注意 token 在请求头里的格式统一写成Bearer xxxxxxx,前端和后端约定好,解析时先截掉前缀。需要区分权限的接口,再写一个自定义注解@RequireRole(2)放到 Controller 方法上,在拦截器里读注解比对。给面试通知接口加上企业角色限制,给学生投递接口加上学生角色限制,只要在拦截器里写一次,比每个 Controller 手写 if 判断干净太多。

3.2 职位发布接口:企业端发布职位,状态字段怎么控制上下线

企业发布职位的接口看起来简单,但如果status设计不对,后面审核、上下线、统计全部乱套。我建议把状态分成这几档:0 草稿、1 待审核、2 招聘中、3 已下线、4 驳回。企业提交时直接置为 1,管理员审核通过后变成 2,手动下线是 3,被驳回是 4,企业修改后重新提交又回到 1。

Controller 的写法也决定了这个接口好不好改:

@RestController @RequestMapping("/api/job") public class JobController { @PostMapping public Result<Long> create(@RequestBody @Valid JobCreateRequest req, HttpServletRequest request) { Long companyId = (Long) request.getAttribute("companyId"); Job job = new Job(); BeanUtils.copyProperties(req, job); job.setCompanyId(companyId); job.setStatus(1); jobService.save(job); return Result.ok(job.getId()); } }

companyId不应该由前端传,而是从登录用户的资料里查出来。如果学生角色也调这个接口,会被拦截器挡掉,所以接口本身不需要再写角色判断。JobCreateRequest里要用@NotBlank、@NotNull这类校验注解约束 title、city、salaryMin、salaryMax,避免前端传空值进来。薪资字段如果用字符串存“面议”,后面做筛选排序会很痛苦,所以我一般用两个 int,salaryMin=0且salaryMax=0表示面议。

这个接口最应该注意的是审核逻辑。很多毕设项目企业一发就直接上线,但“面向高校毕业生的智能就业平台”如果连审核都没有,岗位质量没法保障。给系统预留管理员角色后,职位列表查询就要区分“对外展示的职位”和“所有职位”两个版本:学生端只查status = 2,企业端查自己名下所有状态,管理员可以查全部。

3.3 简历投递:用数据库唯一索引挡住重复提交

简历投递最怕的不是接口写不出来,而是学生手快点了两次按钮,数据库里出现两条一模一样的投递记录。如果只靠先查询再插入,两个请求同时进来时都查不到记录,还是会重复插入。所以我在表结构里加的唯一键uk_student_job才是真正的防线。

Service 层写法可以简化成下面这样:

@Transactional public void apply(StudentUser student, Long jobId, String resumeUrl) { Delivery delivery = new Delivery(); delivery.setStudentId(student.getId()); delivery.setJobId(jobId); delivery.setResumeUrl(resumeUrl); delivery.setStatus(0); try { deliveryMapper.insert(delivery); } catch (DuplicateKeyException ex) { throw new BizException(400, "你已投递该职位,请在个人中心查看进度"); } }

@Transactional保证 insert 失败时事务回滚,不会产生半截子数据。DuplicateKeyException是 Spring 对唯一键冲突的封装,捕获后转成业务异常,前端就能弹出友好提示。为什么不在 catch 里做“查询已有记录并直接返回”?因为那一层查询可能是脏读,直接把异常转成提示最稳妥。

投递状态status的流转也要在 Service 里统一维护,不要每次都在 Controller 里直接 update。比如企业查看简历后状态从 0 变成 1,企业发送面试邀请前必须校验 delivery.status 是 1 或 2,状态不对要拒绝操作。状态机看起来多写几行,但它能避免面试通知发出去了,投递记录还停在“已投递”的尴尬情况。

3.4 面试通知加定时任务:SpringBoot 的 @Scheduled 处理面试提醒

面试通知这个功能,很多人只做了一个“企业添加面试记录”的接口,但实际业务是有两个时间点的:企业发出面试邀请时,要立刻通知学生;面试日期前一天,提醒学生别迟到。第二个动作适合用 SpringBoot 的定时任务做。

启动类或配置类上先加@EnableScheduling,然后写一个任务组件:

@Component public class InterviewRemindTask { @Scheduled(cron = "0 0 8 * * ?") public void remindInterview() { LocalDateTime now = LocalDateTime.now(); LocalDateTime tomorrow = now.plusDays(1); List<Interview> list = interviewMapper.selectComing(now, tomorrow); for (Interview interview : list) { // 站内信写一条记录,或调用短信网关 notifyService.sendRemind(interview.getStudentId(), interview); } } }

cron = "0 0 8 * * ?"表示每天早上 8 点执行一次,注意秒、分、时、日、月、周七个位置的顺序,写错一个系统启动直接抱错还很难定位。selectComing查的是面试时间在现在到明天之间的记录,SQL 里用<![CDATA[处理<符号,或者把参数拼成now()调用。如果系统部署了多个实例,这个定时任务会在每个节点各跑一遍,通知会重复发。生产环境要加分布式锁,或者把任务独立出来只在一个节点部署。

面试通知的“通知”本身也要落表。站内信表可以简单设计成id, user_id, content, is_read, create_time,学生在个人中心拉取未读消息。短信网关这类外部依赖不要放在主链路里,发失败不能影响面试记录保存,用异步或者消息队列解耦,毕设阶段先用站内信+邮件足够。

4. Vue 前端如何与后端对接:登录态、简历上传、面试通知和个人中心的落地写法

4.1 初始化 Vite + Vue3 并封装 axios:代理和 401 跳转

前端项目用 Vite 创建,最优先做的是把 axios 封装统一起来。项目里所有页面都会调接口,如果不封装,每个页面都要写token拼接逻辑,后续后端接口结构调整时改到怀疑人生。

先看src/utils/request.js:

import axios from 'axios' 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 => res.data, err => { if (err.response && err.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(err) } ) export default service

baseURL写成/api而不是完整域名,是为了让开发环境用 Vite proxy 把请求转发到后端,生产环境再让 Nginx 或 SpringBoot 处理同一个前缀,这样前端代码不用区分环境。响应拦截器直接返回res.data,是因为这个系统后端统一用Result包装,code、message、data三件套,页面里拿 data 就行;一旦后端把结构改成别的,只改这里。

对应的vite.config.js里要配 proxy:

export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

配完 proxy 后,浏览器里的请求还是localhost:5173/api/user/info,跨域问题根本不会出现,所以我不建议后端再到处写@CrossOrigin。生产环境和这个不同,前端打包后放进 SpringBoot 的静态资源目录,就没有跨域问题了。

4.2 职位列表页和简历投递:分页查询、FormData 上传

职位列表页是学生打开系统后的第一屏,查询条件一般有城市、职位类别、薪资范围、关键词。接口参数用分页加筛选,前端封装在src/api/job.js:

import request from '@/utils/request' export function getJobs(params) { return request.get('/job/list', { params }) } export function applyJob(jobId, file) { const formData = new FormData() formData.append('jobId', jobId) formData.append('file', file) return request.post('/delivery/apply', formData) }

applyJob里使用FormData,是因为简历是 PDF 或 Word 文件,不能放到 JSON 请求体里。jobId以字符串形式 append,后端用@RequestParam("jobId") Long jobId接收;文件用@RequestPart("file") MultipartFile file接收。调用时不要手动设置Content-Type,浏览器会自动加上multipart/form-data; boundary=...,一旦手动设置,boundary 对不上就会报 multipart 解析失败。

职位列表组件里只需要维护一个列表数组和分页对象,比如currentPage、pageSize、total。切换筛选条件时重置页码到 1,否则你在第 5 页筛选,永远看不到第一页结果。每一行的“投递”按钮要加loading状态,防止用户双击产生两次请求;后端虽然用唯一键兜底了,前端也要减少误操作。上传前还要检查文件类型和大小,常见做法是file.type.indexOf('pdf') > -1或者扩展名判断,超过 10MB 直接提示,别再往服务端传。

4.3 面试通知和个人中心:用 setTimeout 做轮询而不是 setInterval

学生登录后,个人中心最重要的信息是投递状态和面试通知。企业发出面试邀请后,学生不可能手动刷新页面等结果,所以要做一个小轮询。很多人一上来就写setInterval(fetch, 3000),但接口慢或者网络抖动时,上一次请求还没返回,下一次又发出去了,响应顺序错乱,页面数据会跳来跳去。

推荐写法是递归 setTimeout:

import { onMounted, onUnmounted } from 'vue' let timer = null const loadNotifyCount = async () => { const res = await getNotifyCount() state.unreadCount = res.data timer = setTimeout(loadNotifyCount, 30000) } onMounted(() => { loadNotifyCount() }) onUnmounted(() => { clearTimeout(timer) })

每次请求返回后才设置下一次 30 秒后的定时器,这样能保证请求不会叠加。30 秒是校园招聘这种低实时性场景的常规值,如果改成 3 秒,后端压力大且没有实际意义。如果学校网络环境好,想做到秒级通知,可以考虑 WebSocket 或者 SSE,但轮询在毕设和中小系统里足够稳定。

个人中心的投递记录要把后端返回的status数字翻译成文字和颜色。后端在接口里返回纯数字,前端用映射表渲染:

const deliveryStatusMap = { 0: '已投递', 1: '企业已查看', 2: '邀面试', 3: '不合适', 4: '已入职' }

企业端看到的是反过来的投递列表,同样一份delivery,学生端和个人中心是企业端工作台,状态映射文案可以不一样,但数字不能各写各的,否则前后端对不上。比如学生端显示“企业已查看”,企业端显示“待处理”,两边都要基于同一个 status 字段换算。

4.4 把 Vue 打包放进 SpringBoot:static 映射和刷新 404

开发完前端后,最省事的部署方式是把 Vue 打包产物放进 SpringBoot 的静态资源目录,一个 jar 包直接跑起来。操作上就是把dist里的文件复制到backend/src/main/resources/static,这一步可以手动做,也可以用 maven-resources-plugin 在构建时自动复制:

cd frontend && npm run build rm -rf ../backend/src/main/resources/static/* cp -r dist/* ../backend/src/main/resources/static/

如果平时用 Maven 打包后端,可以在pom.xml里配置插件,把前端打包产物作为资源复制过去。这里要注意的是,复制之后的index.html是给前端路由用的,后端如果没有对history模式的回退做处理,直接访问/student/resume会得到 Whitelabel 404 页面。需要加一个转发 Controller,把前端路由前缀转发到index.html:

@Controller public class PageForwardController { @GetMapping(value = {"/student/**", "/company/**", "/admin/**"}) public String forward() { return "forward:/index.html"; } }

三个前缀对应学生端、企业端、管理员端的路由路径。这样做能解决刷新 404,但要注意/api/**不能走这个转发,否则前端调用不存在的接口也会返回 HTML,而不是 JSON 报错。更稳妥的方式是在后端配置了统一的/api上下文后,再通过自定义 ErrorController 处理非/api的 404 跳转。比较省心的兜底方案是前端路由改用createWebHashHistory,路径变成/#/student/resume,刷新永远不会 404,代价是 URL 不好看,面试聊起来也不如 history 模式加分。

5. 避坑:基于 SpringBoot 和 Vue 的招聘系统最容易翻车的五个问题与排查

5.1 跨域报错:前端加 proxy,后端就别再配一堆 @CrossOrigin

现象:开发环境前端跑在 5173 端口,后端跑在 8080 端口,浏览器请求接口时报Access-Control-Allow-Origin错误。有人给每个 Controller 都加上@CrossOrigin,结果登录正常了,带Authorization头的接口还是报CORS preflight did not succeed。

原因:跨域不是加一个注解那么简单。Authorization头属于自定义请求头,浏览器会先发 OPTIONS 预检请求,后端配置不允许对应 Header 时预检就过不去。另外前端同时引用了 proxy 和后端@CrossOrigin,部分请求走了代理,部分请求直连后端,行为不一致。

解决:开发环境统一用 Vite 的 proxy 配置,浏览器始终请求同源地址,跨域问题直接消失。生产环境如果前后端没有部署在同一端口,后端用统一的WebMvcConfigurer写 CORS 规则,配置allowedOriginPatterns("*")、allowedMethods("*")、allowedHeaders("*"),不要在每个方法上注解。前后端约定请求头统一使用Authorization,CORS 配置里必须显式放行这个头。

5.2 简历上传失败:multipart 请求被拒绝,先查临时目录和大小限制

现象:学生投递简历时报Current request is not a multipart request,或者上传超过 1MB 的文件直接失败。

原因:第一种情况通常是前端手动设置了Content-Type: application/json,或者请求体里没有FormData;第二种情况是 SpringBoot 默认的multipart.max-file-size只有 1MB,简历 PDF 大部分都超过这个值。

解决:前端调用上传接口时一定不要手动设置 Content-Type,交给浏览器自动生成。yaml 里把大小限制调大,比如max-file-size: 10MB,max-request-size: 30MB。如果小文件能传、大文件失败,去查服务器临时目录权限和磁盘空间;Tomcat 默认会把上传文件写进系统 temp 目录,权限不对或磁盘满都会导致解析失败。把spring.servlet.multipart.location指到一个专用目录,能减少这类问题。

5.3 LocalDateTime 返回格式:前端看到一串带 T 的时间

现象:面试通知接口返回"interviewTime": "2025-06-03T09:15:00",前端页面直接显示,看起来非常不专业。有时候列表接口因为某个字段格式问题直接序列化报错,整个接口返回 500。

原因:Jackson 默认对LocalDateTime按 ISO-8601 格式输出,中间会带一个T。spring.jackson.date-format配置只对老Date类型生效,对LocalDateTime无效。

解决:在实体字段上加@JsonFormat:

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") private LocalDateTime interviewTime;

或者全局配置一个 Jackson 的Customizer,统一所有LocalDateTime的格式。我建议全局配置,不然每个时间字段都要加注解,漏一个就出一次问题。配置时注意timezone要写,否则时间可能比本地时间差 8 个小时,这也是时区问题的另一种翻车形式。

5.4 Vue 路由刷新 404:部署在 SpringBoot 里,F5 白屏

现象:前端打包放进 SpringBoot 后,访问首页正常,点击菜单跳转到/student/jobs后按 F5,变成 Whitelabel Error Page。

原因:前端用createWebHistory时,路由是浏览器端 history API 维护的,后端只配置了静态资源映射,没有对前端路由做回退。后端收到/student/jobs请求,找不到对应的 Controller 和静态文件,就返回 404。

解决:用 4.4 节里的PageForwardController,把/student/**、/company/**、/admin/**这些前端前缀转发到index.html。如果前端部署在 Nginx 上,就在 Nginx 配置里写try_files $uri $uri/ /index.html。注意只对页面路径做转发,/api/**和静态资源路径不要进这个规则,否则接口 404 会被伪装成 HTML 页面,排查问题更痛苦。

5.5 MySQL 连接参数“玄学”报错:时区、SSL 和编码的坑

现象:本地数据库连接好端端的,换到服务器启动时报The server time zone value '�й���׼ʱ��' is unrecognized,或者中文插入数据库变问号。

原因:MySQL 8 的 JDBC 驱动对时区处理更严格,serverTimezone没设置就会报错。Windows 和 Linux 系统默认时区不同,同一套配置在一个环境正常,另一个环境就会炸。编码问题则是连接 URL 里缺少characterEncoding参数。

解决:JDBC URL 里统一写成:

jdbc:mysql://localhost:3306/campus_recruit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

注意有两个细节:一是 yml 里&符号如果不加引号可能被当成 YAML 解析的锚点,所以整段 URL 建议用单引号包起来;二是 MySQL 8 的驱动类要写com.mysql.cj.jdbc.Driver,老项目常见的com.mysql.jdbc.Driver是新驱动里已经被移除的类。数据库建表时,排序规则也统一用utf8mb4_general_ci,这样 emoji 和特殊字符都能存,避免学生简历里的名字包含生僻字时变成问号。

6. 发版前的验证习惯:接口自测命令和页面路径的冒烟清单

每次把系统从开发机部署到服务器,我最怕的不是后端 500,而是前端页面能打开,但业务链路是断的。所以我养成一个习惯:上线前跑一组 curl 命令做接口冒烟,再人工点三个页面路径确认前后端连通。这组命令不长,但能覆盖招聘系统的核心链路:

BASE=http://localhost:8080/api # 1. 登录企业账号拿 token TOKEN=$(curl -s -X POST $BASE/auth/login \ -H 'Content-Type: application/json' \ -d '{"username":"company_demo","password":"123456"}' | jq -r '.data.token') # 2. 发布一个职位 JOB_ID=$(curl -s -X POST $BASE/job \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"title":"Java开发实习生","city":"上海","salaryMin":200,"salaryMax":300}' | jq -r '.data.id') # 3. 学生端登录并投递 STU_TOKEN=$(curl -s -X POST $BASE/auth/login \ -H 'Content-Type: application/json' \ -d '{"username":"student_demo","password":"123456"}' | jq -r '.data.token') curl -s -X POST $BASE/delivery/apply \ -H "Authorization: Bearer $STU_TOKEN" \ -F "jobId=$JOB_ID" \ -F "file=@/tmp/resume.pdf"

这四步走完,登录、发布、投递三个核心接口就都通了一遍。后面再补一条企业端把投递状态改为“邀面试”的接口,学生端再拉一次未读消息,整个链路就算闭环了。页面路径我固定看/student/jobs、/company/deliveries、/student/resume这三个,重点是刷新不 404、数据能渲染、上传 PDF 后能下载。

我最早部署这类系统时,只在本地把npm run dev和后端跑起来,页面全打开就以为上线成功。结果服务器上 jar 包一跑,学生端列表有数据,企业端登录却报 401,排查半天发现是 token 的密钥没在服务器环境变量里配。后来我把这组验证命令固定成脚本,每次发版先跑脚本,再人工点页面,前后端状态对不上的情况就很少再出现了。多花这十分钟,比上线后让用户当黑匣子测试体面得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询