先说一句大实话:市面上的“招聘系统”源码一抓一大把,但能从数据库设计一路讲到前端权限控制的完整教程不多。这篇分享用的是 SpringBoot + Vue + MyBatis + MySQL 这套经典组合,做的是一套带管理员端、企业端、求职者端三种角色的招聘管理系统。写这篇文章的目的,是帮那些刚学完 Java 基础、想做一个能写进简历的完整项目的人,或者已经在公司里被分配了类似“内部招聘平台”需求的后端开发,省掉到处翻文档的折腾时间。全文会按照我的实际开发顺序来讲:从表结构设计、后端接口实现、前端页面联调,到分页、权限、文件上传这些绕不开的细节,最后会把我踩过的坑一并列出来。
1. 项目整体思路与架构拆解
1.1 为什么选 SpringBoot + Vue 这套组合
SpringBoot 和 Vue 在 2025 年依然是中小型管理系统的主流搭配,没有之一。原因很朴素:SpringBoot 让后端不用再忍受 XML 配置的折磨,内嵌 Tomcat,打一个 jar 包就能跑;Vue 的组件化开发方式特别适合后台管理系统这种“左侧菜单 + 右侧表格 + 弹窗表单”的固定形态。MyBatis 则胜在 SQL 可控,招聘系统里有大量复杂联表查询(职位表、简历表、投递记录表、用户表),用 MyBatis 写 SQL 比 JPA 那种自动生成的方式更直观,排查问题也更方便。
这套组合的另一个优点是“学习曲线平缓”。对后端开发者来说,Vue 的模板语法跟 Angular 比已经很接近原生 HTML 了;对前端开发者来说,SpringBoot 的 Controller 返回 JSON 就是约定俗成,不需要理解什么复杂的 RPC 协议。再加上两者社区资源极多,你遇到任何问题基本都能搜到现成答案。
1.2 三种角色的权限设计
招聘系统的核心难点不在增删改查,而在“谁来操作什么”。
我这边把用户分成三种角色:管理员、企业 HR、求职者。管理员的权限是全局的——管理所有企业账号、审核职位、查看全站的数据统计;企业 HR 只能操作自己公司名下的数据,比如发布职位、查看投递简历、维护公司信息;求职者则是浏览职位、投递简历、管理自己的简历附件。
权限这块我没有引入 Spring Security 那种重型框架,而是用 JWT + 拦截器来做的。为什么不用 Shiro 或 Security?因为招聘系统本身不是高安全等级场景,不需要动态权限模型、不需要细粒度的按钮级控制,用拦截器校验 token 里的角色字段,配合前端路由守卫做页面隐藏,已经足够。这样做的好处是代码量少、逻辑透明,新手拿到手也看得懂。
1.3 功能模块划分
整个系统拆成了几个独立模块,开发的时候可以并行推进:
- 用户模块:注册、登录、个人信息维护,用户表用 role 字段区分三种身份
- 企业模块:企业信息的增删改查、企业认证状态管理
- 职位模块:职位发布、下架、审核,支持按城市/薪资/关键词搜索
- 简历模块:在线简历编辑、附件简历上传(只支持 PDF,别问为什么,问就是 Word 的格式能被浏览器解析出来的是真少)
- 投递模块:投递记录、状态流转(待审核→已查看→已邀约→已录用→已拒绝)
- 数据统计模块:管理员首页的仪表盘,展示职位数、用户数、投递量等
每个模块都是先设计好接口文档再动手写代码,这个习惯很重要。我见过太多人代码写一半才发现接口字段对不上,前后端联调的时候改来改去改到怀疑人生。
2. 环境准备与数据库设计
2.1 环境版本说明
写这篇文章的项目使用的版本是经过实测稳定的组合:
- JDK 1.8(别用 17,除非你想体验 Lombok 不兼容的酸爽)
- SpringBoot 2.7.18(3.x 也可以,但有些第三方 starter 还没跟上,建议保守)
- Vue 2.7 + Element UI 2.15(Vue 3 + Element Plus 也行,但是 Element UI 的资料多,新手踩坑少)
- MyBatis 2.3.1 对应的 spring-boot-starter(注意版本,这是 mybatis-spring-boot-starter 的版本号)
- MySQL 5.7 / 8.0 均可
- Node.js 16+,Maven 3.6+
2.2 数据库表结构设计
表结构是整个项目的灵魂。我设计的时候遵循了一个原则:先拆业务,再建表。总共八张表,分别是用户表、企业表、职位表、简历表、投递记录表、职位分类表、公司行业表、系统日志表。
用户表是最核心的,这里给个核心字段:
CREATE TABLE `user` ( `id` int NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `role` tinyint NOT NULL DEFAULT '3' COMMENT '1管理员 2企业 3求职者', `phone` varchar(20) DEFAULT NULL, `email` varchar(50) DEFAULT NULL, `status` tinyint NOT NULL DEFAULT '1' COMMENT '账号状态:1正常 0禁用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意几个细节:
- 密码永远不要明文存储,我这里用的是 BCrypt 加密,比 MD5 靠谱得多。MD5 配合彩虹表早就被打穿了,写简历的时候如果写“密码使用 MD5 加密”,面试官看到会直接无语。
- id 用自增而不是 UUID。招聘系统数据量撑死几十万条,自增主键走聚集索引,查询性能比 UUID 好。等真到了要拆库分表的量级,再做雪花算法也不迟。
- 所有表的字符集用 utf8mb4,不要用 utf8。因为 utf8 在 MySQL 里最多存三个字节,一些生僻字和 emoji 表情会直接报错。这是老生常谈但踩的人依然不少。
2.3 核心表之间的关联关系
职位表关联了企业表和职位分类表。“投递记录表”是三表关联的体现:用户表、职位表、简历表。
需要特别注意的是:企业表和用户表我做了逻辑分离而不是物理合一。也就是说,企业注册的时候会生成两条记录:用户表里加一条 role=2 的账号,企业表里加一条企业信息。企业表通过 user_id 关联回用户表。这样做的考虑是扩展性——以后如果允许一个账号管理多家公司,只需要在企业表加个 company_id 字段就行。如果你把企业和用户揉在一张表里,后面做这种扩展会非常痛苦。
职位表设计时有一个字段值得单独拿出来说:is_active(上下架状态)和audit_status(审核状态)是分开的。这是业务上的约束:HR 发布职位后管理员需要审核,审核通过后职位才可见;上架/下架是 HR 自己的操作,不需要重新走审核。合在一起会导致状态机混乱,分开就清爽很多。
3. 后端核心功能实现
3.1 MyBatis 配置与分页插件接入
MyBatis 的使用没什么高深的,但有几个配置项值得提一下。
第一个是驼峰映射。数据库字段是 create_time,Java 属性是 createTime,如果不开启 map-underscore-to-camel-case,你查出来的 createTime 永远是 null,找半天找不到原因:
mybatis: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl第二个是分页。招聘系统的职位列表、投递记录列表全都需要分页,手写 LIMIT 会写吐。我用的 PageHelper,这个插件在 2025 年依然非常稳定:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>用法是:
PageHelper.startPage(pageNum, pageSize); List<Position> list = positionMapper.selectByCondition(condition); PageInfo<Position> pageInfo = new PageInfo<>(list);这里有个坑必须提醒:PageHelper 的 startPage 必须紧跟在要分页的查询语句前面,中间不能穿插别的查询。因为 PageHelper 用的是 ThreadLocal,一旦你在中间执行了另一条 SQL,分页就直接作用到那条 SQL 上去了。别问我是怎么知道的。
3.2 JWT 登录认证与拦截器
后端拦截器的实现逻辑是:登录接口放行,其他接口都走 token 校验。校验通过就把用户信息塞到 ThreadLocal 里,方便后续代码获取当前登录用户。
JWT 生成采用 jjwt 库,core、api、impl 这三个包都引入。token 里存 userId 和 role,密钥硬编码在配置文件里,过期时间设为 24 小时。24 小时其实有点太长了,但这个项目为了演示方便,没有做 refresh token 机制。实际生产环境一般 30 分钟过期 + 7 天 refresh token,但那样代码复杂度会提升不少。
拦截器这里有一个容易被忽视的点:跨域问题。如果你前端用的是 Vue devServer 代理,那后端只要放开 CORS 即可。如果前端直接用 axios 请求后端地址,就会遇到预检请求(OPTIONS)被拦截器拦掉的问题。我第一个版本就栽在这上面:前端死活报 401,打开控制台才发现是 OPTIONS 请求没放行。解决方案是在拦截器里加一句判断:
if ("OPTIONS".equals(request.getMethod())) { return true; }3.3 多条件搜索与简历投递实现
职位搜索是招聘系统里最常被问到的功能:按关键词模糊搜索标题、按城市精确匹配、按薪资区间筛选、按发布时间排序。
我一开始的做法是 SQL 里拼动态条件,用<if>标签来判断传参是否为空。MyBatis 动态 SQL 也确实是这么设计的:
<select id="selectByCondition" resultType="com.example.entity.Position"> select * from position <where> <if test="keyword != null and keyword != ''"> and title like concat('%', #{keyword}, '%') </if> <if test="city != null and city != ''"> and city = #{city} </if> <if test="minSalary != null"> and salary_min >= #{minSalary} </if> </where> order by create_time desc </select>这里出现了一个容易出 bug 的细节:XML 里>和<要转义。比如salary_min >= #{minSalary}写成>=,不然 XML 解析会直接报错。这个老手都知道,但新手第一次写很容易被卡住。
投递记录的核心逻辑是“同一用户对同一职位只能投递一次”,这个约束我放在了数据库层,用唯一索引实现:
ALTER TABLE deliver_record ADD UNIQUE KEY uk_user_position (user_id, position_id);同时后端在投递前也会查一次记录,防止重复提交时抛异常给用户看到白花花 500 页。这里是一个防御式编程的经典案例:前端按钮 disable + 后端查重 + 数据库唯一索引,三层防护缺一不可。
4. 前端页面与接口联调
4.1 Vue 项目初始化和路由配置
前端部分我用 Vue CLI 创建项目,安装 Element UI、axios、vue-router、vuex。安装 Vue 依赖有一个常见坑:npm install 时卡在某个包不动,多半是网络问题,直接切换 npm 镜像源能解决余下大部分问题。
路由配置上,我用了静态路由 + 动态路由结合的方式。静态路由只有/login和/register,登录成功后根据角色动态添加菜单路由。Vue Router 的 addRoutes 方法在 2025 年已经改成了 addRoute,但 Vue 2.7 还兼容旧写法,这里根据你实际版本来。
前端路由守卫是权限控制的第一道门:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else { next() } })4.2 Axios 封装与 token 携带
axios 封装是我比较想展开说说的部分。这次前后端联调时,因为 token 没有统一附加到请求头,导致后端收到了 Not Login 错误。在 main.js 里做响应拦截器的好处是:所有后端返回 401 或者其他错误码,都能统一弹提示,不需要每个接口里单独写错误处理。
真正让我体会到封装价值的场景是:后端返回的 code 是 200,但业务 code 是 500,提示语里面还有后端传过来的 Exception 信息,前端拿到之后直接在数秒后弹出提示,这个体验远好于不处理。我的 axios 封装结构如下:
service.interceptors.request.use( config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }, error => Promise.reject(error) ) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { Message.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } )这里特别说明一下:响应拦截器里面做了错误提示之后,调用页面里就不要再用 try-catch 去再次弹出错误信息,否则同一个报错会弹两遍提示框,看起来相当业余。
4.3 核心页面与组件拆分
组件化拆分的原则是“三次以上复用就抽组件”。我在“职位列表”页面抽出了 SearchBar、PositionCard、PaginationBar 三个组件,在“简历管理”页面抽出了 ResumeForm、AttachmentUpload 两个组件。
Element UI 的 el-table 结合 el-pagination 是后台管理系统最经典的组合。表格的列配置要写清楚 prop 和 label,这个没有任何技术难度,真正麻烦的是“操作列”(编辑、删除、上下架)的按钮权限控管。我在前端做了一个自定义指令v-permission,传入需要的角色数组:
Vue.directive('permission', { inserted(el, binding) { const roles = JSON.parse(localStorage.getItem('userInfo'))?.roles || [] if (!binding.value.some(r => roles.includes(r))) { el.parentNode.removeChild(el) } } })在职位表格里,只有管理员角色的按钮会显示“审核”,企业角色按钮显示“下架”,求职者角色显示“投递”。这个功能在面试时特别加分,很多应届生只会写增删改查,对按钮级权限没有概念,你能讲出来就是差异化。
5. 常见问题与踩坑实录
5.1 数据库连接和时区问题
MySQL 8.0 连接时经常会遇到Public Key Retrieval is not allowed报错。这个问题的根源是 MySQL 8.0 默认的 caching_sha2_password 认证插件,JDBC URL 里需要加上allowPublicKeyRetrieval=true。另外时区参数要加serverTimezone=Asia/Shanghai,否则程序启动时直接给你一个红彤彤的报错信息。
数据库连接代码如下:
spring: datasource: url: jdbc:mysql://localhost:3306/job_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true5.2 文件上传大小限制
简历上传如果超过 1MB 会被 SpringBoot 直接拦掉,默认max-file-size是 1MB,max-request-size是 10MB。做招聘系统,简历文件很容易就超过这个限制,尤其是带作品集的求职者。我在 application.yml 里把它放开到 20MB:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MB这里还有个国产文件类型问题,就是前端上传时校验了 PDF 类型,但某些古董浏览器会把 PDF 的 MIME 类型识别为application/octet-stream,导致校验通过不了。我最后放弃了前端 MIME 严格限制,改成只检查文件后缀名.pdf,后端再做类型伪装校验,毕竟真上传个 exe 进来也不是我们想要的。
5.3 前后端联调 404 和接口返回 null
接口联调阶段我最常遇到的问题是前端访问/api/position/list返回 404,而后端明明写了这个接口。排查下来一半是打包后的前端静态资源没有放到后端 resources 目录下,另一半是后端接口路径带了个/api前缀,而 nginx 或 devServer 代理没配好。
接口返回的字段里大量出现 null 值,多半是实体类属性名和数据库字段名对不上。排查方法很直接:看控制台里 MyBatis 打印的 SQL 和 resultMap。比如数据库叫is_active,Java 属性叫isActive,开启驼峰映射就没问题;但如果数据库叫is_active,Java 类写成了activeStatus,那就只能自己写 resultMap 手动映射。
5.4 日志排查三板斧
如果项目跑起来后数据不对,先说最简单的排查思路:第一,看控制台打印的 SQL,确认 MyBatis 生成的 SQL 是不是你预期的逻辑,新手最容易翻车的是动态 SQL 里的条件没有命中,导致全表查询了;第二,看前端 Network 面板里的 Response,确认后端返回的 JSON 结构是否跟前端页面对表字段的命名一致;第三,看 IDE 的断点调试,在 Service 层入口和方法返回值上分别断点,很快就能定位问题是出在前端还是后端。
这套三板斧我使用频率最高的反而是第一板斧。查数据库问题时,SQL 的日志分析能力远比调试 Java 代码重要。
写在最后
这个项目前后花了我差不多两个完整周末,做到第三天才把数据权限理得比较顺——核心问题不在于功能多难,而是功能交织在一起,表单字段多、状态流转多,改一处带动好几处。但做完之后再回头看,这套 SpringBoot + Vue + MyBatis + MySQL 的招聘系统,其实每个模块单独拎出来都不复杂,复杂度都藏在边界情况里。
我个人实操中有一个习惯,现在写下来供你参考:每完成一个模块,我会先把接口文档(哪怕是简单的 Markdown 表格)更新一遍再去做下一个模块。因为开发隔几天之后你再看自己写的接口,真的会忘记返回字段里那个isActive到底是什么意思。别相信自己的记忆力,把接口定义写清楚,联调的时候会少掉一半血压飙升的机会。
如果你想在这个项目上继续扩展,我建议两个方向:一是做简历解析,把 PDF 里的基本信息自动抽取到表单里,技术栈可以试试 PDFBox 或者 OCR;二是增加消息通知,当企业查看你的简历或者管理员审核通过职位时,站内信实时推送给用户,这就能引入 WebSocket 了。这两个方向都能让你的项目从“课程设计”变成“有亮点的工作经验”。