
先说一句大实话市面上的“招聘系统”源码一抓一大把但能从数据库设计一路讲到前端权限控制的完整教程不多。这篇分享用的是 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.183.x 也可以但有些第三方 starter 还没跟上建议保守Vue 2.7 Element UI 2.15Vue 3 Element Plus 也行但是 Element UI 的资料多新手踩坑少MyBatis 2.3.1 对应的 spring-boot-starter注意版本这是 mybatis-spring-boot-starter 的版本号MySQL 5.7 / 8.0 均可Node.js 16Maven 3.62.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意几个细节密码永远不要明文存储我这里用的是 BCrypt 加密比 MD5 靠谱得多。MD5 配合彩虹表早就被打穿了写简历的时候如果写“密码使用 MD5 加密”面试官看到会直接无语。id 用自增而不是 UUID。招聘系统数据量撑死几十万条自增主键走聚集索引查询性能比 UUID 好。等真到了要拆库分表的量级再做雪花算法也不迟。所有表的字符集用 utf8mb4不要用 utf8。因为 utf8 在 MySQL 里最多存三个字节一些生僻字和 emoji 表情会直接报错。这是老生常谈但踩的人依然不少。2.3 核心表之间的关联关系职位表关联了企业表和职位分类表。“投递记录表”是三表关联的体现用户表、职位表、简历表。需要特别注意的是企业表和用户表我做了逻辑分离而不是物理合一。也就是说企业注册的时候会生成两条记录用户表里加一条 role2 的账号企业表里加一条企业信息。企业表通过 user_id 关联回用户表。这样做的考虑是扩展性——以后如果允许一个账号管理多家公司只需要在企业表加个 company_id 字段就行。如果你把企业和用户揉在一张表里后面做这种扩展会非常痛苦。职位表设计时有一个字段值得单独拿出来说is_active上下架状态和audit_status审核状态是分开的。这是业务上的约束HR 发布职位后管理员需要审核审核通过后职位才可见上架/下架是 HR 自己的操作不需要重新走审核。合在一起会导致状态机混乱分开就清爽很多。3. 后端核心功能实现3.1 MyBatis 配置与分页插件接入MyBatis 的使用没什么高深的但有几个配置项值得提一下。第一个是驼峰映射。数据库字段是 create_timeJava 属性是 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 groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency用法是PageHelper.startPage(pageNum, pageSize); ListPosition list positionMapper.selectByCondition(condition); PageInfoPosition 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 idselectByCondition resultTypecom.example.entity.Position select * from position where if testkeyword ! null and keyword ! and title like concat(%, #{keyword}, %) /if if testcity ! null and city ! and city #{city} /if if testminSalary ! null and salary_min gt; #{minSalary} /if /where order by create_time desc /select这里出现了一个容易出 bug 的细节XML 里和要转义。比如salary_min #{minSalary}写成gt;不然 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 里需要加上allowPublicKeyRetrievaltrue。另外时区参数要加serverTimezoneAsia/Shanghai否则程序启动时直接给你一个红彤彤的报错信息。数据库连接代码如下spring: datasource: url: jdbc:mysql://localhost:3306/job_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue5.2 文件上传大小限制简历上传如果超过 1MB 会被 SpringBoot 直接拦掉默认max-file-size是 1MBmax-request-size是 10MB。做招聘系统简历文件很容易就超过这个限制尤其是带作品集的求职者。我在 application.yml 里把它放开到 20MBspring: 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_activeJava 属性叫isActive开启驼峰映射就没问题但如果数据库叫is_activeJava 类写成了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 了。这两个方向都能让你的项目从“课程设计”变成“有亮点的工作经验”。