拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+Vue+MyBatis+MySQL招聘系统开发实战

SpringBoot+Vue+MyBatis+MySQL招聘系统开发实战

今年年初,朋友公司的人事还在用共享表格加微信群收简历,HR 每天要从几十条聊天记录里翻候选人,我实在看不下去,花了两个周末做了一套招聘系统管理后台。技术选型就是标题里那套:SpringBoot 做接口,Vue 做页面,MyBatis 管 SQL,MySQL 存数据。跑通以后,职位发布、简历投递、HR 筛选、约面记录全部能在系统里闭环,后来我把这套源码整理成了一个可复用的版本。如果你正好需要做类似的内部招聘后台、毕业设计,或者想拿一套 SpringBoot + Vue 的真实项目练手,这篇文章应该能帮你少走很多弯路。

我用的版本是 Spring Boot 3.2.2 + JDK 17、Vue 3.4 + Vite 5、MySQL 8.0.36、MyBatis 的 spring-boot-starter 3.0.3。这套组合放到 2025 年依然算主流配置,不是把老项目换个皮。下面我不会只贴代码,而是把每一步为什么这么做、上线时容易在哪翻车一起讲清楚。

1. 招聘系统这种内部后台,核心价值到底在哪

很多人一听招聘系统就觉得是做拉勾、BOSS直聘那种平台,其实大部分公司真正要的是"内部的招聘工作台":HR 不用再翻聊天记录,求职者也不用把简历发到邮箱然后石沉大海。核心业务主线就一条:职位发布 → 求职者投递简历 → HR 筛选 → 约面/反馈 → 结果归档。

围绕这条主线,功能可以拆成这样:

模块使用角色核心功能
职位管理HR、管理员职位发布、上下架、编辑、复制
职位前台求职者职位搜索、分类筛选、职位详情
简历管理求职者在线简历填写、附件简历上传
投递管理HR、求职者投递、状态更新、投递记录
面试管理HR约面时间、面试反馈、结果录入
用户与权限管理员用户管理、角色划分、账号启停

我一开始也差点陷入"功能越多越好"的误区,后来发现招聘后台最怕的是状态混乱。比如同一个候选人可能被 HR 的同事重复联系,同一个岗位投递记录没有唯一约束,候选人被淘汰后也不知道去哪看结果。所以设计第一版时,我建议你先别加招聘流程引擎、消息推送、BI 报表,先把职位、简历、投递、面试这四个核心实体的表结构做扎实。

还有一点容易被忽略:招聘系统的用户身份是天然多角色的。求职者要看岗位和投递状态,HR 要处理简历,管理员要管账号和职位审核。前后端分离的项目里,如果只在按钮上控制显示隐藏,后端接口不校验权限,那跟裸奔没区别。后面我会专门讲 JWT 拦截器怎么和角色权限配合。

这套系统适合谁参考?如果你是刚学完 SpringBoot 和 Vue 想做完整项目,它比图书管理系统复杂,但又不至于像电商系统一样堆满中间件;如果你是在公司里维护内部 HR 工具,这套表结构和接口设计也能直接改改拿来用。核心代码量不大,真正花时间的其实是业务状态设计和部署排错。

2. 技术栈的选择不是凑热门,而是每一项都有它的位置

标题里的 SpringBoot、Vue、MyBatis、MySQL 四个词,单独看都是老面孔,组合在一起能成为招聘系统最稳妥的方案,不是没有原因的。

2.1 后端选型:SpringBoot 解决的是"快速起服务"和"生态兼容"

如果不用 SpringBoot,用原生 Servlet + JDBC 写招聘系统也能跑,但你会把大量时间花在配置连接池、事务管理、JSON 序列化、拦截器注册这些基建上。SpringBoot 的价值在于约定优于配置:一个main方法就能启动内嵌 Tomcat,spring-boot-starter-web把 Web MVC、Jackson、内嵌容器都带好了,spring-boot-starter-validation管参数校验,spring-boot-starter-jdbc配合 MyBatis starter 把数据源和事务管好。

我选的是 Spring Boot 3.2.x,它要求 JDK 17 起步。如果你还在用 JDK 8,严格来说也能跑旧版,但 2025 年新项目确实没必要再守着 javax 命名空间。Spring Boot 3 里把javax.servlet换成了jakarta.servlet,拦截器、Filter、WebMvcConfigurer 的导入包名全部要跟着变,这个后面部署章节我会提到,是新手最容易踩的坑。

