SpringBoot+Vue3前后端分离:大学生就业招聘系统开发实战复盘
发布时间:2026/9/26 17:54:52 作者:尧图编辑部 阅读量:1,286

用了两周时间我基于 Java SpringBoot Vue3 MyBatis 这套组合把一个前后端分离的大学生就业招聘系统从零搭到了可演示、可交付的状态。系统覆盖了学生求职端、企业招聘端和管理员后台三条业务线数据层全部落在 MySQL 上。这篇文章不是课程设计说明书是我把项目思路、表结构设计、接口实现、前端对接过程中踩过的坑和解决路径全部复盘了一遍。如果你正准备做类似的毕设项目、课设作品或者想拿一套完整业务链路练手前后端分离开发这篇内容应该能帮你少走不少弯路。1. 项目整体设计与需求拆解1.1 大学生就业招聘系统到底要解决什么问题先说业务背景。每年毕业季高校就业办、二级学院辅导员、学生本人三方都在被同一件事折磨岗位信息分散、招聘进度不透明、简历投递结果不可追踪。市面上虽然有智联、BOSS 直聘这类大平台但校内招聘有自己的特殊性——企业进校宣讲需要审核、岗位需要定向推送给对口专业、学生需要学校层面的就业数据统计。所以这套系统的核心不是做一个“小智联”而是围绕学校就业管理场景把学生、企业、管理员三方角色纳入同一套业务流程里。从我的实践来看拆解需求时最容易犯的错是“功能堆砌”——看到别的系统有什么就做什么结果面试官一问业务闭环答不上来。我最终把核心需求收敛为三条线学生端注册登录、浏览职位、投递简历、查看投递反馈、维护个人简历。企业端注册登录、发布职位、查看收到的简历、更新面试通知或录用结果。管理端学生/企业账号审核、职位信息审核、基础数据统计职位数、投递数、行业分布等。这三条线合起来就是一个完整的业务环企业发布职位 → 学生投递简历 → 企业处理简历 → 学生收到反馈 → 管理端全程可见可管。环闭合了系统才有真实使用价值。1.2 前后端分离架构的选型思考这套系统用的是前后端分离架构前端 Vue3 负责页面渲染和交互后端 SpringBoot 负责业务逻辑和数据读写双方通过 JSON 格式的 RESTful API 通信。为什么不用传统的服务端渲染模板比如 Thymeleaf JSP两个原因第一业务复杂度决定了页面交互密度。简历编辑器、职位筛选列表、企业端简历卡片操作这些用组件化开发维护起来比模板引擎舒服得多Vue3 的 Composition API 在组织这类逻辑时尤其顺手。第二前后端分离是当前实际项目的标准形态。既然做这套系统的目的之一是练手真实项目能力那技术形态就应该向工业界看齐而不是停留在课程设计的老路子上。关于接口通信我用的是 Axios请求统一走/api前缀后端通过server.servlet.context-path统一处理跨域和接口前缀问题。后端只返回数据不掺和任何页面渲染逻辑这在调试时非常省心——前端报错只需要看接口返回的 JSON 结构定位问题快很多。1.3 角色的权限模型设计三方角色的权限控制是我早期忽略、后期返工最多的部分。最初图省事只在 Controller 里随手写判断结果越到后面越乱。后来重构为基于拦截器 注解的方案用户登录成功后生成 token 存入 Redis也可以用 JWT 自带状态前端每次请求在 Header 里携带 token后端拦截器统一解析用户身份并写入 ThreadLocal业务层通过自定义注解RequireRole控制访问权限。具体划分是这样学生端接口/api/student/**企业端接口/api/company/**管理端接口/api/admin/**接口路径隔离 注解权限校验双重保障。我踩过的教训是仅靠路径约定不够因为 Controller 层可能因为复制粘贴出现一个学生接口放在企业路径下。注解校验是兜底两者必须同时存在。2. 核心技术栈与关键依赖选型2.1 后端 SpringBoot 的版本与依赖组合SpringBoot 版本我用了 2.7.x为什么不用 3.x说实话3.x 已经成熟了但当时考虑的是 MyBatis 相关生态的兼容性。如果你自己动手做用 3.x 也没问题但一定要确认配套依赖都是适配 Jakarta EE 的版本不然javax和jakarta的包名冲突会让人非常崩溃。我的核心依赖清单大致是dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency这里重点说下 MyBatis 的分页插件 PageHelper。很多新手用分页时会在业务代码里手动拼接 LIMIT这在小项目里能跑但一旦列表接口变多、条件变复杂每个接口都手写 LIMIT 很容易出错。PageHelper 的好处是只写业务查询不管分页逻辑——它通过拦截器在执行 SQL 前自动改写为带 LIMIT 的查询并执行一条 COUNT 查询返回总条数。用法非常简单PageHelper.startPage(pageNum, pageSize); ListJobVO list jobMapper.selectJobList(query); PageInfoJobVO pageInfo new PageInfo(list);这里有一个特别容易踩的坑PageHelper.startPage()之后必须紧跟第一次查询中间不能插入其他 SQL 操作否则分页会作用到错误的 SQL 上。我调试过一个诡异现象——列表页数据量不对查了半天发现是 startPage 和查询之间隔了一次日志写入操作分页被打乱了。2.2 前端 Vue3 的工程化配置前端我用 Vite 搭建工程选择 Vite 而不是 Vue CLI 的原因很实在Vite 冷启动速度快到飞起开发体验好得多。组件库选了 Element Plus因为它是 Vue3 生态里最成熟的中后台组件库——表格、表单、弹窗、分页组件都是现成的能极大缩短页面开发时间。Vue3 项目的核心依赖大致是{ dependencies: { vue: ^3.4.0, vue-router: ^4.0.0, pinia: ^2.0.0, axios: ^1.6.0, element-plus: ^2.5.0 } }状态管理用的 Pinia 而不是 Vuex原因就一条Pinia 的 API 设计更符合 Composition API 的直觉不需要 mutations 那层模板代码写起来少一半样板。前端工程结构我按业务模块划分而不是按技术类型划分src/ ├── api/ # 接口请求封装 ├── views/ │ ├── student/ # 学生端页面 │ ├── company/ # 企业端页面 │ └── admin/ # 管理端页面 ├── components/ # 通用组件 └── stores/ # Pinia 状态这样划分的好处是后续加需求时能直接定位到对应目录而不是在 views 里一锅炖。2.3 MySQL 数据库的版本选择与配置注意点数据库用的 MySQL 8.0。8.0 相比 5.7 有几个关键优势默认字符集是 utf8mb4支持窗口函数、公用表表达式CTE、原子 DDL 操作。对于这个系统来说utf8mb4 尤其关键——用户简历里可能填写各种生僻字和表情符号utf8mb3也就是常说的 utf8存不了表情字符会直接报Incorrect string value错误。连接配置上有一个细节要注意——时区问题。MySQL 8.0 默认时区是服务器时区而 Java 侧的serverTimezone如果不显式指定很容易出现时间数据相差 8 小时的情况。我统一的配置是spring: datasource: url: jdbc:mysql://localhost:3306/job_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseuseSSLfalse是因为本地开发和课程演示场景下不需要加密连接加上可以减少连接时的握手耗时。3. 数据库设计与表结构规划3.1 核心表的设计思路数据库设计是这套系统里花时间最多、返工成本最高的环节。我第一版设计图省事只建了 5 张表结果开发到企业端的时候发现无法支撑业务需求后面花了整整一个晚上重构。第二版设计遵循“一角色一主表 业务关系表”的原则最终稳定为以下核心表t_user用户总表存放登录账号、密码、角色标识student/company/admin、状态待审核/正常/禁用。t_student_profile学生信息扩展表关联用户表存放姓名、学校、专业、学历、毕业年份、联系方式等。t_company_profile企业信息扩展表关联用户表存放企业名称、行业、规模、简介、营业执照等。t_job职位表关联企业存放职位名称、岗位类别、城市、薪资范围、学历要求、职位描述、状态。t_resume简历表关联学生存放简历的各个模块内容。t_delivery投递记录表关联职位和学生状态流转为待处理 → 已查看 → 面试邀请 → 已录用/已淘汰。为什么要分t_user和t_student_profile、t_company_profile因为不同角色的属性差异太大。如果所有字段塞进一张用户表会有大片的稀疏列——学生不需要企业规模企业不需要毕业院校。拆开后扩展性更好以后加一个“导师”角色只需要新建一张扩展表用户表不用动。3.2 职位表与投递表的关键字段设计职位表t_job我设计了几个值得说明的字段salary_min和salary_max薪资范围用两个整数存储而不是一个字符串。原因很简单范围查询比如筛选 8k~12k用字符串完全没法做两个整数字段才能支持高效的数值比较。status职位状态用tinyint存储0表示草稿、1表示发布中、2表示已下架。为什么不用字符串tinyint比较性能更好而且字段占用空间小。publish_time发布时间用于列表的按时间排序。投递表t_delivery是业务闭环的关键设计时要保证同一学生不能重复投递同一职位。我在建表时直接加了联合唯一索引ALTER TABLE t_delivery ADD UNIQUE KEY uk_student_job (student_id, job_id);有了这层数据库约束即使前端按钮没做禁用、后端接口也没判重数据库也能兜住重复投递的问题。我的原则是凡是不能重复的业务数据一定在数据库层面建唯一索引不能只靠应用层判断。3.3 数据库索引设计踩过的坑这块儿我想展开说。项目刚上线内部测试时职位列表接口在数据量 500 条左右时响应时间差不多 40ms看起来还行。但模拟到 3 万条数据后接口直接飙到 2 秒多。EXPLAIN一看全表扫描根本没走到索引。优化措施分了三步第一所有外键字段都建索引。t_job.company_id、t_delivery.student_id、t_delivery.job_id这些高频关联查询字段必须有索引。第二列表查询的排序字段建索引。职位列表默认按publish_time倒序所以加了(status, publish_time)复合索引。注意顺序很重要——MySQL 复合索引遵循最左前缀原则status要放在前面因为查询条件里status 1是等值匹配然后才是排序字段。第三分页深度问题。LIMIT 100000, 10这种深分页在大数据量下依然慢因为 MySQL 要扫描并丢弃前十万行。解决方式是延迟关联SELECT t.* FROM t_job t INNER JOIN (SELECT id FROM t_job WHERE status 1 ORDER BY publish_time DESC LIMIT 100000, 10) tmp ON t.id tmp.id;子查询先走索引覆盖查询出主键再回表取完整数据性能提升非常明显。实际项目里这个优化后的查询在 50 万数据量级下也能控制在 100ms 内。4. 核心功能模块与后端实现细节4.1 登录认证与 JWT 的落地代码登录是系统的入口这块不做好后面全是窟窿。我采用的是 JWT 无状态认证方案登录成功后签发 token后续请求通过拦截器校验。登录接口的核心逻辑大致如下PostMapping(/login) public Result login(RequestBody LoginDTO dto) { // 1. 验证账号密码 User user userService.login(dto.getUsername(), dto.getPassword()); // 2. 生成 JWT token String token JwtUtil.generateToken(user.getId(), user.getRole()); // 3. 返回前端需要的信息 return Result.ok(new HashMapString, Object() {{ put(token, token); put(role, user.getRole()); put(username, user.getUsername()); }}); }JWT 的生成工具类我是自己封装的核心代码没有想象的复杂public class JwtUtil { private static final String SECRET_KEY your-secret-key; private static final long EXPIRE_TIME 7 * 24 * 60 * 60 * 1000; // 7天 public static String generateToken(Long userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); } }这里有一个安全细节用户在数据库中有一个status字段是否封禁。如果管理员封禁了某个学生但他的 JWT 还没过期他依然能访问接口。解决办法是在拦截器中每次请求时检查用户状态而不是只信任 token。这个坑我是在做管理端的“封禁用户”功能时发现的——封禁后用户居然还能正常调用接口因为拦截器只验了 token 的合法性和角色没查用户当前状态。修复方案很简单拦截器里从 token 取出 userId 后在 Redis 或数据库查一下状态字段即可。为了性能我是登录时把用户状态缓存到 Redis封禁用户时同步删除缓存拦截器只查 Redis 不存在再用数据库兜底。4.2 职位搜索与多条件筛选的实现学生端职位列表是系统最高频的接口支持的关键字搜索、城市筛选、薪资范围筛选、学历筛选、排序方式。这个接口的实现质量直接决定了系统的手感。最开始的实现是业务代码里硬拼 SQL条件一多就乱成一团。后来我换成了 MyBatis 的 XML 文件里用动态 SQL 组织代码清爽太多。核心片段是这样select idselectJobList resultTypecom.example.vo.JobVO SELECT j.*, c.company_name, c.company_industry FROM t_job j LEFT JOIN t_company_profile c ON j.company_id c.id where j.status 1 if testkeyword ! null and keyword ! AND (j.job_name LIKE CONCAT(%, #{keyword}, %) OR c.company_name LIKE CONCAT(%, #{keyword}, %)) /if if testcity ! null and city ! AND j.city #{city} /if if testminSalary ! null AND j.salary_max gt; #{minSalary} /if if testmaxSalary ! null AND j.salary_min lt; #{maxSalary} /if if testeducation ! null and education ! AND j.education_requirement #{education} /if /where ORDER BY choose when testsortType newestj.publish_time DESC/when when testsortType salaryj.salary_max DESC/when otherwisej.publish_time DESC/otherwise /choose /select这里有两个细节值得注意细节一是薪资筛选的逻辑用户选择8k-12k时查询条件应该是job.salary_max 8000 AND job.salary_min 12000。这是一个区间重叠判断不是简单的某个字段等于多少。我见过不少新手写成salary_min 8000 AND salary_max 12000那样查出来的是薪资范围恰好全部落在 8k-12k 内的职位区间有交集但没完全覆盖的职位会被漏掉。细节二是 XML 文件中特殊符号的转义和在 XML 里不能直接写。我最初写完启动直接报错后来才知道必须写成gt;和lt;。如果嫌转义麻烦也可以用 MyBatis 的![CDATA[]]包裹。4.3 学生投递简历的业务时序投递接口是整个系统业务链路的核心事务。学生点击投递简历按钮后后端需要做四件事检验简历是否已存在、检验是否重复投递、创建投递记录、更新职位的投递数量。这个接口我要求自己是一个事务里完成避免中间步骤失败产生脏数据。Transactional(rollbackFor Exception.class) public void deliverResume(Long studentId, Long jobId) { // 1. 校验简历是否存在 Resume resume resumeMapper.selectByStudentId(studentId); if (resume null) { throw new BizException(请先完善个人简历); } // 2. 校验是否重复投递数据库唯一索引兜底 int count deliveryMapper.countByStudentAndJob(studentId, jobId); if (count 0) { throw new BizException(您已投递过该职位请勿重复投递); } // 3. 创建投递记录 Delivery delivery new Delivery(); delivery.setStudentId(studentId); delivery.setJobId(jobId); delivery.setStatus(0); // 0-待处理 delivery.setDeliverTime(new Date()); deliveryMapper.insert(delivery); // 4. 更新职位投递数 jobMapper.increaseDeliveryCount(jobId); }Transactional注解必须指定rollbackFor Exception.class这是我自己踩过的坑——Spring 默认只在遇到 RuntimeException 时回滚而 Java 的检查异常比如IOException不会自动触发回滚。如果不指定rollbackFor事务里抛出一个检查异常数据就悄悄提交了线上出过几次这种幽灵数据。5. 前端 Vue3 关键实现与对接经验5.1 基于 Axios 的请求封装和拦截器前后端分离项目中前端请求层的封装质量直接影响开发效率。我做了三层封装第一层是创建 Axios 实例// api/request.js import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 })第二层是请求拦截器统一带上 tokenrequest.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config })第三层是响应拦截器统一处理错误和状态码request.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } if (res.code 401) { // 登录过期跳转登录页 localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(登录过期)) } ElMessage.error(res.message) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(error.message) return Promise.reject(error) } )这三层封装做完之后业务页面里调用接口非常简洁只需要写具体接口// api/job.js export const getJobList (params) request.get(/job/list, { params }) export const deliverResume (jobId) request.post(/delivery/create, { jobId })一个小技巧我用的是前端开发服务器代理转发解决跨域问题。Vite 的vite.config.js里配一行server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端请求/api/job/list开发环境下会被转发到localhost:8080浏览器层面根本不存在跨域问题。当然部署到生产环境时更推荐用 Nginx 做反向代理统一转发。5.2 学生端简历编辑的组件设计学生简历模块是前端最复杂的页面包含基本信息、教育经历、技能特长、项目经验、实习经历等模块。我的实现方式是把每个模块做成独立子组件父组件负责数据汇总和同步。这里遇到的一个实际问题是简历编辑有多个子组件用户修改后如何同步到全局状态最初用的是组件内emit向上抛事件父组件逐个监听子组件多了以后代码像蜘蛛网。后来改成了 Pinia 统一管理简历数据子组件直接操作 store// stores/resume.js export const useResumeStore defineStore(resume, { state: () ({ resumeData: { basicInfo: {}, educationList: [], skillList: [], projectList: [] } }), actions: { updateBasicInfo(info) { this.resumeData.basicInfo { ...this.resumeData.basicInfo, ...info } } } })子组件里直接store.updateBasicInfo(form)不再需要层层 emit。这个重构让我意识到一个经验多子组件共享数据的状态管理一开始就应该用全局 store不要等到传参传崩溃了再回头改。5.3 企业端投递处理的界面与状态流转企业端的核心界面是收到的简历列表每一条投递记录有三种状态待处理、已查看、面试邀请、已录用、已淘汰。用 Element Plus 的el-tabs加el-table组合按状态筛选展示每行操作按钮根据当前状态动态渲染。状态流转我用了一个简单的状态机来管理防止非法状态跳转。比如已淘汰的记录不能直接变成面试邀请必须从头走流程。这个逻辑在后端接口里也做了校验if (delivery.getStatus() 3) { // 已淘汰 throw new BizException(已淘汰的投递记录无法变更状态); }这种双向校验前端控制按钮 后端校验状态在实际项目中很有必要。前端只是体验优化后端的校验才是真正的防线。只靠前端控制状态跳转别人用 Postman 直接调接口就能绕过这属于基础安全问题。6. 开发链路中的高频问题与避坑实录6.1 MyBatis 查询结果为空的排查套路这套系统开发过程中我遇到最多的一类问题是SQL 在 Navicat 里跑有数据但通过 MyBatis 查询返回 null。排查经验总结成一条固定路径第一步先确认返回类型和数据库字段映射。MyBatis 默认的驼峰映射在实际开发中基本都会开启配置一行即可mybatis: configuration: map-underscore-to-camel-case: true开启了map-underscore-to-camel-case之后数据库的company_name字段才能自动映射到 Java 的companyName属性。我第一次开发时忘了配这个查出来所有 List 的每一行全是 null排查了很久才发现就是这么一个小配置的问题。第二步如果开启驼峰映射还是 null就要检查 SQL 结果集的列别名。多表关联查询时如果两个表有同名字段比如t_job.id和t_company_profile.id都是 id结果集后面的列会覆盖前面的列。解决方式是在 SQL 里为查询字段显式起别名或者用AS明确指定SELECT j.id AS job_id, c.id AS company_id, ...6.2 前后端联调时的参数接收不一致问题前后端分离联调时最常见的矛盾是参数格式不一致。我做学生端登录接口时就踩到一个典型问题前端用 JSON 格式 POST{ username: abc, password: 123456 }后端用RequestParam接收结果所有参数都是 null。原因很简单RequestParam接收的是 form 表单参数application/x-www-form-urlencoded而前端 Axios 默认发送的是 JSONapplication/json。解决方案有二方案一是后端用RequestBody接收 JSONPostMapping(/login) public Result login(RequestBody LoginDTO dto) { ... }方案二是前端改传 form 格式。但 RESTful API 设计习惯上POST 创建资源用 JSON 更加规范。我最终统一为所有 POST 接口走 JSONRequestParam只用在 GET 请求的查询参数上。6.3 跨域问题的完整解决路径前后端分离必然遇到跨域。虽然我前端用了 Vite proxy 在开发环境规避了跨域但部署时前端静态资源如果由 Nginx 服务、后端接口在另一台服务器跨域问题还是会出现。后端统一开启跨域配置我用的是一种比较干净的方式——实现WebMvcConfigurer接口配置全局跨域规则Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里有一个细节allowCredentials(true)时allowedOrigins(*)不能生效浏览器会直接拦截。必须要用allowedOriginPatterns(*)这个坑网上很多文章都没说清。6.4 简历上传和文件存储的简化方案简历模块支持附件上传最初我想用本地磁盘存储简单省事。但本地存储有几个问题服务器重启后路径可能挂掉、多实例部署时文件不共享、备份麻烦。考虑到这是一个教学/毕设级别的项目为了省去搭建对象存储的复杂度我采用了一个折中方案——使用 MySQL 数据库存储文件元信息文件本身存在服务器本地路径写入数据库。虽然不完全优雅但胜在简单课程设计答辩完全够用。如果后续想扩展到生产环境推荐迁移到 MinIO 或者阿里云 OSS 这类对象存储。迁移时只需要把文件存储的工具类接口抽象出来业务代码不用改。这也是我编码时的一个习惯——文件上传一律走一个FileStorageService接口不同实现随时可以替换。6.5 一次典型的“慢 SQL”定位实录系统做完后有一项优化我印象很深。管理端有一个就业数据统计页面需要统计各个学院的职位投递量和录用率。我的第一版 SQL 写成嵌套子查询在本地 2 万条数据下响应 400ms 左右当时觉得还行。但模拟到 10 万条数据后直接超时。用EXPLAIN分析定位到性能瓶颈是一个大表全表扫描后来优化思路是把最耗时的 AGGREGATE 子查询结果提前用GROUP BY去重合并然后在子查询里只查必要的字段减少回表次数。这类问题的通用排查路径是拿到慢 SQL →EXPLAIN查看type字段如果看到ALL全表扫描或ref但rows飙高 → 优先检查索引和查询条件是否走了索引 → 再考虑改写 SQL 结构。MySQL 优化器有时会自动决定不走索引这时候可以用FORCE INDEX强制但这是最后手段一般不推荐。7. 优化部署与后续扩展建议7.1 前后端分离项目的部署方案部署环节我采用的是常见的前后端分离部署架构前端构建出的静态文件由 Nginx 托管后端是 SpringBoot 的 fat JAR 包用java -jar启动通过 Nginx 反向代理/api路径到后端服务。前端构建非常简单npm run build构建完成后dist目录下的文件直接拷贝到服务器 Nginx 的 html 目录。Nginx 的关键配置片段server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /var/www/job-system; 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; } }注意location /api/里的proxy_pass http://127.0.0.1:8080/末尾带了斜杠作用是去掉前缀再转发——前端请求/api/job/listNginx 会转发成/job/list给后端。这样后端接口不用统一加/api前缀两边的职责边界更清晰。还有try_files $uri $uri/ /index.html;这一行非常关键。Vue Router 如果用的是 history 模式刷新某个页面路径时 Nginx 如果找不到对应的静态文件会返回 404这行配置能让所有未匹配路径都回退到 index.html交给前端路由处理。7.2 系统的测试数据与演示准备经验如果你和我一样做这套系统是为了课程设计或毕业设计答辩那么数据层面的准备一定不能忽略。空数据库跑起来的系统看起来太假答辩效果大打折扣。我的做法是写了一个DataInitializer组件在应用启动时自动生成演示数据30 个学生账号带完整的简历数据15 家企业账号覆盖互联网、金融、教育、制造等行业200 条职位数据分布在多个城市500 条左右的投递记录覆盖所有状态流转。为了让演示数据看起来真实公司名和职位我参考了真实的招聘网站风格。城市、薪资、学历这些字段保持一定的随机性这样列表页的筛选功能演示起来才有说服力。还有一个容易被忽略的小技巧给每个账号设置一个直观的密码比如统一123456答辩时现场登录不慌乱。教师账号、企业账号、学生账号各准备一个分别演示三个端的功能。7.3 从课程设计到生产级系统的差异在哪很多同学做完这套系统会问如果我要把它变成简历上的真实项目还需要补什么我的建议优先级是第一引入 Redis 缓存热点数据。职位列表的高频查询完全可以缓存减少数据库压力。做法是查询前先查缓存缓存没有回源数据库并设置 5-10 分钟的过期时间。第二把文件存储从本地迁移到对象存储。上面提过封装好FileStorageService接口后这步的成本可控。第三补充更细致的日志和监控。至少做到每个接口有入参出参日志、异常有堆栈日志、接口响应时间超阈值打印慢请求日志。线上问题排查没有日志等于盲人摸象。第四补充单元测试和接口测试。重点覆盖投递、审核这类核心业务逻辑。这步虽然不直接产生可见功能但在面试中聊测试意识和代码质量会比只聊功能好得多。8. 写在最后的实操感想这套系统从确定技术方案到跑通全部流程我实际花了大约三周业余时间。回头复盘最大的感悟是做这种项目型的系统前期数据库设计和接口协议设计花的时间越长后期开发越省力气。我第一次建表只花了两个小时就开写代码结果后面为字段缺失、关联关系不当返工的工时数倍于此。第二版认真梳理业务关系后开发效率肉眼可见提升。另外一个小建议一定要保留好每个阶段的调试笔记。这套系统开发过程中有一本工作日志记录了我遇到的各种问题和解决路径包括刚才提到的 PageHelper 分页错乱、MySQL 时区问题、XML 特殊符号转义、跨域配置的allowedOriginPatterns坑。面试时被问到“项目中遇到最大的挑战是什么”直接翻笔记总结一两个真实的排查案例比背八股文有力得多。如果你打算拿这个项目去面试有几个追问方向提前准备一下MyBatis 的一级缓存和二级缓存有什么区别、什么场景下要禁用缓存JWT 和传统 Session 认证各自的优缺点前后端分离项目的跨域方案有哪些MySQL 索引失效的典型场景。这些不是八股而是这套系统里真正用得上的底层理解。个人建议的下一步扩展把职位推荐功能做成基于学生专业和投递历史的关键词匹配推荐不用上什么高深的机器学习模型简单的标签权重计算就能做出效果很好的推荐结果。这个扩展既能展现业务思考能力又能展示算法基础比堆功能的有意义得多。