恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
SpringBoot + Vue3 招聘系统实战:从数据库设计到 Nginx 部署上线
首页
资讯中心
/
SpringBoot + Vue3 招聘系统实战:从数据库设计到 Nginx 部署上线
SpringBoot + Vue3 招聘系统实战:从数据库设计到 Nginx 部署上线
发布时间:2026/10/2 5:34:44
招聘系统这个方向说起来不算新但你要是真去学校就业办转一圈会发现很多地方还在用Excel 收集简历 微信群发岗位的方式工作。学生简历格式五花八门企业岗位信息重复录入投递状态全靠人工同步一到秋招春招高峰期整个流程乱成一锅粥。所以我当时接到这个需求时目标很明确做一套学生、企业、管理员三方都能用的招聘系统学生能维护简历、浏览岗位、投递并跟踪进度企业能发布岗位、筛选简历、更新录用结果管理员负责企业入驻审核和基础数据维护。技术栈选了 Java SpringBoot Vue3 MyBatis MySQL前后端分离开发。这个组合在今天不算花哨但胜在稳妥SpringBoot 负责提供 RESTful APIVue3 负责页面交互MyBatis 负责数据库访问MySQL 存储业务数据。对大多数高校项目、毕业设计或者中小型企业内部系统来说这套组合的学习成本、维护成本和交付速度都处于一个很舒服的平衡点。下面我把整个系统的设计与落地过程拆开讲包括数据库怎么设计、后端分层的坑、前端联调注意什么以及部署时那些搜索引擎里天天有人问的问题。1. 动手开发前我先把需求拆成了三条线1.1 业务场景与核心流程招聘系统的核心业务其实不复杂但角色多、状态多理不清就会做成一锅乱炖。我习惯先把谁在用、干什么、能看见什么画成三条线学生线注册登录 - 维护简历 - 浏览岗位 - 投递 - 查看投递状态待筛选/已通过/已拒绝。企业线注册入驻 - 管理员审核通过 - 发布岗位 - 查看投递者 - 更新投递状态 - 发送面试邀请。管理员线企业审核 - 岗位审核按需- 用户管理 - 数据统计。这三条线一旦定下来安全隐患也基本清楚了。学生和企业不能互相越权比如学生不能创建岗位企业不能给学生账号修改密码管理员则拥有一切权限。后面做登录鉴权和路由守卫时就是围绕这三条线来设计的而不是先写代码再补权限。1.2 为什么选 SpringBoot Vue3 MyBatis 这套组合很多人选型是看别人用什么就用什么我更倾向于从交付角度倒推。这个系统有几个刚性的技术诉求接口繁多且要快速迭代后端需要约定清晰的 RESTful 风格接口SpringBoot 的 Controller 注解体系天然适合。学生端和企业端对页面交互有要求Vue3 的 Composition API 在组织复杂业务逻辑时比 Options API 舒服得多再加上 Vite 的开发服务器热更新很快。招聘场景的查询条件灵活岗位按行业、城市、薪资范围筛选简历按学历、专业筛选这种动态 SQL 场景是 MyBatis 的强项。数据量可控单机 MySQL 完全扛得住没必要引入重型中间件。顺便说一句网上经常有人争论 MyBatis 和 MyBatis-Plus 哪个好。我个人的看法是如果你的项目以简单的单表 CRUD 为主MyBatis-Plus 确实省事但如果查询逻辑复杂、需要精细控制 SQL原生 MyBatis 的 XML 反而更清晰。这套系统我用了原生 MyBatis 加手写分页的方式原因很明显招聘系统的岗位筛选、简历筛选 SQL 逻辑不算少XML 里写动态条件比在 Java 代码里拼字符串要可靠得多。1.3 项目整体架构与目录规划前后端分离的目录结构我按下述方式组织避免前端页面塞进后端工程里导致混乱recruitment-system/ ├── frontend/ # Vue3 Vite 工程 │ ├── src/ │ │ ├── api/ # 接口请求封装 │ │ ├── router/ # 路由配置 │ │ ├── stores/ # Pinia 状态 │ │ ├── views/ # 页面组件 │ │ └── utils/ # 工具函数 ├── backend/ # SpringBoot 工程 │ ├── src/main/java/ │ │ └── com/xxx/recruit/ │ │ ├── controller/ │ │ ├── service/ │ │ ├── mapper/ │ │ ├── entity/ │ │ ├── dto/ │ │ └── config/ │ └── src/main/resources/ │ ├── mapper/ # MyBatis XML 文件 │ └── application.yml └── sql/ # 初始化脚本后端严格分层Controller 只做参数接收和结果包装Service 负责业务逻辑和事务控制Mapper 只负责 SQL 访问。这样做的收益在排错时特别明显某个接口返回异常你能快速判断是参数问题、业务问题还是 SQL 问题而不是在一个几百行的 Controller 里大海捞针。2. 数据库设计简历、岗位、投递的状态流转是核心2.1 用户体系三类角色共用一张表还是分表学生、企业、管理员本质都是用户共用一个sys_user表是合理的用role字段区分类型。企业额外信息单独放company表通过user_id关联学生简历单独放resume表。这样设计的好处是登录认证逻辑只需写一套注册时根据角色决定是否要补充企业资料。sys_user表的核心字段大概这样CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role TINYINT NOT NULL COMMENT 1学生 2企业 3管理员, real_name VARCHAR(50), phone VARCHAR(20), email VARCHAR(100), status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );密码字段必须存加密后的值我用的 BCrypt具体原因后面讲登录鉴权时细说。这里先提醒一句千万不要明文存密码哪怕是内部项目也不行这个习惯一旦养成早晚出事。2.2 岗位表与投递表状态字段怎么设计才不返工岗位表job我建议包含这些关键字段企业 ID、岗位名称、所属行业、工作城市、薪资范围最低值和最高值分开存、学历要求、招聘人数、岗位描述、状态招聘中/已下线、发布时间。薪资范围拆成salary_min和salary_max两个字段而不是存一个8k-15k字符串这样后续做薪资区间筛选时可以直接用 SQL 比较不用解析字符串。投递记录表delivery是整个系统的状态枢纽CREATE TABLE delivery ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, job_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待筛选 1已通过 2已拒绝 3已录用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );这里有一个很容易踩的坑状态字段到底用TINYINT数字还是字符串我见过不少项目用字符串比如PENDING、APPROVED可读性确实好但占空间更大、索引效率更低而且代码里到处都是魔法字符串。数字类型加注释是更务实的选择前端展示时做一层映射即可。另外投递记录要加唯一约束(student_id, job_id)防止学生同一个岗位投两次。这个约束在并发场景下意义重大后面我还会提到。2.3 简历存储富文本还是字段拆分简历是招聘系统的灵魂但它的存储方式经常被低估。我见过最简单粗暴的做法是直接把整个简历塞进一个content字段富文本编辑完存 HTML。这个方案实现最快但后续想按专业筛选、按学历筛选就无从谈起了因为这些信息埋在 HTML 里SQL 没法检索。我的做法是结构化为主富文本为辅。简历表resume包含姓名、性别、出生年份、最高学历、毕业院校、专业、毕业年份、工作年限、期望城市、期望薪资、技能标签、自我评价、附件地址等字段。自我评价这类自由文本用富文本编辑器存入TEXT类型字段而学历、专业、技能这些可筛选字段必须单独列出来。这样岗位筛选简历时一个WHERE就能解决而不是先把 HTML 拽出来用 Java 做内存过滤——后者在数据量稍微上来一点就会卡顿。2.4 索引与外键的取舍招聘系统用得最多的查询是按条件筛岗位和按条件筛简历所以索引策略要围绕这两个高频动作设计job表company_id建普通索引(city, salary_min)联合索引应对城市和薪资筛选。delivery表student_id和job_id分别建索引(student_id, status)联合索引用于学生查看自己的投递列表。resume表user_id唯一索引一条学生记录只对应一份有效简历。外键使用我一直比较克制业务层做逻辑关联就够了物理外键只在数据一致性要求极高的场景才用。原因很简单招聘系统岗位被删除时历史投递记录需要保留用于审计用物理外键ON DELETE CASCADE会把投递记录一并删掉这不是我们想要的行为。逻辑外键配合 Service 层的事务控制反而更灵活。3. 后端开发SpringBoot MyBatis 落地时的关键细节3.1 分层别偷懒Controller 只做编排不写 SQL这是我接手过无数烂项目之后最大的感悟。很多初学者喜欢在 Controller 里直接调 Mapper觉得少写一层 Service 省事。代码量少的时候确实爽但一旦涉及事务、权限、多表联动这种写法就是灾难。标准的写法是这样RestController RequestMapping(/api/job) public class JobController { Autowired private JobService jobService; GetMapping(/list) public Result list(RequestParam Integer page, RequestParam(defaultValue 10) Integer size, RequestParam(required false) String city, RequestParam(required false) Integer salaryMin) { PageResultJobVO page jobService.pageQuery(page, size, city, salaryMin); return Result.ok(page); } }Service 里加Transactional管理事务Mapper 里只写 SQL。这样职责边界清晰Controller 管参数、Service 管业务、Mapper 管数据访问。接口返回统一用Result包装包含code、message、data三个字段前端 Axios 拦截器根据code做统一处理省去每个页面重复写错误提示的废话。3.2 MyBatis 的 XML 与注解怎么选MyBatis 支持注解写 SQL 也支持 XML我个人的规则是单表简单 CRUD 用注解多表关联和动态条件用 XML。比如用户登录查询用注解就够了Select(SELECT * FROM sys_user WHERE username #{username}) SysUser findByUsername(String username);但岗位分页筛选这种带一堆可选条件的场景必须上 XMLselect idpageQuery resultTypecom.xxx.recruit.dto.JobVO SELECT j.*, c.name AS company_name FROM job j LEFT JOIN company c ON j.company_id c.id where if testcity ! null and city ! AND j.city #{city} /if if testsalaryMin ! null AND j.salary_max #{salaryMin} /if if testkeyword ! null and keyword ! AND (j.title LIKE CONCAT(%, #{keyword}, %) OR j.description LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY j.created_at DESC LIMIT #{offset}, #{size} /selectwhere标签会自动处理第一个条件前的AND这是 MyBatis 最实用的特性之一。另外注意LIMIT的offset计算放在 Service 层做offset (page - 1) * size这样 SQL 保持清晰避免在 XML 里写一堆算术。3.3 分页查询我没有用 PageHelper 的原因PageHelper 确实方便一个PageHelper.startPage()就能自动分页。但它在多表查询、嵌套子查询、以及 SQL 里手动写了LIMIT的时候容易出幺蛾子比如 count 语句生成错误或者分页参数串到不该串的查询上。这套系统我选择手动分页SQL 里显式写LIMIT #{offset}, #{size}Service 里再查一次总数。有人会觉得麻烦但手动分页有一个很大的优势你永远知道 SQL 在执行什么。PageHelper 的 SQL 是运行时拼接修改的出了问题黑盒调试非常痛苦。手动分页的代价无非是多写一个SELECT COUNT(*)换来的是确定性和可控性在压力不大、数据量几十万以内的系统里完全足够。3.4 两个容易翻车的 MyBatis 细节TypeHandler 与缓存网上搜 MyBatis 面试题高频出现的就是TypeHandler和缓存。实际开发中 TypeHandler 最典型的应用场景是枚举与字段类型的互转。比如投递状态我用TINYINT存Java 里对应一个枚举可以写一个DeliveryStatusHandler继承BaseTypeHandlerDeliveryStatus在查询结果映射时自动把数字转成枚举。不过说实话如果你的枚举不多、转换逻辑不复杂直接在 Service 层做getByCode()转换也够了TypeHandler 适合那种全项目统一处理的场景没必要为了用而用。MyBatis 的一级缓存是 SqlSession 级别的默认开启二级缓存默认关闭。实际开发中我建议直接关闭二级缓存特别是多表关联查询时一张表更新了但另一张表的缓存没失效查出来的数据是脏的。招聘系统的岗位和投递状态变化频繁缓存失效带来的心智负担远大于性能收益。数据库层面走 MySQL 的查询缓存或者后面引入 Redis都比 MyBatis 二级缓存可控得多。3.5 登录鉴权JWT 还是 Session招聘系统是前后端分离架构Session 方案需要处理跨域 Cookie 和 CSRF 问题比较啰嗦。我选了 JWT登录成功后后端生成一个 token前端存到localStorage每次请求在 Axios 拦截器里附加Authorization: Bearer token头后端用拦截器校验。流程大概是Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BusinessException(未登录或token已失效); } // 解析token校验签名和过期时间将userId放入request attribute return true; } }然后注册到 WebMvcConfigurer并配置需要拦截的路径。登录接口本身要放行岗位浏览、简历查看这些公开接口按需放行投递、发布岗位、后台管理等接口必须拦。这里有个细节值得注意前端路由守卫和后端拦截器要做双重校验前端守卫只是体验优化真正的安全边界在后端。网上很多项目只在前端判断了一下有没有 token 就以为安全了实际上直接调接口就能绕过这属于必须纠正的认知。密码加密用 BCryptBCryptPasswordEncoder是 Spring Security 里的组件即便你没用完整的安全框架单独引入它是可以的。注册时加密存储登录时matches()校验。BCrypt 每次加密结果都不同但校验有效原因在于它内部自己带了盐这也是为什么它比 MD5 靠谱得多——MD5 加盐还得自己存盐字段容易出错。4. Vue3 前端从零搭页面到前后端联调的完整链路4.1 工程初始化与目录规划前端我用 Vite 初始化 Vue3 项目npm create vitelatest frontend -- --template vue cd frontend npm install npm install vue-router pinia axios element-plusVue3 开发效率上来的核心是Composition API script setup。对比旧写法业务逻辑按功能模块组织而不是按选项拆分比如一个投递列表页把分页、筛选、操作企方法论放一起阅读和维护都更直观。Element Plus 作为组件库表格、表单、弹窗这些后台常用组件开箱即用减少造轮子的时间。4.2 Axios 封装统一处理 token、错误和重复点击Axios 不封装直接能用但几十个页面重复写if (res.code ! 200)会让人崩溃。我的封装思路// src/utils/request.js import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, // 开发走代理生产走nginx timeout: 15000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( res { const data res.data if (data.code 200) return data ElMessage.error(data.message || 请求失败) return Promise.reject(new Error(data.message)) }, err { if (err.response err.response.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(err.message || 网络异常) return Promise.reject(err) } ) export default request这样每个 API 模块只需关心业务数据// src/api/job.js import request from ../utils/request export const getJobList (params) request.get(/job/list, { params })4.3 三个角色的路由与菜单权限路由权限用动态路由 路由守卫实现。登录后拿到用户的role前端维护一个角色到菜单的映射学生看到我的简历、岗位浏览、我的投递企业看到岗位管理、收到的投递管理员看到企业审核、用户管理、数据统计。router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.path /login) { next() return } if (!token) { next(/login) return } if (to.meta.roles !to.meta.roles.includes(Number(role))) { next(/403) return } next() })再次强调前端的菜单隐藏只是用户体验真正的数据权限必须靠后端接口拦截。接口层不做校验学生直接拼一个接口地址就能看到企业后台数据这是低级但常见的漏洞。4.4 简历编辑器的选型学生端简历编辑是最繁琐的页面。我的方案是用 Element Plus 的表单组件做结构化填写自我评价部分嵌入一个轻量级富文本编辑器。富文本组件建议用成熟项目如 wangEditor 或 Quill 的 Vue 封装千万别自己写 contenteditable浏览器的光标行为和各种坑会耗掉你大量时间。简历编辑采用自动保存 手动提交结合表单失焦时保存草稿到本地点击提交时才调接口入库。这样学生编辑到一半关掉页面也不至于全丢。4.5 开发环境的跨域与代理前后端分离开发时前端跑在localhost:5173后端跑在localhost:8080直接请求必然跨域。我推荐在 Vite 里配置代理而不是在后端开 CORS// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })后端也顺带配一个全局 CORS 配置兜底方便独立调试接口工具时使用。生产环境就不存在跨域问题了一般由 Nginx 统一代理前端静态资源和后端接口同域或者反向代理到不同端口。5. 部署上线踩过的坑这些问题搜索引擎里天天有人在问5.1 MySQL 8 连接报 SSL 错误这个问题出现频率极高java.sql.SQLNonTransientConnectionException: Public Key Retrieval is not allowed或者 SSL 握手失败。根因是 MySQL 8 默认开启 SSL而 JDBC 驱动版本和 MySQL 服务端配置不一致。解决方案是在 JDBC URL 里显式禁用 SSL 并允许公钥检索spring: datasource: url: jdbc:mysql://localhost:3306/recruitment?useUnicodetruecharacterEncodingutf8useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver注意serverTimezone一定要配否则本地时区和 MySQL 默认时区不一致会在时间字段上出各种幺蛾子。高版本的 MySQL 驱动已经自带时区处理但显式配置仍是最稳妥的做法。5.2 SpringBoot 版本与 MyBatis 依赖的兼容性网上搜索springboot版本太高的人特别多典型场景是引入了mybatis-spring-boot-starter后启动报错。MyBatis 官方 starter 的版本需要和 SpringBoot 大版本匹配比如 SpringBoot 3.x 对应mybatis-spring-boot-starter3.x 版本SpringBoot 2.7 对应 mybatis starter 2.3 左右。如果你用的是最新版 SpringBoot 3.3然后引入一个老的 mybatis starter 2.x启动时大概率会遇到ClassNotFoundException或者自动配置不生效。我的建议是不要盲目追求最新版本。SpringBoot 2.7 到今天依旧能打结合 MyBatis 2.3.x 生态最稳。如果一定要用 SpringBoot 3.x注意它基于 Jakarta EEjavax.*包要换成jakarta.*很多老博客的例子需要对应调整。5.3 Vue 打包后放进 SpringBoot 的静态资源方案有人喜欢把 Vue 打包后的dist目录复制到 SpringBoot 的static下一个 Jar 包部署省事。这个方案能用但有几个细节必须处理前端路由如果是 history 模式刷新非首页时会在后端找不到对应路径要做路径回退把未知请求转发到index.html。接口前缀和静态资源要区分开避免后端拦截器把静态资源也拦了。后续前端更新需要重新打包替换整个 Jar动静很大。我个人的选择是前端独立部署到 Nginx后端以 API 服务方式启动两者通过 Nginx 反向代理关联。这样前端发版不影响后端后端重启不影响前端职责清晰。5.4 Nginx 部署时的接口代理配置生产环境用 Nginx 做统一入口核心配置如下server { listen 80; server_name yourdomain.com; # 前端静态资源 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } # 后端接口代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里try_files是为了解决 history 模式路由刷新 404 的问题。proxy_pass后面的地址注意不要多加斜杠否则路径拼接会和预期不一致这个细节折磨过不少人。5.5 数据一致性与并发投递的兜底招聘高峰期多个学生同时投递同一个岗位很常见。我在 2.2 节强调过投递表要加(student_id, job_id)唯一约束这不仅是设计规范更是并发兜底。如果只靠 Service 层先SELECT再INSERT两个并发请求可能同时查到未投递然后都执行插入就会产生重复数据。数据库唯一约束是最后一道防线在约束生效后重复插入会抛出DuplicateKeyExceptionService 层捕获后直接返回您已投递过该岗位。另一个一致性问题是简历更新和投递记录的关系。学生修改简历后企业看到的是最新简历所以投递记录表不需要冗余简历内容快照每次查看时联表查最新简历即可。但如果业务要求投递时的简历快照比如企业已在审核的投递不能因为学生改简历而改变内容那就需要把快照存下来或者用版本号机制。这个取舍要提前想清楚否则后面返工成本很高。6. 功能还能怎么扩展从能用走向好用6.1 简历解析与关键词匹配学生简历里的专业技能字段往往是纯文本企业筛选简历时按关键词手动匹配效率很低。可以引入分词工具比如 HanLP 在 SpringBoot 里的简单集成对技能标签做词法分析建一张技能-岗位的映射表实现简历自动匹配岗位时的高亮提示。这个方向上即使只做到把简历中的技能标签提取出来存成结构化字段体验就已经比纯文本强很多。6.2 消息通知投递状态变化的触达投递状态从待筛选变成已通过时学生是不知道的得自己刷新页面。加一个消息通知模块学生投递成功后、企业更新状态后都往消息表插一条记录前端在学生端顶栏做个未读小红点轮询或者用 WebSocket 推送。WebSocket 对大多数招聘系统来说略重先做个简单的轮询加消息表就够用。6.3 数据看板与导出管理员端统计功能是招聘系统的加分项按学院统计投递人数、按行业统计岗位数、按月份统计简历投递趋势。用 MySQL 的GROUP BY配合日期函数就能做不需要引入 BI 工具。另外投递列表导出 Excel 是很多就业办老师的刚需用 EasyExcel 导出把投递记录、学生基础信息、岗位信息三张表拼好导出即可比 POI 手写要省力。一些我后来才想明白的事这套系统从设计到落地前后花了不到一个月的时间但中间踩的坑一点都不少。如果让我重新做一遍有些事一定会改变比如数据库字段的注释要写全比如接口返回结构一开始就统一用Result包装再比如前端 TypeScript 应该早点引入而不是后期再补类型。这些都不是什么惊天动地的技术只是一次次被教训之后沉淀下来的习惯。最后分享一个我觉得最值的经验在做之前先花半天把系统的状态流转表画清楚比如投递状态的每一步谁可以触发、触发后谁应该收到通知。这个半天的投入能省掉你后面反复改表结构、改接口、改前端页面的一周时间。招聘系统的业务逻辑不算深但状态和角色交织在一起一旦理不清代码就会越写越乱。先把这些想明白剩下的就只是按部就班地写代码了。