每到毕业季,我都会收到几条类似的私信:学长,学校里那个招聘网站太难用了,我想自己写一个校园招聘求职系统,能不能给点建议?问的人多了,我索性用一个完整的 Spring Boot + Vue 项目来回答。这个项目不是普通的 CRUD 堆功能,而是尽量贴近真实校招流程——学生传简历、搜岗位、投递、看状态,企业发职位、筛简历、约面试,管理员做审核和数据统计。整套系统跑通之后,既能用来写毕业设计,也能作为前端或后端岗位的求职作品。
用 Spring Boot + Vue 做这套系统,最大的好处是前后端完全分离,一个团队里前端同学写 Vue,后端同学写 Java,接口定好就能并行开发。而且这两个框架的生态都非常成熟,从权限框架到数据库连接池,从 UI 组件库到 HTTP 请求库,都有现成方案可用,不需要自己造轮子。这篇文章我尽量把从设计到部署的完整过程讲清楚,包括表结构、状态机设计、权限模型、跨域处理、Nginx 部署等关键点,适合有一定 Java 或 Vue 基础、想完整走一遍全栈项目的读者参考。
1. 先想清楚:校园招聘系统和通用招聘网站到底差在哪
很多第一次做这类项目的同学,上来就开始建表写代码,做到一半发现问题层出不穷。我自己最早的教训也是这样——把一个校园招聘系统做成了 BOSS 直聘的极简版,最后发现学生端、企业端、管理员端的诉求完全对不上。
校园招聘系统和通用招聘平台有几个明显的差异,这些差异直接决定技术设计。
用户群体非常固定。学生端基本就是在校生,企业端是来校招的 HR 或部门负责人,管理员就是学院的就业指导老师或系统运维人员。三类角色的权限边界必须非常清晰,学生看不到企业后台,企业看不到学生管理界面,管理员对两类用户都要有审核和管理能力。
投递关系是一对多的精确匹配。一个学生可以投递多家企业,但同一岗位只能投递一次。企业收到简历后,需要筛选、标记通过或淘汰,学生能实时看到状态变更。这个流程非常像状态机——待筛选、已查看、面试邀约、已录用、已淘汰,每一步都牵扯到学生端和企业端两边的数据一致性。
数据有很强的时效性。校园招聘的岗位通常标注招聘截止时间,过期自动下架;企业宣讲会信息、校级双选会公告,都要有明确的开始和结束时间。系统设计时需要在接口层做时间校验,不能光靠前端按钮控制。
简历格式相对统一。校园招聘的简历通常围绕教育背景、实习经历、项目经历、技能特长、自我评价这几块,不像社会招聘那样百花齐放。所以系统里可以提供一个在线简历编辑器,学生填写结构化信息,企业端按统一模板查看,这样比上传 PDF 附件更容易做数据解析和搜索。
也就是说,这套系统的核心,不是把“发布职位-投递简历”做成两个列表,而是要设计一整套契合校招场景的数据结构和状态流转逻辑。想清楚这一点之后,再谈技术选型才有的放矢。
2. 技术选型复盘:Spring Boot + Vue 这套组合是被需求推着走的
这个项目用 Spring Boot + Vue,不是什么赶时髦,而是这套组合在处理此类业务时确实顺手。
2.1 后端为什么是 Spring Boot
Spring Boot 的优势在于“约定大于配置”。以前用 SSM 写一个项目,XML 配置文件能写几百行,数据源、事务、MyBatis 映射都要手动装配;Spring Boot 把这些都自动化了,一个启动类加几行配置就能把 Web 服务跑起来。
拿这个校园招聘系统来说,后端需要做的事情包括:
- 用户注册登录、会话管理(JWT 或 Session)
- 学生端简历的增删改查、投递记录管理
- 企业端职位的发布、下架、修改,简历筛选
- 管理员端的用户审核、职位审核、数据统计
- 文件上传(学生传头像、企业传 Logo)
这些功能 Spring Boot 都有非常成熟的生态支持。Spring Security 或 Sa-Token 做权限控制,MyBatis-Plus 做数据库操作,Hutool 处理验证码和字符串工具,Redis 做缓存和验证码存储。即便你之前没接触过 Spring Boot,照着一个标准项目骨架学下来,两三天就能上手开发。
2.2 前端为什么是 Vue
Vue 在这类管理系统里几乎是事实标准。它的响应式数据绑定让我们维护“职位列表-筛选条件-分页状态”这类联动数据非常轻松;组件化开发可以把简历编辑器、职位卡片、状态标签拆成独立组件,复用度很高。
Vue 生态里的 Element Plus 组件库,对中后台管理界面的覆盖非常完整,表格、表单、日期选择器、上传组件都有现成的。做校园招聘系统时,企业端的职位管理表格、学生端的投递记录时间线,用 Element Plus 能省掉大量样式工作,而且视觉效果不差。
Vue Router 做前端路由控制也很关键。我们可以给不同角色配置不同的路由表——学生登录后只能访问学生端页面,企业用户访问学生端路由时直接重定向或拦截。这个逻辑用 Vue Router 的全局前置守卫实现非常优雅。
2.3 为什么选 MySQL + Redis 组合
数据存储上我选了 MySQL 做主数据库,Redis 做缓存和辅助存储。MySQL 不用多说,表结构清晰,事务支持可靠,校招业务数据量远没到需要分库分表的程度。Redis 在这个系统里承担了三个职责:
- 存储登录验证码和 JWT Token,设置过期时间自动清理
- 缓存职位分类、学校列表这类几乎不变的数据
- 记录学生投递行为的接口计数,防止并发重复请求
用 Redis 做接口级防重是我在这个项目里比较得意的一个设计。学生点击“投递简历”按钮,后端先去 Redis 检查这个学生和这个职位组合的 key 是否存在,不存在才允许执行插入操作,然后设置 key 的过期时间。这样即使用户连点两次按钮,第二次请求也会在 Redis 层被拦下,避免产生重复投递记录。
3. 数据库建模:三张核心表和一张会踩坑的中间表
表结构设计决定了整个系统写起来顺不顺手。我把核心表理成三块:用户与角色相关、企业职位相关、投递流程相关。
3.1 用户体系:一份用户表还是三份?
很多初学同学会设计三张用户表:student、company、admin,各存各的字段。这样做前端容易理解,但后端写统一登录接口时会很痛苦——你得先判断账号是哪类用户,再去查对应的表。
我的做法是一张用户主表,用 role 字段区分 STUDENT、COMPANY、ADMIN 三种角色,再扩展出学生信息表和企业信息表存各自的详细信息。用户主表存账号、密码(BCrypt 加密)、角色、状态、注册时间;学生扩展表存姓名、学号、学历、专业、毕业年份等;企业扩展表存公司名称、统一社会信用代码、行业、规模、简介等。
这样的好处有三个。第一,登录接口只需要查一张表,通过 role 字段决定返回给前端的菜单和跳转地址。第二,管理员审核用户时,只需要修改 user 表的 status 字段,不用关心这个用户是学生还是企业。第三,后续如果新增角色(比如辅导员),不需要改表结构,加一个枚举值就行。
3.2 简历表:为什么必须跟用户表分开
简历不要直接嵌套在用户表里,原因是简历本身有版本概念。一个学生在大三下学期和大四上学期投递简历,内容肯定不一样。如果简历直接存在 user 表,每次修改都会覆盖旧内容,无法追溯。
我单独建了 resume 表,关联 student_id,核心字段包括:教育经历、实习经历、项目经历、技能标签、自我评价、期望薪资、期望城市。学生修改简历时走更新逻辑,但每次投递时系统会生成一条简历快照,存到投递记录表里。
这里说下为什么做简历快照。设想一个场景:学生在 9 月份投递了 A 公司,11 月份修改了简历,12 月份 A 公司打开简历时看到的是新版本。如果 A 公司觉得新版本的某个项目经历不错,想再考核一下,但学生觉得那是旧简历里的内容,这就产生了分歧。所以投递记录里一定要保存简历内容或简历引用的快照,保证企业看到的简历始终是学生投递那一刻的内容。
3.3 核心中间表:投递记录表的字段设计
投递记录表是连接学生和企业的纽带,也是这个系统最容易出问题的地方。字段至少包括:投递编号、学生 ID、职位 ID、简历快照(或简历 ID + 版本号)、投递时间、状态、面试时间、企业备注、学生备注。
状态字段我推荐用整数枚举:0 待筛选、1 已查看、2 面试邀约、3 已录用、4 已淘汰、5 已撤回。这里要注意,状态流转必须校验合法性。学生撤回投递只能发生在“待筛选”阶段,企业如果已经查看了简历,学生就不能再撤回了;企业发出面试邀约后,学生可以选择接受或拒绝,不能直接跳到录用状态。这些规则写在服务层,而不是前端控制。
为了避免“投递记录表无限膨胀”的问题,我加了索引优化查询:
- (student_id, status) 联合索引,加速学生端投递列表查询
- (position_id, status) 联合索引,加速企业端筛选简历查询
- create_time 单列索引,支持按时间排序
其实早期版本我没建联合索引,数据量到两万条左右时,学生端的投递记录列表接口就开始变慢,加了索引之后响应时间从 800 毫秒降到了 50 毫秒以内。这也是一个真实经验——小项目也要有索引意识。
4. 学生端实现:简历、检索、投递状态流转这三个部分最费功夫
学生端是这个系统的使用频率最高的角色,功能设计好了,整个项目体验就成功一半。
4.1 简历编辑器的核心设计
我在简历编辑器上花了不少精力。以前我做过一个版本,把所有经历都存成 Text 长文本字段,学生填起来像写作文,企业端看起来像在看 PDF。后来改成结构化 JSON 存储——教育经历存学校、专业、起止时间、学历四字段;实习经历存公司、岗位、起止时间、工作内容、收获;项目经历存项目名称、角色、时间段、项目描述。
前端表单用动态组件渲染,后端用 JSON 字段存储,MySQL 5.7 以上版本支持 JSON 类型,查询时可以走 JSON_EXTRACT 函数提取字段。这套设计的好处是学生填写方便,企业端查看时能自动渲染成排版清晰的预览模式。
需要注意一点:富文本内容如果是用户输入的,一定要做 HTML 转义或过滤。如果学生在技能特长里填了<script>alert('xss')</script>,你原样存储并渲染,企业端访问简历时就会弹窗。我用的方案是后端接入了 JSoup 过滤器,把危险标签直接剔除掉。
4.2 职位搜索:索引和缓存怎么配合
职位列表页是学生端最核心的入口。我的实现方案是:职位主表存原始字段,通过 MySQL 的 LIKE 查询做关键词匹配,配合分页返回,适合数据量在十万以内的场景。如果数据量再大,就要上 Elasticsearch 了,但对校招系统来说没有必要。
职位搜索接口用到了 Redis 缓存。职位分类、工作城市、学历要求这几个筛选条件是高频访问但低频变更的数据,我设置 30 分钟缓存。职位列表本身不走 Redis,因为变化频率太高,而且要结合用户的分页参数和筛选条件,缓存命中率很低,反而会因为缓存穿透问题拖慢速度。
加了一个小的功能:搜索关键词自动补全。用 Vue 的远程搜索组件,输入前几个字就向后端请求热门搜索词,后端从 Redis 的有序集合里取热度前十的关键词返回。这个功能不复杂,但对用户体验的提升非常明显,也容易被答辩老师看重。
4.3 投递状态机:代码里怎么写流程校验
投递状态流转是实现中最容易出 bug 的部分。我写了一个枚举类 DeliveryStatus,把状态流转规则直接固化在代码里:
public enum DeliveryStatus { PENDING(0, "待筛选") { @Override public Set<Integer> allowedTransitions() { return new HashSet<>(Arrays.asList(1, 2, 4, 5)); } }, REVIEWED(1, "已查看") { @Override public Set<Integer> allowedTransitions() { return new HashSet<>(Arrays.asList(2, 3, 4)); } }, INTERVIEW(2, "面试邀约") { @Override public Set<Integer> allowedTransitions() { return new HashSet<>(Arrays.asList(3, 4)); } }, HIRED(3, "已录用") { @Override public Set<Integer> allowedTransitions() { return new HashSet<>(Arrays.asList()); } }, REJECTED(4, "已淘汰") { @Override public Set<Integer> allowedTransitions() { return new HashSet<>(Arrays.asList()); } }, WITHDRAWN(5, "已撤回") { @Override public Set<Integer> allowedTransitions() { return new HashSet<>(Arrays.asList()); } }; }学生撤回投递、企业更新状态之前,都先调用 checkTransition(from, to) 方法做合法性校验。这个设计在代码可读性和后期维护上非常友好——面试官问起来,你能清晰地解释状态机的设计思路。
5. 企业端实现:职位管理、简历筛选和面试流程
企业端虽然使用人数没有学生端多,但操作逻辑更复杂,因为一个 HR 要同时管理多个职位和几十份简历。
5.1 职位发布的设计:状态和截止时间双保险
企业发布职位时,需要填职位名称、岗位类别、招聘人数、薪资范围、学历要求、工作城市、职位描述、截止时间。这里有一个细节容易忽略——数据库里除了 status 字段控制上架/下架,还要在查询时判断截止时间是否过期。
我的做法是,企业端的职位列表查询 SQL 里加上条件AND (deadline IS NULL OR deadline > NOW()),后端服务层再做一个定时任务,每小时扫描一次职位表,把已过期的职位自动置为下架状态。双保险保证学生在前端不会看到过期职位。
职位审核是管理员端的能力。新发布的职位默认状态是“待审核”,管理员审核通过后才能变为“招聘中”。这个机制在校招系统里非常有用,避免企业乱发招聘信息。
5.2 简历筛选:标签化数据带来的搜索红利
因为简历是结构化存储的,企业端的简历筛选就能做得很细。按毕业院校筛选、按专业关键词筛选、按技能标签筛选、按学历筛选,后端通过 JSON_EXTRACT 或关联表查询都能实现。
我设计了一个简单的匹配度算法:企业发布职位时填了 3-5 个技能标签,系统把学生简历里的技能标签做一次交集计算,匹配 2 个以上的排在前面。算法很简单,就是用 Java 的 Set 求交集,但实际效果很好——HR 打开简历列表时,优先处理最匹配的候选人。
5.3 面试邀约:站内消息和邮件通知双通道
企业向学生发出面试邀约后,系统需要同时做两件事:第一,更新投递记录状态为“面试邀约”;第二,向学生推送站内消息。如果是校级系统,还可以配置邮件通知,通过 JavaMail 发送一封标准格式的面试邀请邮件。
站内消息表的设计也不难,message 表记录 sender_id、receiver_id、title、content、is_read、create_time。学生端轮询未读消息数量,有变化时在导航栏上显示红点。实时性要求高的场景可以用 WebSocket,但校园招聘场景轮询就够了,没必要增加服务器负担。
6. 管理后台、安全设计与权限体系:最容易忽略也最容易出事
很多全栈项目把重心放在学生端和企业端,管理后台草草做几个列表就完了。但真正常被答辩老师问住的,恰恰是权限设计是否严谨。
6.1 认证方式:JWT 还是 Session?
这个项目我用的是 JWT(JSON Web Token)。流程是用户登录成功后,后端生成一个包含用户 ID、角色、过期时间的 Token 返回前端;前端存储在 localStorage,每次请求在 Authorization 请求头里带过去;后端通过拦截器解析 Token,识别用户身份和角色。
用 JWT 的好处是服务端无状态,不需要像 Session 那样在服务器内存里保存会话数据,分布式部署时也方便。但要注意两个安全问题:
- Token 有效期不能太长,我设置的 24 小时,用户重新登录后旧 Token 失效
- 前端把 Token 明文存在 localStorage 有被 XSS 窃取的风险,严格来说应该用 HttpOnly Cookie 存储,但那样做前后端跨域配置会麻烦一些。我的妥协方案是:用户敏感操作(修改密码、撤回投递)时要求重新输入密码,避免 Token 泄露后的恶意操作
6.2 权限拦截:后端必须做,不能只靠前端
前端路由守卫拦截是体验层面的,后端接口必须有权限校验才能保证安全。我的做法是在 Spring Boot 里写一个拦截器,根据请求 URL 前缀匹配角色权限:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().startsWith("/api/auth")) { return true; } // 校验 Token String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { response.setStatus(401); return false; } // 解析 Token,存入 ThreadLocal LoginUser user = JwtUtil.parseToken(token); if (user == null) { response.setStatus(401); return false; } // 管理员接口校验 if (request.getRequestURI().startsWith("/api/admin") && !"ADMIN".equals(user.getRole())) { response.setStatus(403); return false; } UserContext.set(user); return true; } }这样即使前端被绕过,用户直接通过 Postman 调用接口,鉴权依然有效。这是一个被追问概率极高的知识点,务必在项目里写清楚。
6.3 数据权限:企业只能看自己的数据,学生只能看自己的简历
权限控制除了角色,还要做数据级隔离。企业端查询简历列表时必须带上 company_id 条件,查询职位列表同理;学生端查询自己的投递记录时,接口里通过 Token 解析出的用户 ID 直接作为查询条件,不接受前端传进来的 student_id。
我见过不少项目在这里偷懒——前端把 userId 作为参数传给后端,后端不校验直接查询。这样做很危险,用户把参数改成别人的 ID 就能看到别人的数据。正确的做法是后端永远从 Token 里拿当前登录用户信息,前端传的参数最多作为辅助条件。
7. 部署上线:从本地 Demo 到 Nginx 线上环境的完整迁移
项目在本机跑通了,部署上线是另外一回事。这个系统的部署方案我分成三步:后端打包、前端构建、Nginx 反向代理。
7.1 后端打包踩过的坑
Spring Boot 项目用 Maven 打包成 JAR,然后通过java -jar启动,这个流程大家都会。我第一次部署时踩了一个坑:配置文件里的数据库地址写的是 localhost,服务器上一跑直接报连接拒绝。
这个问题的根源是配置文件没有区分环境。解决方案是用 Spring Boot 的多环境配置机制,application-dev.yml 写本地环境,application-prod.yml 写服务器环境,打包时用--spring.profiles.active=prod指定。数据库密码等敏感信息用环境变量注入,而不是写死在配置文件里。
7.2 Vue 项目构建和路由模式问题
前端项目打包是npm run build,生成 dist 目录,里面是编译后的静态文件。把 dist 的内容复制到服务器 Nginx 的 www 目录下,理论上就完成了前端部署。
但有一个大坑:Vue Router 默认是 hash 模式,URL 里会带着 # 号,不美观;改成 history 模式后,用户在浏览器里直接访问/company/positions会 404,因为 Nginx 找不到这个路径对应的物理文件。
解决办法是在 Nginx 配置里加上 try_files 指令:
location / { root /var/www/dist; index index.html; try_files $uri $uri/ /index.html; }这个配置的意思是,先找真实的文件路径,找不到就回退到 index.html,由 Vue Router 接管后续的路由解析。
7.3 反向代理:解决跨域和环境切换
前端开发环境访问后端接口时有跨域问题,部署到生产环境可以用 Nginx 反向代理解决。我的做法是配置一个/api/前缀的转发规则,把请求转发到本地的后端服务端口。
location /api/ { rewrite ^/api/(.*)$ /$1 break; 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; }这样前端只需要配置/api作为请求前缀,部署时不用改任何代码。生产环境的静态资源和 API 请求走同一个域名,天然规避了跨域问题。
7.4 Nginx 上部署多个项目
如果你的服务器上同时部署了多个 Web 项目,比如一个校园招聘系统、一个学校官网、一个博客系统,Nginx 的 server_name 加端口号或域名区分即可。我的习惯是给每个项目单独写一个 conf 文件,放在 /etc/nginx/conf.d/ 目录下,用端口或子路径区分,方便管理和隔离。
# 校园招聘系统 server { listen 8081; server_name localhost; root /var/www/recruit/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:9090; } }管理后台如果也要单独部署,再监听一个端口就行,配置思路完全一样。
8. 踩坑实录:三个让我加班到深夜的问题
最后分享几个我在开发过程中真正踩过的坑,希望你能绕开。
8.1 投递记录重复插入,接口防重没做好
有一次测试同学快速双击“投递简历”按钮,数据库里出现了两条一模一样的投递记录。原因很简单——前端按钮的 loading 状态控制不到位,第一个请求还没返回,第二个请求已经发出去了。
我做了三层防护。前端按钮加 loading 状态;后端接口加上基于 Redis 的幂等性校验;数据库层再给 (student_id, position_id) 加上联合唯一索引。三层一起才能保证绝对不可能出现重复数据。
幂等性校验的核心代码大致如下:
Boolean hasKey = redisTemplate.hasKey("delivery:" + studentId + ":" + positionId); if (hasKey != null && hasKey) { throw new BusinessException("请勿重复投递"); } redisTemplate.opsForValue().set("delivery:" + studentId + ":" + positionId, "1", 2, TimeUnit.SECONDS);这个做法在演示时也能体现对并发问题的思考,是加分项。
8.2 文件上传大小限制
上传头像和公司 Logo 时,默认的 Spring Boot 上传限制是 1MB,超过就报 MaxUploadSizeExceededException。在校招系统里,学生上传头像、企业上传营业执照图片,1MB 经常不够用。我在 application.yml 里调整了配置。
spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB同时要在 Nginx 层设置 client_max_body_size,否则 Nginx 会在请求到达后端之前就把大文件拦截掉。这个双层面配置的问题,我记得很清楚,因为第一次部署线上环境时就漏了 Nginx 这一层。
8.3 Element Plus 表格分页组件的数据格式兼容
前端使用 Element Plus 的 el-pagination 组件时,分页参数是 current-page 和 page-size,而后端返回的数据格式一般是{ records: [], total: 100 }。第一次对接时,我把 total 写错了字段名,结果分页组件的总页数一直显示 0。
后来我统一封装了一个分页返回类 PageResult,包含 records、total、current、size 四个字段,并且所有后端接口都返回这个对象,前端也统一封装了请求方法。这样对接接口时基本上不用翻文档,减少了联调成本。
9. 做完这套系统后,我给出的几点实在建议
如果你打算自己动手做一个校园招聘求职系统,以下几点是我个人比较深的体会。
第一,不要纠结于炫技。这个项目的重点是把业务逻辑理清楚,尤其是状态流转和权限控制。这两个部分做好了,整个系统的质量就有了底线。
第二,代码规范带来的收益远超想象。类名、方法名、变量名要有明确含义,接口返回结构要统一,实体类不要直接当作返回对象往外传,而是用 VO 或 DTO 做层转换。我早期图省事直接返回 Entity,后来前端需要加一个字段,后端就要改实体类,连带数据库表结构都受影响。用 VO 隔离之后,前后端联调顺畅多了。
第三,前期花点时间设计好数据库表结构,中期会省很多事。我见过不少项目做到一半要加“简历状态”字段,结果在五六张表里各加一列,改得昏天黑地。好的表设计要预留扩展空间,但又不能过度设计。
第四,多想想真实场景。这个项目不是用来炫技的——学生的需求是简历能存、岗位能投、状态能查,企业的需求是职位能发、简历能筛、面试能约,管理端的需求是审核用户、审核职位、看数据统计。把这三类诉求理解透了,功能设计自然而然就有了章节结构。
第五,答辩或面试展示时,多讲你的思考过程,不要只是演示功能。比如哪里可能并发有问题、哪里要防止用户乱传参数、为什么状态机要这样设计,这些才是真正体现能力的地方。