
每年毕业季办公室最热闹的业务系统就是就业管理。岗位信息要汇总、投递记录要跟踪、企业数据要审核、简历要反复筛选靠着Excel和微信群来回倒腾信息一乱就全乱了。所以当我决定自己动手写一套Web就业管理系统时心里很清楚前端用Vue后端用SpringBoot数据落在MySQL持久层交给MyBatis一套全栈代码完全自己把控。这套系统做下来前后花了三个完整周末从数据库设计到接口联调再到前端页面打磨目前已经能稳定跑起来。这篇文章就把整个项目从需求拆解、表结构设计、后端接口实现、前端页面搭建到部署避坑完整记录下来适合正在做Java全栈入门项目的人、准备大作业/答辩的在校学生以及在公司内部想搭建内部招聘工具但又不想买商业系统的开发同学参考。1. 项目定位与整体设计思路1.1 就业管理这个场景到底需要什么先别急着写代码得把业务理顺。一个就业管理系统表面上看就是“发布岗位、投简历、看状态”真放到实际场景里会发现角色完全是三套脾气。学生要的是“找得到、投得出去、查得到结果”所以系统必须能按岗位名称、企业名称、工作城市去检索投递之后能看到记录和状态简历要能上传和编辑。企业端HR要的是“发岗位、筛简历、约面试”所以要有企业资质信息、岗位管理、收到简历列表、对投递记录做状态流转已投递、已查看、邀约面试、不合适。管理员端则是统筹全局审核企业注册信息、上/下架违规岗位、查看整体投递数据、管理用户状态。我把系统角色定成三种学生student、企业company、管理员admin。对应的功能模块就清楚分成了前台招聘大厅面向学生、企业后台面向HR、管理后台面向运营人员。这个设计思路基本沿用了几乎所有主流B端招聘产品的套路不用发明创造照着成熟的模式划分即可。1.2 技术栈选型为什么是这几个而不是别的技术选型是这类项目最先被问到的点也是答辩时老师最爱追问的地方。我最终定下来的是SpringBoot 2.7 MyBatis MySQL 8 Vue 2.x配合Element UI下面这张表是我当时对比的思考过程选型方案A方案B我的选择与理由后端框架SpringBootSSM手写整合用SpringBoot。自动配置解决了一堆XML配置问题内嵌Tomcatjava -jar直接跑减少环境摩擦。SSM整合虽然经典但入门成本高纯手写配置文件很浪费时间持久层MyBatisSpring Data JPA用MyBatis。SQL可控性高复杂查询职位筛选、多条件动态SQL写XML一眼看明白排查慢SQL也方便。JPA省代码但遇到多表聚合时就有点“魔法”调试时反而不透明数据库MySQL 8PostgreSQL用MySQL。对普通开发者最熟悉、文档多、Navicat等工具成熟部署到服务器上也省心。MySQL 8的JSON类型、窗口函数留着以后做数据统计也够用前端Vue 2 Element UIVue 3 Element Plus用的Vue 2。因为Element UI组件生态对中后台系统支持极其成熟网上资料多遇到问题搜索成本低。如果你习惯组合式API也可以写Vue 3核心业务组件基本都能平移这套组合最大的特点是“资料多、坑少、好答辩”。SpringBoot和Vue各自都是生态最活跃的框架MyBatis又是国内企业使用的重头面试常问的Java八股、MyBatis分页插件、MySQL事务这些点在这个项目里全都能对上做一个项目顺带把面试知识点也串起来了。2. 数据库设计与核心表结构2.1 表规划和字段设计逻辑数据库设计是整个项目的地基我吃过“表没设计好后面改代码改到吐”的亏所以这次老老实实画了两天ER图。最终核心表一共七张用户表、学生信息表、企业信息表、岗位表、简历表、投递记录表、系统日志表。用户表是登录的入口我把它做成统一认证表用角色字段区分身份CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT 登录密码(加密存储), role VARCHAR(20) NOT NULL COMMENT 角色: student/company/admin, status TINYINT DEFAULT 1 COMMENT 状态: 1启用 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT系统用户表;这里有几个容易被忽略的设计点。第一username必须唯一这是登录时查询的索引依据不加唯一约束后面业务一定会出现脏数据。第二密码绝不能明文存我用MD5加盐的方式存储后面讲安全的时候细说。第三角色用字符串而不是数字枚举牺牲一点点存储换来代码可读性查询的时候WHERE role student比WHERE role 1直观得多。学生信息表和企业信息表都以用户表ID作为主键同时兼任外键角色属于“一对一扩展表”的思路CREATE TABLE student_profile ( user_id BIGINT PRIMARY KEY COMMENT 关联用户ID, name VARCHAR(30) NOT NULL, gender TINYINT, school VARCHAR(100), major VARCHAR(100), education VARCHAR(20), phone VARCHAR(20), email VARCHAR(50), expectation_city VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP );岗位表是招聘大厅的核心数据来源也是查询条件最多的表。我设计了标题、所属企业ID、工作城市、薪资范围上下限、学历要求、岗位类型、状态七类常用检索字段。薪资用两个整数字段而不是一个字符串这样后续做薪资区间筛选时可以直接用SQL比较如果用“8k-15k”这种字符串筛选就成了噩梦。2.2 表关系和外键为什么不用“物理外键”表之间的关系比较清晰用户表与学生/企业是一对一企业与岗位是一对多岗位与投递记录是一对多学生与简历是一对一投递记录关联学生和岗位。我在建表的时候没有写任何FOREIGN KEY约束只用逻辑外键就是存对方ID但约束交给代码。很多人会质疑这一点我解释一下为什么。就业管理系统本质是读多写少的业务而物理外键在MySQL的InnoDB引擎里每次插入都要检查关联表表数据大了以后会成为写入瓶颈。更重要的是项目上线后一旦要做分表分库或数据归档物理外键会严重阻碍迁移。实际开发中我把数据一致性交给Service层的事务保证性能更好灵活性也更高。投递记录表是整个系统状态流转的核心CREATE TABLE delivery_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, job_id BIGINT NOT NULL COMMENT 岗位ID, student_user_id BIGINT NOT NULL COMMENT 学生用户ID, company_user_id BIGINT NOT NULL COMMENT 企业用户ID, resume_id BIGINT COMMENT 使用的简历ID, status TINYINT DEFAULT 0 COMMENT 状态: 0已投递 1已查看 2邀约面试 3不合适, interview_time DATETIME COMMENT 面试时间, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_student (student_user_id), KEY idx_company (company_user_id) ) COMMENT投递记录表;在投递记录表里同时冗余了学生和企业两端的用户ID这样企业查“收到的简历”时单表直接WHERE company_user_id ?学生查“我的投递”时单表直接WHERE student_user_id ?完全不用多表关联查询性能是最好的。面试时间字段也放在投递记录上而不是单独开一张面试安排表因为一个投递记录最多对应一次面试没必要过度设计。这里要记住的是在写业务代码之前把所有状态字段的值先定义清楚前后端共用同一套数字枚举后面才不会出现“前端显示待处理后端查出来状态码对不上”的情况。3. 后端核心实现SpringBoot与MyBatis落地细节3.1 工程结构和代码分层后端工程遵循标准的SpringBoot单体分层结构com.example.job ├── JobApplication.java // 启动类 ├── controller/ // 控制层只做参数接收和结果返回 ├── service/ // 业务层事务和核心逻辑 ├── mapper/ // MyBatis的Mapper接口 ├── entity/ // 数据库实体类 ├── dto/ // 前端交互的数据传输对象 ├── common/ // 统一结果封装、异常处理、工具类 └── config/ // WebMvc配置、拦截器、跨域配置分层这个事我多说两句。很多新手写SpringBoot项目时Controller里直接塞一大堆业务代码一个接口上百行后面改一个需求能把人绕疯。我坚持Controller薄、Service厚的原则Controller只负责接收请求、调用Service、把结果包成统一返回结构业务判断、事务管理、数据组装都在Service层。实体类entity严格按照数据库字段来但返回给前端的数据结构需要用DTOData Transfer Object来定义比如岗位列表页需要额外附带企业名称这个字段主要从企业表查出来再塞进DTo返回。统一返回结构的代码是每个接口都要用的基座Data public class ResultT { private Integer code; // 200表示成功 500表示失败 private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMsg(操作成功); result.setData(data); return result; } public static T ResultT error(String msg) { ResultT result new Result(); result.setCode(500); result.setMsg(msg); return result; } }所有接口返回这个结构前端Axios拦截器就能统一判断状态码不需要每个接口单独写错误处理逻辑。这是我强烈建议所有全栈项目都遵守的统一约定。3.2 MyBatis动态SQL与分页插件实战对于像岗位筛选这种多条件组合查询MyBatis的XML动态SQL是真正的核心利器。招聘大厅的检索条件是标题关键词、城市、学历、岗位类型、薪资范围每个条件都可选如果硬拼SQL字符串不仅容易出错还容易产生SQL注入风险。我用where加if标签来实现。select idselectJobList resultTypecom.example.job.entity.JobPosition SELECT j.*, c.company_name, c.company_logo FROM job_position j LEFT JOIN company_info c ON j.company_user_id c.user_id where if testkeyword ! null and keyword ! AND j.job_title LIKE CONCAT(%, #{keyword}, %) /if if testcity ! null and city ! AND j.city #{city} /if if testeducation ! null and education ! AND j.education #{education} /if if testjobType ! null and jobType ! AND j.job_type #{jobType} /if if testminSalary ! null AND j.salary_min gt; #{minSalary} /if if testmaxSalary ! null AND j.salary_max lt; #{maxSalary} /if AND j.status 1 /where ORDER BY j.create_time DESC /select这里注意两个细节。第一where标签会自动去掉第一个AND这样不需要在if里纠结有没有前置条件MyBatis的where标签天生处理好了。第二XML里和不能直接用要用gt;和lt;转义新手第一次写XML动态SQL多半是在这里报错的。分页我用的是PageHelper插件用法非常简单Override public PageInfoJobPositionVO getJobList(JobQueryDTO query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); ListJobPositionVO list jobMapper.selectJobList(query); return new PageInfo(list); }注意一个核心规则PageHelper.startPage()后面必须跟第一条SQL查询语句中间不要穿插其他查询、Log输出或者赋值操作否则分页插件会拦截到错误的SQL导致分页失效。这个坑我踩过当时在startPage和select之间加了一行打印当前用户信息的日志结果分页出来的总条数就错了。排查了半天才发现PageHelper是基于ThreadLocal实现的它把分页参数绑定到了当前线程遇到下一次SQL查询就会自动应用所以“紧随其后”是铁律。3.3 登录认证JWT与拦截器就业管理系统的三个角色都不希望别人能直接操作自己的数据所以认证是必须的。我采用的方案是JWTJSON Web Token加拦截器。用户登录成功后后端签发一个有效期为24小时的token前端把它存在localStorage里每次请求在请求头Authorization字段带上拦截器统一校验。签发token的核心逻辑public String generateToken(Integer userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }签到时候把用户ID放在subject里同时把角色塞进claim这样后面做接口级权限控制时直接在拦截器里解析claim就能判断当前用户是不是对应角色。拦截器的配置注意要放行登录接口和静态资源其他接口一律拦截Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor()) .addPathPatterns(/**) .excludePathPatterns( /api/login, /api/register, /api/job/list, /error ); }密码加密这里我多说一句虽然MD5已经被暴力破解得很厉害但在这个体量的项目里配合加盐依然够用。用户注册的时候把密码和盐拼起来做MD5数据库里同时存盐值登录时取出盐重新计算比对。如果以后想升级改成BCrypt也就一个工具类的事。3.4 安全防护XSS过滤与统一异常处理标题里既然提到了XSS千万别觉得那是“黑客才要关心的事”。就业管理系统的岗位名称、简历自我介绍都是文本输入框如果有人在前端脚本里嵌了scriptalert(document.cookie)/script存储型XSS就能偷走管理员的登录凭证。我的处理方案是写一个全局的XSS过滤过滤器基于Jsoup库把请求参数中可疑的HTML标签清理掉。Configuration public class XssConfig { Bean public FilterRegistrationBeanXssFilter xssFilterRegistration() { FilterRegistrationBeanXssFilter registration new FilterRegistrationBean(); registration.setFilter(new XssFilter()); registration.addUrlPatterns(/*); registration.setOrder(1); return registration; } }另外后端一定要配全局异常处理不然数据库异常、空指针异常直接裸露给前端既不好看也不安全。我用RestControllerAdvice加ExceptionHandler做了三层兜底业务异常返回友好提示BindException返回字段校验错误兜底异常返回“系统繁忙请稍后重试”并打印完整堆栈到日志。4. 前端核心实现Vue工程搭建与页面设计4.1 工程初始化和必装依赖库前端部分我用了Vue CLI来搭建骨架。执行vue create job-frontend选上Router和Vuex接下来安装UI组件库和网络请求库npm install element-ui axios npm install less less-loader --save-dev这里有个环境配置的小建议。Element UI按需引入能显著减小打包体积但配置babel-plugin-component的步骤多一点。我的建议是先用全量引入等系统功能定下来、开始优化性能的时候再切换按需引入。全量引入的代价是初始包体积大一点但开发期省心所有组件直接可用。4.2 路由设计与会话保持路由是前端页面的骨架我设计了两套布局。不需要登录就能看的只有登录页登录后根据角色加载主布局侧边菜单加顶栏内部包含招聘大厅、我的投递、个人中心学生岗位管理、投递管理、企业信息维护企业用户管理、岗位审核、数据概览管理员。路由核心片段{ path: /login, name: Login, component: () import(/views/Login.vue), meta: { public: true } }, { path: /, component: Layout, redirect: /home, children: [ { path: home, name: Home, component: () import(/views/Home.vue), meta: { title: 招聘大厅, roles: [student, company, admin] } }, // 其他业务页面 ] }路由守卫用来拦截未登录访问router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.public) { next(); } else if (!token) { next(/login); } else { next(); } });Vuex用来保存登录用户的基本信息和角色页面刷新之后从localStorage重新取出来初始化这样刷新页面不会掉登录状态。4.3 核心页面拆解招聘大厅、岗位发布、投递追踪招聘大厅是最重要的前端页面结构上选择了“筛选区 卡片列表 分页”的组合。筛选区是表单组件绑定查询条件卡片列表用el-card展示岗位标题、企业名称、城市、薪资、学历要求每张卡片底部放“查看详情”“立即投递”两个按钮分页组件绑定页码和每页大小。“立即投递”之前要先检查用户是否已经填写过简历没有简历的弹窗引导去完善。岗位发布页面在企业端用el-form做表单验证薪资范围我用两个输入框填最小值和最大值提交给后端以后端字段为准。发布成功之后岗位列表页会直接刷新出现新岗位。投递追踪页对学生来说是一张状态表对每个求职记录展示当前状态。我用el-tag显示状态不同的颜色已投递是灰色已查看是蓝色邀约面试是绿色附带面试时间展示不合适是红色。企业端看收到的简历就投递记录反着来看列表按投递时间排序状态操作按钮放在行尾。4.4 Axios封装与接口对接前后端对接最忌讳每个页面都写一遍axios调用、重复处理错误。我在src/utils/request.js里统一封装了Axios实例import axios from axios; const service axios.create({ baseURL: process.env.VUE_APP_BASE_API || /api, timeout: 15000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; }); service.interceptors.response.use( response { const res response.data; if (res.code ! 200) { if (res.code 401) { localStorage.removeItem(token); window.location.href /login; } return Promise.reject(new Error(res.msg || 请求失败)); } return res; }, error { return Promise.reject(error); } ); export default service;请求拦截器负责自动附加token响应拦截器负责统一处理业务状态码。这样在页面里调用接口就显得非常干净getJobList(query).then(res { this.jobList res.data.list; this.total res.data.total; });对接阶段还注意一个点前后端接口文档要提前定好。我用的方式是先在记事本上列出所有接口的URL、请求参数、返回结构前后端都对照这同一份文档开发比边写边对效率高得多也少扯皮。5. 联调部署与完整源码使用指南5.1 本地环境启动全流程拿到源码之后要能跑起来这里把步骤和坑一次捋清楚。第一步准备环境JDK 1.8以上、Maven 3.6、MySQL 8、Node.js 14。安装MySQL 8的时候记得密码认证插件选项选Legacy否则老版本JDBC驱动连不上。数据库初始化用SQL脚本执行即可脚本里包含建库、建表、初始化管理员账号的SQL。第二步修改后端配置。在application.yml里改成你的数据库账号密码spring: datasource: url: jdbc:mysql://localhost:3306/job_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case: true这个配置必须开否则数据库字段create_time映射到Java的createTime就失败查询出来全是null。我记得第一次弄这个项目时没开这个配置前端列表所有时间字段都是空白排查了半小时才发现是驼峰映射的问题。第三步启动后端。用IDEA打开工程等Maven依赖解析完运行JobApplication.java。在浏览器里访问http://localhost:8080/api/job/list如果能拿到JSON说明启动成功。第四步启动前端。在项目目录执行npm install npm run serve如果npm install报错多半是依赖版本冲突可以删掉package-lock.json和node_modules重新安装或者用npm install --legacy-peer-deps跳过依赖冲突检查。启动后浏览器访问http://localhost:8081遇到白屏先去地址栏确认端口号对不对。第五步前后端联调。开发环境下我用的是Vue CLI的代理配置解决跨域在vue.config.js里这样设置module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }所有请求都带/api前缀代理到后端8080端口开发环境完全规避了跨域问题。如果不用代理就要在后端配置CorsConfig放行指定来源两种方案我都试过代理方案在开发期更干净不需要动后端代码。5.2 打包部署完整方案部署到服务器上我用的是前后端分开部署的方案。后端执行mvn clean package -DskipTests打出jar包然后扔到服务器上nohup java -jar job-system-1.0.0.jar app.log 21 前端在本地执行npm run build产物在dist目录下把它用Nginx部署。这样后端占8080端口Nginx占80端口二者通过/api路径进行反向代理server { listen 80; server_name your_domain.com; root /usr/share/nginx/html; # dist目录位置 index 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 / { try_files $uri $uri/ /index.html; } }注意try_files $uri $uri/ /index.html;这一行是Vue路由History模式部署的关键。如果没有这一行刷新页面到/home这类路由时会返回404因为Nginx找不到对应的物理文件。如果你不想折腾Nginx最简单的办法是让后端直接把Vue构建产物放到src/main/resources/static目录下一起打包这样java -jar一个进程就跑完了整个系统只有8080一个端口不用配Nginx对初学者最友好。6. 常见问题与排查技巧实录6.1 典型报错与解决方案速查表我从开发和上线过程中整理了一些高频报错做成了速查表报错现象根本原因解决方案访问后端接口报Access denied for user rootlocalhost数据库密码不对或MySQL 8认证插件不兼容检查application.yml密码连接串加useSSLfalseserverTimezoneAsia/Shanghai必要时重设root密码前端列表数据全部为nullMyBatis没有开启下划线转驼峰application.yml配置mybatis.configuration.map-underscore-to-camel-case: true分页查询返回条数错误PageHelper.startPage()和查询之间插入了其他SQL语句确保startPage后紧贴第一条查询SQL中间不执行任何其他Mapper方法登录接口通但其他接口全部401JWT拦截器没有放行登录接口或token附加位置不对检查拦截器excludePathPatterns配置确认前端请求头传的是Authorization字段多个页面接口报Failed to fetch前端代理没有生效或跨域配置缺失确认访问地址走的是/api前缀、后端端口正确生产环境检查Nginx的location匹配规则上传简历后刷新丢失简历文件保存到了临时目录或没有持久化把上传目录配置到服务器固定路径如/data/upload/并给Nginx加静态资源映射岗位发布成功但列表不显示岗位状态字段没有标记为启用发布接口里status设置为1列表查询条件默认过滤status1npm install报ERESOLVE错误Node版本较新与旧依赖冲突加--legacy-peer-deps参数或降级Node版本到16.x6.2 踩过几次坑之后的独家经验第一个坑前端多角色菜单权限。最开始我把菜单栏写死在侧边栏组件里三个角色进来看到的内容一模一样学生居然能点进“企业岗位管理”。后来改成动态菜单方案登录成功后后端把当前用户的角色返回给前端前端用一个router.addRoutes()方法按角色动态添加路由菜单也根据router里的meta字段动态生成。改完之后不仅权限清晰了代码也更好维护了。第二个坑面试时间展示。邀约面试时我本来只在后端存了一个面试时间字段但企业HR存在多个时区的可能性其实主要是不同城市作息后来扩展成了面试时间加面试地点的组合字段前端面试邀约弹窗里同时让HR填时间、地点、联系人这样学生收到的通知才完整可用。第三个坑数据库时区导致时间错乱。有一次线上系统所有投递时间都比本地时间快了8个小时查下来发现是MySQL连接串没加serverTimezoneAsia/Shanghai。MySQL 8默认时区是UTC存储和读取的时候都会按UTC处理如果你在中国最终展示结果就差了8小时。连接串里加这一项之后问题彻底解决。第四个坑导出投递记录。企业HR要求把收到的简历列表导成Excel当时图省事用前端把表格数据转CSV结果Excel打开乱码。后来老老实实后端用EasyExcel生成Excel文件前端拿blob下载一步到位。这里顺带说一个下载文件容易踩的坑axios请求响应类型要设置成responseType: blob否则文件下载下来是损坏的JSON。7. 写在最后的个人体会整套系统从零到一做下来我最大的感受是这类偏管理的Web系统难点从来不在某个单一技术而在“串起来”的能力。数据库表之间怎么关联、后端接口怎么对齐前端页面的数据需求、分页参数和状态码怎么统一约定、部署的时候跨域和静态资源怎么处理每一个环节单独拆开都不难但组合起来就能淘汰掉一大批只会“跟着教程跑demo”的人。我在实际开发中坚持的顺序是先画清楚ER图、再定义好统一返回结构、最后才开始写接口和页面前两步做好了后面编码时间至少省一半。这套就业管理系统后续还可以继续扩展统计报表、消息通知、在线笔试这些模块骨架已经在了加功能只是往上面长肌肉的事。如果你正在做类似的项目希望这篇记录能帮你少踩几个坑把时间花在真正有意思的需求上。