
每到毕业季我都会收到几条类似的私信学长学校里那个招聘网站太难用了我想自己写一个校园招聘求职系统能不能给点建议问的人多了我索性用一个完整的 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 BootSpring Boot 的优势在于“约定大于配置”。以前用 SSM 写一个项目XML 配置文件能写几百行数据源、事务、MyBatis 映射都要手动装配Spring Boot 把这些都自动化了一个启动类加几行配置就能把 Web 服务跑起来。拿这个校园招聘系统来说后端需要做的事情包括用户注册登录、会话管理JWT 或 Session学生端简历的增删改查、投递记录管理企业端职位的发布、下架、修改简历筛选管理员端的用户审核、职位审核、数据统计文件上传学生传头像、企业传 Logo这些功能 Spring Boot 都有非常成熟的生态支持。Spring Security 或 Sa-Token 做权限控制MyBatis-Plus 做数据库操作Hutool 处理验证码和字符串工具Redis 做缓存和验证码存储。即便你之前没接触过 Spring Boot照着一个标准项目骨架学下来两三天就能上手开发。2.2 前端为什么是 VueVue 在这类管理系统里几乎是事实标准。它的响应式数据绑定让我们维护“职位列表-筛选条件-分页状态”这类联动数据非常轻松组件化开发可以把简历编辑器、职位卡片、状态标签拆成独立组件复用度很高。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 转义或过滤。如果学生在技能特长里填了scriptalert(xss)/script你原样存储并渲染企业端访问简历时就会弹窗。我用的方案是后端接入了 JSoup 过滤器把危险标签直接剔除掉。4.2 职位搜索索引和缓存怎么配合职位列表页是学生端最核心的入口。我的实现方案是职位主表存原始字段通过 MySQL 的 LIKE 查询做关键词匹配配合分页返回适合数据量在十万以内的场景。如果数据量再大就要上 Elasticsearch 了但对校招系统来说没有必要。职位搜索接口用到了 Redis 缓存。职位分类、工作城市、学历要求这几个筛选条件是高频访问但低频变更的数据我设置 30 分钟缓存。职位列表本身不走 Redis因为变化频率太高而且要结合用户的分页参数和筛选条件缓存命中率很低反而会因为缓存穿透问题拖慢速度。加了一个小的功能搜索关键词自动补全。用 Vue 的远程搜索组件输入前几个字就向后端请求热门搜索词后端从 Redis 的有序集合里取热度前十的关键词返回。这个功能不复杂但对用户体验的提升非常明显也容易被答辩老师看重。4.3 投递状态机代码里怎么写流程校验投递状态流转是实现中最容易出 bug 的部分。我写了一个枚举类 DeliveryStatus把状态流转规则直接固化在代码里public enum DeliveryStatus { PENDING(0, 待筛选) { Override public SetInteger allowedTransitions() { return new HashSet(Arrays.asList(1, 2, 4, 5)); } }, REVIEWED(1, 已查看) { Override public SetInteger allowedTransitions() { return new HashSet(Arrays.asList(2, 3, 4)); } }, INTERVIEW(2, 面试邀约) { Override public SetInteger allowedTransitions() { return new HashSet(Arrays.asList(3, 4)); } }, HIRED(3, 已录用) { Override public SetInteger allowedTransitions() { return new HashSet(Arrays.asList()); } }, REJECTED(4, 已淘汰) { Override public SetInteger allowedTransitions() { return new HashSet(Arrays.asList()); } }, WITHDRAWN(5, 已撤回) { Override public SetInteger 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这个项目我用的是 JWTJSON 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.activeprod指定。数据库密码等敏感信息用环境变量注入而不是写死在配置文件里。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 隔离之后前后端联调顺畅多了。第三前期花点时间设计好数据库表结构中期会省很多事。我见过不少项目做到一半要加“简历状态”字段结果在五六张表里各加一列改得昏天黑地。好的表设计要预留扩展空间但又不能过度设计。第四多想想真实场景。这个项目不是用来炫技的——学生的需求是简历能存、岗位能投、状态能查企业的需求是职位能发、简历能筛、面试能约管理端的需求是审核用户、审核职位、看数据统计。把这三类诉求理解透了功能设计自然而然就有了章节结构。第五答辩或面试展示时多讲你的思考过程不要只是演示功能。比如哪里可能并发有问题、哪里要防止用户乱传参数、为什么状态机要这样设计这些才是真正体现能力的地方。