2.2 前端选型:Vue3 不是简单升级,是组件组织和响应式体验更好

Vue2 当年很流行,但 Vue3 的组合式 API 对招聘系统这种"列表详情 + 表单流程"的 CRUD 项目相当友好。你不需要记一堆data/computed/methods分散的选项,一个<script setup>里把业务逻辑按功能聚在一起,可读性高很多。

配合 Vite 之后,开发环境的启动速度和热更新比 Webpack 爽太多。我在本地跑招聘系统时,改一个职位列表组件,刷新基本是秒开。前端样式我用的是 Element Plus,为什么选它不选 Ant Design Vue?没有绝对好坏,招聘后台的表格、表单、弹窗、分页这些组件 Element Plus 够用,中文文档全,出问题搜起来也快。关键是别把组件库当万能解药,业务状态和权限控制才是你需要手写的地方。

2.3 数据库选型:MySQL 8.0 在中小型系统里依然是最务实的选择

招聘系统的数据量不会大到需要上分库分表,MySQL 8.0 的 InnoDB 支持事务、行级锁、外键(虽然我不建议多用外键)、通用表表达式、窗口函数,索引能力也够用。8.0 默认字符集是 utf8mb4,能直接存 emoji,简历里出现表情符号也不会报存储错误。

MySQL 8.0 和 5.7 最大的区别之一是新版本默认认证插件是caching_sha2_password,连接字符串配置不对就会出现 SSL 或公钥检索错误,这个我放到部署坑里细说。数据库选型不是越贵越好,而是看团队维护成本和业务增长预期。招聘系统跑在单库单表上完全没问题,将来真的并发高了,先加 Redis 缓存热点职位列表,再加读写分离,都比一开始上微服务靠谱。

3. 表结构设计:先把"职位、简历、投递、面试"这四件事想清楚

招聘系统的数据库设计,重点不在字段多,而在关系清晰、状态可追踪。我第一版设计时把所有附件、投递记录、面试反馈都塞进一张大表,后来改得很难受。拆成下面几张表以后,整个项目一下子清爽了。

3.1 用户与角色:不要用两个表分别存求职者和HR

很多新手会把求职者放candidate表,HR 放admin表,注册登录各写一套。其实招聘系统里这两类人都是"系统用户",只是角色不同。我用一张t_user表加role字段区分,再加一张t_resume存求职者的简历扩展信息。

CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', nickname VARCHAR(50), phone VARCHAR(20), email VARCHAR(100), role TINYINT NOT NULL COMMENT '1管理员 2HR 3求职者', status TINYINT DEFAULT 1 COMMENT '1正常 0禁用', create_time DATETIME, update_time DATETIME ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

密码一定不要存明文或 MD5。我在项目里用 Spring Security 的BCryptPasswordEncoder只做加密工具,没有引入完整 Security 拦截链,因为招聘系统接口鉴权用 JWT 拦截器就够了。BCrypt 每次哈希结果不同,但matches能验证,即使数据库泄露,用户密码也相对安全。

3.2 职位表与简历表:职位要有上下架状态,简历要留附件入口

职位表是招聘系统的核心,字段要覆盖 HR 发布岗位时最常填的信息:职位名称、公司名称、城市、薪资范围、经验要求、学历要求、职位描述、上下架状态。薪资我用两个整数salary_min和salary_max,比存字符串好排序和筛选。

