最近抽空把之前做的大学生就业招聘系统整理成了完整源码,技术栈是 Java SpringBoot + Vue3 + MyBatis + MySQL,前后端分离,数据库用 MySQL 8.0。这个选题在课程设计、毕业设计里非常常见,学生端投简历、企业端发职位、管理员后台审核,三条业务线闭环走完,几乎覆盖了 Java 全栈面试里最喜欢问的登录鉴权、分页查询、多表关联、文件上传这些点。如果你正在准备课设、毕设,或者想找一个能写进简历的实战项目,这套东西可以直接拿来改、拿来跑。
我写这篇文章不只是贴源码结构和目录,而是把从需求分析到数据库设计、从后端接口实现到前端页面联调、再到部署运行遇到的坑,完整复盘一遍。所有表结构、核心代码、配置步骤都是我在实际开发中验证过的,不是网上那种只给你一个 README 的空壳项目。
1. 项目整体设计与需求拆解
1.1 从需求文档到功能模块
做这类系统最忌讳一上来就建表写代码,先把角色和业务用例理清楚,后面所有代码都是顺着用例长出来的。大学生就业招聘系统说白了就是三方角色围绕“职位”和“简历”这两条主线转。
学生端需要注册登录、完善个人简历(基本信息、教育经历、项目经历、技能标签)、浏览职位、按关键词和城市筛选、投递简历、查看投递状态、收藏职位、接收企业的面试邀约。企业端需要注册后由管理员审核通过、发布职位、下线职位、查看收到的简历、筛选候选人、发出面试通知。管理员端则是企业账号审核、职位审核、学生账号管理、基础数据统计。
这三个角色的功能叠加起来,后端接口基本可以归成四类:认证接口(登录注册、token刷新)、学生业务接口(简历、投递、收藏)、企业业务接口(职位管理、简历处理)、管理端接口(审核、统计)。前端就是对应四套页面,路由层面用角色字段做访问控制,学生访问企业后台就直接重定向到403页面。
1.2 为什么选这套技术栈:前后端分离的取舍逻辑
可能有人会问,这种课设级别项目用 JSP + Servlet 不是更快吗?为什么非要上 SpringBoot + Vue3 前后端分离?我说点实在的。
第一,SpringBoot 让后端开发重心完全落在业务代码上,不用再配置一堆 XML,内嵌 Tomcat 直接 java -jar 就能跑,这对课程设计来说极大降低了部署门槛。第二,MyBatis 比 JPA 更适合这种面试导向的项目,因为很多 Java 面试题就是问 MyBatis 中 #{} 和 ${} 的区别、一级缓存二级缓存、分页插件原理,你亲手在项目里用过这些东西,面试答起来完全不一样。第三,不用 MyBatis-Plus 是我故意做的选择,目的就是让你把手写 SQL、resultMap 映射、动态 SQL 这些基本功练扎实,实际工作中很多公司还是以手写 MyBatis 为主。
前端选 Vue3 + Vite + Element Plus 也是同理。Vite 冷启动快,开发体验比 Vue CLI 舒服太多;Element Plus 组件库能快速把后台管理界面搭出来,样式不用自己从零写。前后端分离之后,后端只出 JSON 接口,前端通过 Axios 调接口渲染页面,这种模式现在是企业绝对主流,你做完这个项目再去看任何后台管理系统都似曾相识。
2. 数据库设计与核心表结构
2.1 用户中心与角色设计
用户表我采用了单表加角色字段的设计,没有拆成 student 表、company 表、admin 表,然后各自独立登录。原因很简单,三个角色登录认证的逻辑完全一样,用户名、密码、手机号、状态这些字段是公共的,拆开反而让认证接口变成三份重复代码。我只需要在 tb_user 表里用一个 role 字段区分身份,然后学生和企业的详细信息各自放到扩展表里。
role 字段我用的是 tinyint,0 表示学生、1 表示企业、2 表示管理员。为什么不直接存字符串?因为 tinyint 占用空间小、对比速度快,而且代码里用常量类定义,不会出现字符串拼写不统一的问题。
这是 tb_user 的表结构,我简化后直接贴出来:
CREATE TABLE `tb_user` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` varchar(50) NOT NULL COMMENT '登录用户名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `role` tinyint NOT NULL DEFAULT '0' COMMENT '角色:0学生 1企业 2管理员', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1正常 0冻结', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`), UNIQUE KEY `uk_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';这里有两个关键设计。用户名和手机号都建了唯一索引,因为手机号可以作为登录账号的替代方案;如果你后续要接入短信登录,这个索引就是现成的。密码字段长度我给了 100,因为 BCrypt 加密后的字符串是 60 位,后续如果改用其他加密算法也不会因为长度不够而报错。status 字段用于管理员冻结账号,这属于业务状态,不要和逻辑删除混在一起。
学生扩展表存姓名、性别、出生日期、最高学历、毕业院校、专业、工作年限、期望城市、自我评价、头像等。企业扩展表存企业全称、统一社会信用代码、所属行业、规模、简介、营业执照图片地址。企业注册时是待审核状态,管理员通过后 status 才变成 1,这个过程在代码里通过一个审核记录表来追踪。
2.2 职位、简历与投递记录
职位表是业务核心,字段设计直接决定查询和筛选好不好写。我给出核心建表语句:
CREATE TABLE `tb_position` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '职位ID', `company_id` int NOT NULL COMMENT '企业ID', `title` varchar(100) NOT NULL COMMENT '职位名称', `category` varchar(50) DEFAULT NULL COMMENT '职位类别(Java/前端/测试等)', `city` varchar(50) DEFAULT NULL COMMENT '工作城市', `salary_min` int DEFAULT NULL COMMENT '薪资下限(K)', `salary_max` int DEFAULT NULL COMMENT '薪资上限(K)', `education` varchar(20) DEFAULT NULL COMMENT '学历要求', `experience` varchar(20) DEFAULT NULL COMMENT '经验要求', `description` text COMMENT '职位描述', `requirement` text COMMENT '任职要求', `status` tinyint DEFAULT '1' COMMENT '职位状态:1招聘中 0已下线', `view_count` int DEFAULT '0' COMMENT '浏览次数', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_company_id` (`company_id`), KEY `idx_category_city` (`category`, `city`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='职位表';薪资我特意拆成 salary_min 和 salary_max 两段,而不是直接存一个字符串“8k-13k”。因为字符串没法做范围筛选,学生搜“10k-15k”的职位时,SQL 要能同时比较下限和上限。这种看起来很小的设计,其实反映了对查询场景的理解,面试官问起来你就讲这个点。
投递记录表 tb_delivery 是这个系统的关键表,它记录了学生和职位之间的动作。我在 user_id 和 position_id 上建了联合唯一索引,直接从数据库层面杜绝重复投递。投递状态用 tinyint 表示:0 待处理、1 已查看、2 已通过、3 已拒绝。每次状态变更记录更新时间,方便学生端展示进度。
这里说一个重要经验:表之间的关联不要加物理外键。外键约束在并发写入时会带来额外的锁开销,而且后续做分库分表、数据迁移都非常痛苦。现在主流做法是只建普通索引,关联关系在 SQL 里通过 JOIN 维护。你可能会觉得没有外键数据会乱,但实际上业务代码里的逻辑判断完全能保证一致性,这个取舍在面试中也能体现你对数据库设计的理解。
2.3 初始化数据与 SQL 脚本
数据库初始化我准备了完整的 init.sql,包括建库、建表、插入管理员账号和测试数据。字符集一定要用 utf8mb4,不要用 utf8,因为 utf8 在 MySQL 里最多存 3 个字节,遇到表情符号就报错。
插入管理员账号的 SQL 如下,密码是 BCrypt 加密后的值,你直接用源码里的密码常量注册登录即可:
INSERT INTO `tb_user` (`username`, `password`, `role`, `status`) VALUES ('admin', '$2a$10$E.UesuNkuBox4EYCMuQmUeBcY2nudoEOCSs9Qh0yW3N9Y2Hn/q8bW', 2, 1);测试数据我准备了 10 家企业、20 个职位、若干学生账号,行业覆盖 Java、前端、运维、测试、产品等方向。建议初始化时多造一些数据,后面测试分页、搜索、筛选功能才有真实感,空表测试很多问题测不出来。
3. 后端核心实现与关键代码
3.1 SpringBoot 分层架构与统一返回结果
后端包结构我按 controller、service、mapper、entity、dto、common、config 来分。entity 对应数据库表,dto 是接口传输对象,避免把实体类直接暴露给前端。实体类里的 password 字段加了 @JsonIgnore,防止序列化时把密码返回到前端。
所有接口统一返回 Result 对象,结构是 code、message、data。code 为 200 表示成功,400 表示参数错误,401 表示未登录或 token 失效,500 表示服务器异常。前端 axios 拦截器只要判断 code 不是 200 就弹对应提示,整个项目的错误处理就统一起来了。
统一返回类代码很简单,重点在于全局异常处理。我在项目里加了 @RestControllerAdvice 全局异常处理器,业务异常通过自定义 BizException 抛出,参数校验异常通过 MethodArgumentNotValidException 捕获。这样 controller 里就不需要写一堆 try-catch,代码非常干净。这个设计是面试加分项,很多人做课设项目每个接口单独 try-catch,那代码根本没法看。
3.2 MyBatis 与分页插件:列表查询的正确打开方式
列表接口是高频接口,职位列表、投递记录、用户管理都涉及分页。我选的是 PageHelper 分页插件,因为它侵入性最小,不用写 limit 参数,也不用自己数总数。集成步骤很简单:
pom.xml 加依赖:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>application.yml 只需要一个配置:
pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: trueservice 层用法非常简单:
public PageResult<PositionVO> listPositions(PositionQuery query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); List<PositionVO> list = positionMapper.selectPositionList(query); return PageResult.build(new PageInfo<>(list)); }注意一个关键点,PageHelper 只对紧随其后执行的一条 SQL 生效。所以在调用 startPage 之后必须立刻执行 mapper 查询方法,中间不要穿插其他查询。如果你在 startPage 之后又去查了用户表,分页就会加错地方,总数也乱了。我见过很多新手在这个地方踩坑,排查半天发现是查询顺序问题。
另一个要点是 MyBatis 的多表关联查询。职位列表需要展示企业名称和 LOGO,我的做法是在 Mapper 里用 resultMap 做映射。手动写 resultMap 确实比 MyBatis-Plus 的注解麻烦,但它是 MyBatis 面试题的高频考点,你亲手配置过 collection 和 association,对数据映射的理解会深很多。
3.3 登录认证与权限控制:JWT Token 的落地细节
登录认证我选择 JWT 而不是传统 Session,理由有三:前后端分离后,Session 依赖 Cookie,跨域场景下 Cookie 处理麻烦;移动端假如要复用后端接口,Session 没法用;JWT 天然适合分布式系统,以后系统扩容加一台服务器不需要做 Session 共享。
我用的是 jjwt 库,版本这里特别注意,0.9.1 版本依赖 jaxb,JDK 11 以上运行会报错。建议直接用 0.11.5,API 稍有不同,但稳定很多。核心代码我拆成 JwtUtil 工具类,包含生成 token 和解析 token 两个方法:
public String generateToken(Integer userId, String username, Integer role) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }token 过期时间设为 7 天,这个时间要权衡用户体验和安全。太短学生投个简历就过期要重新登录,太长又有安全风险。项目里我还在拦截器里做了自动刷新处理,token 剩余有效期不足 2 天时,重新签发新 token 放在响应头里。
权限控制通过 HandlerInterceptor 实现,在 preHandle 方法里先检查请求头是否携带 token,然后解析校验,最后把用户信息放入 ThreadLocal。这里要说一个细节:拦截器里放行的路径要配置好,登录接口、注册接口、文件访问路径、静态资源都要放行,否则前端还没登录就被拦截器挡掉了。
密码存储我用的是 BCrypt。不用 MD5 的原因很简单,MD5 是消息摘要算法,不是为密码存储设计的,彩虹表可以轻易破解;BCrypt 自带盐值,每次加密结果都不一样,即使两个用户密码相同,密文也不相同。这是安全常识,写进简历是亮点,面试官也爱问。
3.4 简历文件上传与静态资源映射
学生在简历模块需要上传 PDF 或 Word 文件。文件上传的核心实现就是 SpringBoot 的 MultipartFile,但我在这里遇到一个最容易被忽略的坑:文件存储路径和访问路径的映射。
文件不能直接存到数据库的 BLOB 字段,因为数据库会迅速膨胀,备份恢复都成问题。正确做法是存到本地磁盘一个目录,数据库里只存文件的相对路径。配置文件里我用自定义属性:
upload: path: D:/data/upload/然后写一个 WebMvcConfigurer,把 /upload/** 的请求映射到本地磁盘目录:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceResolver(new PathResourceResolver() { // 实际项目中直接使用绝对路径映射即可 }); registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); }这个映射不配置的话,前端 img 标签访问 http://localhost:8080/upload/xxx.pdf 会 404,但文件明明已经存在磁盘上,问题就在虚拟路径没做映射,这个坑很典型。
文件上传我还加了大小限制,application.yml 里配置:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB超过大小会抛出 MaxUploadSizeExceededException,我在全局异常处理器里单独捕获这个异常,返回友好提示。另外在上传时把文件扩展名做白名单校验,只允许 pdf、doc、docx、jpg、png,防止有人上传 JSP 或 exe 文件,这是最基础的安全意识。
4. Vue3 前端实现要点
4.1 工程创建与基础配置
前端部分我用 Vite 创建 Vue3 项目,命令就是 npm create vite,选择 Vue 模板。这里需要提醒一下,Vite 要求 Node.js 16 以上,装的时候先检查 node -v 版本,很多新人在这一步就卡住了。
项目依赖我安装了 vue-router、pinia、axios、element-plus。Element Plus 按需引入还是全量引入?课设项目全量引入就行,省事,按需引入配置起来多一层麻烦,对最终打包体积影响不大。main.js 里挂载 Element Plus 和路由:
import { createApp } from 'vue' import { createPinia } from 'pinia' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import App from './App.vue' import router from './router' const app = createApp(App) app.use(createPinia()) app.use(router) app.use(ElementPlus) app.mount('#app')Vue3 项目的核心思路就是组件化。我会把页面拆成布局组件和视图组件,登录页是一个独立视图,学生端是一个带侧边栏的布局,企业端和管理员端各自有自己的布局。路由做嵌套,侧边栏菜单根据角色动态渲染,这个结构后面加新页面只是加一个路由和对应的 view 文件。
4.2 Axios 封装与拦截器
前后端联调的关键在 axios 封装。我在 src/utils/request.js 里创建了 axios 实例,baseURL 设置为 /api,这样开发时通过 Vite 代理转发到后端 8080 端口,生产环境可以把前端静态文件部署到 Nginx,再把 /api 反向代理到后端服务,整个项目不用改任何代码。
请求拦截器里把 token 从 localStorage 取出来放到请求头:
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config })响应拦截器统一处理业务码:
service.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res } if (res.code === 401) { router.push('/login') return Promise.reject(new Error(res.message)) } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )这里说个我踩过的坑,在响应拦截器里弹错误提示时,同一个页面上多个请求同时失败,会连续弹出好几个 Message 弹窗,非常烦人。后来我封装了一个防抖提示函数,相同错误内容在一段时间内只弹一次。这种体验细节虽然不是功能需求,但做完之后整个项目质感会提升一个档次。
4.3 核心页面拆解
学生端的职位列表页是核心页面。搜索区放职位名称、城市、薪资范围三个筛选条件,点击搜索后重新请求第一页数据。列表用 el-table 展示,职位名称、企业名称、城市、薪资、学历要求、发布日期,最右侧是“投递”和“收藏”按钮。投递按钮点击后调用后端接口,如果后端返回重复投递提示,前端就直接禁用按钮。
简历编辑页我用了动态表单,教育经历和项目经历都是可以增减的数组。Element Plus 的 el-form 用 v-for 渲染动态表单项,校验规则要写在 form 的 rules 里。这里要注意动态表单的 model 绑定,新增一行时要用数组的 push 方法,同时为了 Vue 3 的响应式,不能直接改数组索引,必须用 Vue 推荐的响应式方法。
企业后台页面需要 tab 切换,已发布职位、收到的简历、面试管理。已发布职位用表格展示,每条数据有下线按钮;收到的简历列表根据投递状态显示“通过”“拒绝”按钮。这个页面的数据基本来自三个不同的接口,但页面骨架完全一致,所以我抽了一个通用的列表组件,传入接口地址和列配置就能复用。
路由守卫是前端权限控制的核心。在 router.beforeEach 里判断目标路由的 meta.roles 是否包含当前用户角色,不包含就跳转 403 页面。比如企业后台路由 meta.roles 是 [1],学生登录后访问这个路径会被拦住。前端控制虽然安全等级不如后端,但它能挡住绝大多数越权访问,提升用户体验。
5. 部署、联调与常见问题排查
5.1 本地环境准备
新手第一次跑这个项目,最容易栽在环境配置上。我按自己的实际顺序列一下:
后端需要 JDK 8 或 17,我推荐 JDK 17,SpringBoot 用 2.7 系列完全兼容。Maven 用 3.8 以上,配置阿里云镜像仓库,否则依赖下载能等半小时。MySQL 安装时注意设置 root 密码,字符集选 utf8mb4,然后新建数据库导入 init.sql。开发工具用 IDEA,打开项目后等待 Maven 导入依赖,注意 JDK 版本要和项目的编译版本一致,否则启动直接报错。
前端需要 Node.js 16 以上,npm 换淘宝镜像源,安装依赖后 npm run dev 启动开发服务器,默认端口是 5173,后端是 8080。所以 vite.config.js 里需要配置开发代理:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } }这里 rewrite 的作用是把 /api 前缀去掉,后端接口设计时不需要带 /api 前缀,前端所有请求统一加 /api,代理转发时再拆掉。这样做的原因是生产环境 Nginx 可以继续做同样的反向代理,前后端联调和生产环境部署用同一套路径逻辑。
5.2 前后端联调的常见坑
我把实际联调过程中遇到的坑统一整理成一张速查表,按出现频率排序:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 前端请求接口报 404 | 后端接口路径和前端请求路径不一致 | 检查 controller 的 @RequestMapping 注解 |
| 登录后请求其他接口返回 401 | 拦截器校验失败 | 看请求头是否带 token,token 是否过期 |
| 前端页面跨域请求被浏览器拦截 | 后端没有开启跨域,或代理配置错误 | 开发环境用 Vite 代理,生产用 Nginx |
| 上传的文件访问 404 | 静态资源虚拟路径没有映射 | 配置 addResourceHandlers |
| 分页数据不对或总数异常 | PageHelper.startPage 调用后混入其他查询 | 保证 startPage 后紧跟目标查询 |
| 数据库中文乱码 | 连接串没设置字符集 | URL 加 useUnicode=true&characterEncoding=utf8 |
| 端口被占用启动失败 | Tomcat 8080 被其他程序占用 | 换端口或杀掉进程 |
这里特别说一下跨域问题。开发环境我用的是 Vite 代理方案,前端请求 /api 开头,Vite 代理转发到后端,浏览器看到的请求是同源的,所以不需要在后端配置 CORS。如果后端配置了 CORS 又同时用了代理,有时候反而会因为重复添加跨域头导致问题。我通常就是二选一,开发用代理,后端保持干净。
5.3 面试怎么讲这个项目
项目做完之后,下一步就是把它讲清楚。我建议你在简历上这样描述:独立设计并开发了大学生就业招聘系统,采用 SpringBoot + Vue3 前后端分离架构,实现了学生、企业、管理员三类角色的完整业务闭环,包含职位发布、简历投递、在线审核、数据统计等模块。
面试官针对这个项目最可能问的点,我提前帮你列几个:
为什么用 JWT 不用 Session?答:前端分离后 API 无状态设计,JWT 自包含校验,适合分布式扩展,详细答案见 3.3 节。
分页插件底层是怎么实现的?答:PageHelper 是基于 MyBatis 拦截器的,启动时把当前线程的 Page 参数绑定到拦截器上下文中,SQL 执行时拦截器拦截 Executor 的 query 方法,改写 SQL 拼接 limit,并自动执行 count 查询,执行完清除 ThreadLocal。
多表查询是怎么做的?答:通过 resultMap 做关联映射,collection 标签处理一对多集合属性,SQL 层用 LEFT JOIN 一次查出来,避免 N+1 查询。
权限控制除了前端路由守卫还做了什么?答:后端拦截器校验 token 后解析角色,在需要权限的接口上通过自定义注解或者代码判断角色,双重校验防止绕过前端直接请求 API。
你在面试中如果能把这些问题答清楚,这个项目的含金量就直接拉满了。不要只停留在“我用了 SpringBoot 和 Vue”这种层面,要做到每个技术选型都能说出一二三。
我个人在实际开发中的体会是,这个项目是少数几个能把前后端全链路走通还包含完整业务闭环的课设选题。从需求梳理到表设计,从接口开发到页面联调,每一步都会逼着你做取舍。比如薪资字段拆两段、投递加唯一索引、PageHelper 的 ThreadLocal 机制、JWT 过期策略,这些点没做过真的学不会。
最后再分享一个小技巧:如果你时间紧张,建议先做核心链路——登录、职位列表、投递简历、企业处理投递。把这条链路彻底跑通,再补收藏、审核、统计这些附加功能。核心功能做到位项目就立住了,附加功能是锦上添花。做完之后记得写一份 README,把启动步骤和环境要求写清楚,无论交作业还是开源给同学参考,都会方便很多。