CREATE TABLE t_position ( id BIGINT PRIMARY KEY AUTO_INCREMENT, company_id BIGINT NOT NULL COMMENT '发布职位的企业ID', company_name VARCHAR(100) NOT NULL, title VARCHAR(100) NOT NULL COMMENT '职位名称', category VARCHAR(50) COMMENT '职位分类', city VARCHAR(50) COMMENT '工作城市', salary_min INT, salary_max INT, experience_required VARCHAR(20), education_required VARCHAR(20), description TEXT COMMENT '职位描述', status TINYINT DEFAULT 1 COMMENT '1发布 0下架', view_count INT DEFAULT 0, create_time DATETIME, update_time DATETIME, KEY idx_status_city_time (status, city, update_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='职位表';

简历表和用户表是一对一关系,所以user_id要加唯一约束。在线简历字段包含姓名、手机、邮箱、学历、工作年限、个人介绍,外加大字段file_path存附件简历地址。这里我的建议是:附件地址不要直接存文件名,而是存一个 UUID 重命名后的相对路径,防止用户上传非法路径或重名覆盖。

3.3 投递与面试的流转状态:通过状态字段把业务串起来

投递记录表是求职者和职位之间的桥梁,也是 HR 处理简历的工作台。状态字段我用 TINYINT:0 待筛选、1 HR已查看、2 已约面试、3 已通过、4 已淘汰。面试单独建一张表,记录面试时间、面试官、反馈内容,投递表通过delivery_id关联。

CREATE TABLE t_delivery ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '求职者用户ID', position_id BIGINT NOT NULL COMMENT '职位ID', status TINYINT DEFAULT 0, create_time DATETIME, update_time DATETIME, UNIQUE KEY uk_user_position (user_id, position_id), KEY idx_position_status (position_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='投递记录表';

为什么要给(user_id, position_id)加唯一约束?因为真实业务里同一个求职者不能反复投同一个职位。很多项目不去重,结果 HR 在工作台里看到的全是重复投递,体验非常差。如果业务上允许"淘汰后重新投递",可以把唯一键去掉,改成在代码里先查是否已有 status 为 0/1/2 的记录,有则提示"您已投递过该职位"。

3.4 索引设计:先看查询条件,再决定加哪些索引

招聘系统最频繁的查询是职位分页列表,条件通常是status = 1加上city、category、keyword的模糊匹配,排序按update_time desc。所以我在职位表建了(status, city, update_time)联合索引,status作为等值条件放最左边,city作为可选条件,update_time用来排序。如果city没传,索引退化为(status, update_time)也比只建单列索引强。

简历表或投递表涉及高频查询:HR 查某个职位下的投递列表,求职者查自己的投递记录。所以delivery表两个联合索引可以拆成:user_id + create_time(求职者查看)和position_id + status(HR 工作台)。索引不是越多越好,招聘系统这种规模最多再加一张t_interview表,不要对着每列都加索引,否则写入性能会下降。

面试表字段大致是:delivery_id唯一、interview_time、interviewer、feedback、result、create_time。面试反馈是长文本,别放到投递表里,否则投递列表查询会变重。

4. 后端接口落地:MyBatis的XML和SpringBoot的Controller是怎么配合的

表结构确定后,后端开发就顺了。我这里重点说三块:工程结构、登录鉴权、Mapper 写法。

4.1 项目工程结构:按业务模块分包,不按技术类型分包

很多人喜欢建controller、service、mapper三个顶层包,项目小的时候还行,招聘系统稍微加几个模块就会越塞越乱。我习惯按模块分包:controller、service、mapper在外层,里面再按auth、position、resume、delivery区分。这样改职位功能时,不会在几十个文件里来回找。

pom.xml核心依赖其实很少:

<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency>

application.yml里最重要的三个配置:数据源、MyBatis 映射、日志。注意 MyBatis 的map-underscore-to-camel-case要打开,不然表字段create_time映射不到 Java 的createTime属性,你会莫名多出一堆 null。

spring: datasource: url: jdbc:mysql://localhost:3306/recruit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/**/*.xml type-aliases-package: com.demo.recruit.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

log-impl这个配置强烈建议开发环境打开,每条 SQL 都会打印到控制台。你不需要再去猜 MyBatis 执行了什么,直接能看到参数和返回行数。等上线前再改成Slf4jImpl或关掉,避免日志刷屏。

4.2 登录鉴权:JWT 签发和拦截器放行规则

登录接口的逻辑不复杂:先按用户名查用户,再用BCryptPasswordEncoder.matches比对密码,比对通过后生成 JWT 返回。生成 JWT 时我把userId和role放进去,后续接口从 token 里取当前用户身份,不需要每次都查数据库。

我用的 JWT 生成方式是基于 JJWT 0.11.5,签名密钥在配置文件里设置,至少 32 字节。核心拦截器逻辑是:从请求头拿token,解析成功就放到ThreadLocal里,解析失败直接返回 401。

复用度高一点的做法是写一个RequireRole注解,标注在 Controller 方法上,拦截器里读取角色。HR 接口必须要求 role 为 2,管理员接口要求 role 为 1。为什么不用每个接口手动判断?因为容易漏,漏一处就是越权漏洞。招聘系统里这些接口尤其要保护:更新投递状态、查看所有简历、用户管理。

放行规则也要注意:/api/auth/login和/api/position/list必须放行,但职位投递、简历保存这些接口必须登录。很多项目把静态资源也拦截了,导致前端页面加载不出来,所以拦截器里excludePathPatterns要把/static/**、/upload/**、/api/auth/login配好。

4.3 职位分页查询:MyBatis XML 里的动态 SQL 是核心

职位列表查询是典型的多条件分页,MyBatis 的 XML 比在 Java 里拼接 SQL 舒服得多。我没有引入 PageHelper 依赖,而是自己算offset,因为招聘系统并发不高,自己控制 SQL 更直观,也避免 PageHelper 和 SpringBoot 3 版本兼容的麻烦。

先写 count 查询再写列表查询:

<select id="countPositions" resultType="int"> select count(*) from t_position <where> status = 1 <if test="keyword != null and keyword != ''"> and (title like concat('%', #{keyword}, '%') or company_name like concat('%', #{keyword}, '%')) </if> <if test="city != null and city != ''"> and city = #{city} </if> </where> </select> <select id="pagePositions" resultType="com.demo.recruit.entity.Position"> select id, company_name, title, city, salary_min, salary_max, education_required, experience_required, update_time from t_position <where> status = 1 <if test="keyword != null and keyword != ''"> and (title like concat('%', #{keyword}, '%') or company_name like concat('%', #{keyword}, '%')) </if> <if test="city != null and city != ''"> and city = #{city} </if> </where> order by update_time desc limit #{offset}, #{pageSize} </select>

为什么用 MySQL 的concat('%', #{keyword}, '%')而不是直接写'%${keyword}%'?因为${}是字符串替换,存在 SQL 注入风险,#{}是预编译参数,安全得多。like模糊查询无法完全走索引,这也是正常的,招聘系统数据量级完全可以接受。列表查询不要把description大字段查出来,职位列表只需要展示标题、薪资、城市、更新时间,详情接口再查完整描述,这个优化很简单但很多人会忘。

Service 层做分页组装时,offset这样算:int offset = (pageNum - 1) * pageSize。如果前端传的pageNum是 0 或负数,后端要兜底成 1,不然 SQL 会出现奇怪的默认值。

4.4 投递简历时如何避免重复投递

投递接口是最能体现"业务细节"的地方。我一开始只写了insert into t_delivery,结果测试时同一用户反复点提交,产生了三条待筛选记录。后来加了两层保护:第一层是数据库唯一约束兜底,第二层是代码里先查。

Delivery existing = deliveryMapper.findActiveByUserAndPosition(userId, positionId); if (existing != null) { return Result.fail("您已投递过该职位,请勿重复提交"); }

这里代码层面的"查再插"严格说有并发漏洞:两个请求同时进来,都查不到记录,然后都插入成功。如果没有唯一约束,数据库拦不住。所以最稳妥的还是靠t_delivery表的uk_user_position唯一索引,插入时捕获DuplicateKeyException,再返回友好提示。这也是为什么我反复强调表结构设计要先想清楚,后端接口写起来才不会整天补漏洞。

投递成功以后,HR 的工作台查询就是关联t_delivery、t_position、t_user三张表,字段不多,用 MyBatis 的嵌套查询或 join 都行。我建议直接 join,因为一次查询带回候选人姓名、职位标题、投递状态,HR 一天打开工作台几十次,别让他等太多次接口。

5. Vue3前端不是套一层Element Plus就完事

前端部分我会把环境、路由、状态管理、页面链路讲一遍。很多新手在 Vue 上栽跟头不是因为组件不会写,而是路由守卫、axios 拦截、接口返回结构这三件事没想清楚。

5.1 开发环境:Node 版本、项目初始化、依赖安装

Vite 5 要求 Node 18 以上。如果你本机 Node 版本过低,npm create vite可能直接报错,或者装完依赖启动不了。先node -v确认版本,我建议 20 LTS。

建项目我用:

npm create vite@latest recruit-web -- --template vue cd recruit-web npm install npm install vue-router@4 pinia element-plus axios

Vite 开发时的接口代理很关键。招聘系统后端跑在 8080,前端跑在 5173,直接请求/api会跨域。我在vite.config.js里加代理:

server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

这样前端代码里请求路径写成/api/position/list,开发环境代理到后端,生产环境打包后也可以直接放在 SpringBoot 的static目录下,不需要改接口路径。

5.2 路由守卫和axios拦截:登录态统一在这里处理

招聘系统的前端页面分成三类:公开页面(职位列表、职位详情)、求职者页面(简历管理、我的投递)、HR/管理员页面(投递工作台、用户管理)。如果在每个页面里手动判断 token,代码会散得到处都是。

我用vue-router的全局前置守卫统一做:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) return } if (to.meta.role && !checkRole(to.meta.role)) { next('/403') return } next() })

axios拦截器要做两件事:请求时自动带上token请求头;响应时如果状态码是 401,说明 token 过期或未登录,清掉本地存储并跳转登录页。后端接口我统一返回{ code, message, data }结构,前端响应拦截器里只处理code === 0的情况,其他情况弹message。这个统一处理一定不要省,不然每个页面都要写错误处理。

5.3 从职位列表到投递成功的完整链路里,前端最容易错的是什么

职位列表页用<el-card>加v-for渲染,点击卡片跳转详情页。详情页从路由参数拿职位 ID,调用/api/position/{id}。投递按钮点击时先检查登录态,如果没有 token,跳转登录页并把当前职位 ID 放在 query 里;登录成功后跳回详情页,用户再点一次投递。这个交互细节要专门处理,不然用户登录完就丢失了原来的浏览位置。

我的做法是在登录页保存redirect参数:

const redirect = route.query.redirect || '/' loginSuccess(() => router.push(redirect))

列表页的筛选条件用reactive对象存,每次点击搜索或分页时重新请求接口。这里注意:分页组件的当前页要同步到 URL query 里,不然用户刷新后回到第一页,体验很差。招聘系统业务简单,不需要把筛选条件复杂化,但分页和搜索条件的联动是质感的来源。

前端还有一个容易翻车的地方:响应式的ref和reactive混用。我的习惯是"简单值用 ref,对象用 reactive",同时注意数组更新时最好整体赋值,不要用下标改,否则 Vue3 的响应式在某些场景下不会触发更新。实际开发中,const list = ref([]),拿到接口结果后直接list.value = result,最省心。

6. 部署上线阶段最容易翻车的几个地方

招聘系统本地跑通不算完,真正麻烦的是部署。我帮朋友部署时踩了好几个坑,这里挑最典型的讲。

6.1 Vue打包后如何交给SpringBoot:历史路由刷新404问题

最简单的部署方案是:npm run build把前端打包成dist目录,然后把dist里的内容拷贝到后端src/main/resources/static下,再mvn clean package打成 jar。这样前端页面和后端接口跑在同一个端口,不需要额外部署 Nginx。

但有个大坑:Vue Router 默认用 history 模式,路由是/position/3、/hr/delivery这种伪路径。用户访问首页没问题,但直接刷新/position/3时,SpringBoot 不会把请求转发到index.html,而是去找名为position/3的静态资源,结果 404。

解决办法是让 SpringBoot 把这些前端路由转发到index.html:

@Controller public class SpaForwardController { @RequestMapping(value = {"/", "/position/**", "/hr/**", "/admin/**"}) public String forward() { return "forward:/index.html"; } }

注意/api/**不能包含在这个转发规则里,否则后端接口会被错误转发到前端页面。

如果你不想前后端混着部署,也可以前端打包后丢到 Nginx,后端单独跑 jar。两种方案都可以,招聘系统并发量不大,我推荐先用"拷贝到 static"的方式,部署成本最低,不用配 Nginx 的try_files重写规则。

6.2 MySQL连接的时区、SSL、认证插件问题

MySQL 8.0 连接字符串如果缺少参数,启动阶段可能报Public Key Retrieval is not allowed,连接阶段可能报 SSL 连接错误。我在application.yml里那串连接参数不是随便加的:

  • useSSL=false:本地开发没有配置 SSL 证书,不需要加密连接。如果数据库服务器要求 SSL,再单独配置证书,不要简单关掉。
  • allowPublicKeyRetrieval=true:MySQL 8.0 默认caching_sha2_password认证,首次连接时需要从服务器获取公钥,不开会直接报错。
  • serverTimezone=Asia/Shanghai:避免日期时间差 8 小时。这个参数不是只影响显示,还会影响 MyBatis 从结果集取DATETIME字段的时区转换。
  • characterEncoding=utf8:虽然 MySQL 8.0 默认 utf8mb4,但连接层还是明确指定字符集更稳。

MySQL 安装后如果启动失败,大多数是权限或数据目录问题,不是应用代码问题。用客户端工具连不上时,先mysql -u root -p命令行试一下,能区分是 MySQL 服务问题还是连接参数问题。

6.3 CORS跨域和拦截器放行规则:本地通过、上线 401 的典型原因

开发环境用 Vite 代理后,前端和后端同源,没有跨域问题。但如果你把前端单独部署在 Nginx、后端单独部署在另一台服务器,一定要处理跨域。后端加一个全局 CORS 配置:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

这里有个细节:跨域请求会先发一个OPTIONS预检请求。如果你的 JWT 拦截器把所有请求都拦截了,包括OPTIONS,前端就会看到"请求被拦截"而不是"未登录"。所以拦截器里要把OPTIONS请求直接放行,或者在拦截器代码里判断请求方法为OPTIONS时直接返回 true。

如果部署后上传文件跨域失败,大概率也是 CORS 配置里没有允许对应请求头,或者上传接口用了multipart/form-data,需要检查allowedHeaders是否包含了文件流需要的请求头。

7. 附件上传、缓存和并发这几个容易被忽视的点

招聘系统功能简单,但有几个地方容易被忽略,等上线后被用户吐槽才回头补。

7.1 简历附件是存本地还是MinIO

简历附件最常见的做法是存服务器本地目录。我用一个配置项upload.path指定目录,上传时用 UUID 重命名文件,再把相对路径存到t_resume.file_path。SpringBoot 还要配置静态资源映射,让前端能通过/upload/xxx.pdf访问:

@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${upload.path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath + "/"); } }

如果你部署到云服务器,本地目录要考虑磁盘扩容和备份;如果未来会部署多个后端实例,本地目录就不行了,需要用 MinIO 或云对象存储。SpringBoot 集成 MinIO 不复杂,加io.minio:minio依赖,用MinioClient上传文件,返回对象存储地址。MinIO 的好处是自带管理界面、支持桶权限控制、部署成本低,适合招聘系统这种中小规模附件存储。

我实际测试下来,本地存储方案在小项目里完全够用,但要注意两点:一是上传时限制文件类型和大小,简历只允许 pdf/doc/docx,最大 5MB,防止有人传病毒或超大文件把磁盘塞满;二是文件名一定要重命名,不支持中文和空格,避免 URL 解析问题。

7.2 MyBatis缓存:默认一级缓存够用,二级缓存别乱开

招聘系统这种 CRUD 项目,MyBatis 的一级缓存是 SqlSession 级别的,同一个 SqlSession 内重复查询会命中缓存。但 Spring 管理的 mapper 调用,每次可能都新建 SqlSession,所以一级缓存对跨方法查询帮助有限。

二级缓存是 mapper namespace 级别的,开启后一个 mapper 的查询结果可以被多个 SqlSession 共享。听起来很香,但实际使用时要特别注意:只要该 mapper 对应的表发生 insert/update/delete,二级缓存就会失效刷新。招聘系统的投递状态、职位上下架随时在变,开二级缓存容易读到旧数据,还要处理多节点部署时的缓存一致性问题。我的结论是:小项目不要开二级缓存,把数据库查询写好、索引建对,性能足够。等真的出现热点职位列表频繁访问,优先用 Redis 做业务级缓存,由代码主动控制失效,比 MyBatis 二级缓存可控得多。

7.3 简单防刷和敏感信息处理

投递接口如果不做任何限制,有人写个脚本可以疯狂刷简历。最简单的手段是在前端按钮加 loading 防重复点击,但这不是安全措施。后端可以做两件事:一是基于 JWT 里的 userId,限制同一用户每分钟最多投递 5 次;二是如果做了短信通知,再对接短信平台的频率限制。招聘系统不需要很重的风控,但至少要把明显漏洞堵住。

敏感信息上,密码用 BCrypt 加密,返回用户信息时不要把密码字段序列化出去,可以在实体类加@JsonIgnore,或者在 VO 层只返回需要的字段。简历里的手机号、邮箱对 HR 可见,但求职者之间不能互相查看,这个权限要在接口层面控制,不要只靠前端隐藏。

招聘系统做到这一步,已经是一个能真实使用的内部系统了。后续如果你还想加功能,我会优先建议做面试日历提醒和招聘数据统计,它们都是在现有表结构上扩展,不会把架构推倒重来。

返回列表