毕业季一到,最忙的往往不是正在找工作的学生,而是负责统计就业数据的辅导员和教务老师。手里一堆Excel表格,今天这个学生签了三方,明天那个学生考上了编制,信息散在各个微信群里,月底要报表时全是手动对账。我也是在那段时间接触到了这套毕业就业信息管理系统,基于SpringBoot做后端、Vue做前端、MySQL存数据,三个角色分别对应学生、企业和学校管理员,把“投简历—发岗位—审企业—统计就业”这条业务链完整串了起来。源码标注了“可直接运行”,我实测下来确实改改数据库账号密码就能启动。
这篇文章适合几类人:准备做管理类毕业设计但还没定题目的在校生,需要快速交付一个小型信息系统的开发者,以及想把就业管理流程从Excel里解放出来的学校技术团队。我会从选型逻辑、数据库设计、后端接口、前端结构到本地部署排错完整拆一遍,尽可能把每一步为什么要这么做的原因也说清楚,方便你直接参考或二次改造。
1. 为什么SpringBoot+Vue+MySQL这套组合能成为“默认答案”
1.1 前后端分离的成熟度已经足够高
现在再去做那种用模板引擎渲染页面的单体Java Web项目,除非学校有硬性要求,否则真没必要。前后端分离的开发模式下,后端只负责提供数据接口,前端只负责渲染页面和交互,两边通过JSON通信,团队协作和后期扩展都舒服很多。SpringBoot在后端圈子里几乎是事实标准,起步快、生态全,一个mvn spring-boot:run就能把服务跑起来;Vue在前端圈子的上手曲线比React更平缓,中文资料也更多;MySQL则是最常见的开源关系型数据库,部署简单,学校和中小企业用得最多。
这套组合不是“最先进”的,但它是“最稳妥”的。一个非一线团队或者个人开发者,用这套组合去搭中小型管理系统,网上能查到的解决方案是最多的,踩过的坑也都被前人填得差不多了。所谓可直接运行,本质上是依赖了这套组合的低环境门槛。
1.2 三类角色把就业业务闭环串了起来
这个系统里有三个登录入口,对应三种身份,登录后看到的功能完全不同:
| 角色 | 核心功能 |
|---|---|
| 学生 | 维护个人简历、浏览招聘岗位、投递岗位、查看企业反馈、登记就业信息 |
| 企业 | 注册与入驻申请、发布招聘岗位、查看收到的学生简历、反馈录用结果 |
| 管理员 | 审核企业入驻、管理学生与企业账号、发布通知公告、查看就业统计报表 |
业务闭环可以这样理解:企业先注册提交入驻申请,管理员审核通过后,企业才能发布岗位;学生在岗位列表里筛选合适的岗位并投递简历,企业查看投递记录后给出“邀约面试”或“不匹配”的反馈;学生被录用后,在系统里登记就业单位信息,管理员端汇总所有登记数据,按学院和专业统计就业率。这个流程覆盖了“招聘前—招聘中—招聘后”的完整周期,比单纯做一个“岗位信息展示系统”要扎实得多。
1.3 这套系统的可复用边界在哪里
不只是毕业设计能用。只要把“学生”换成“求职者”、“岗位”换成“商品”、“就业登记”换成“订单登记”,系统骨架几乎可以迁移到其他信息管理类项目上。权限模型、统一返回、分页接口、文件上传这些代码块,在任何一个中小型前后端分离项目里都是一样的。所以我不太建议把这类系统当成一次性的作业来对待,更应该把它当成一套可复用的管理系统脚手架。
2. 数据库设计是整套系统能不能跑通的底层关键
我接手这类项目的习惯是先看数据库脚本。表结构定得不合理,后面写多少代码都别扭。这套系统里核心的业务表大概有九张,我逐个说清楚它们的作用和设计时的取舍。
2.1 九张核心表的功能定位
| 表名 | 用途 | 最关键的两个字段 |
|---|---|---|
| sys_user | 统一登录账户表 | username、role |
| student_info | 学生基础与扩展信息 | user_id、student_no |
| company_info | 企业信息与入驻状态 | user_id、status |
| job_info | 招聘岗位表 | company_id、status |
| resume_info | 学生简历内容表 | student_id、skills |
| delivery_record | 学生投递岗位记录表 | student_id、job_id |
| employment_info | 就业信息登记表 | student_id、employment_status |
| notice_info | 通知公告表 | title、publish_time |
| college_info | 学院/专业基础数据表 | college_name |
sys_user 单独拆出来的原因很直接:三种角色的登录行为是统一的,都需要用户名、密码、状态和角色标识,如果各自建表会让登录逻辑没办法复用。业务扩展信息则放各自的资料表里,通过 user_id 去关联。这个“账户表+资料表”的拆分模型,在很多大型系统里也是同样的思路。
2.2 用户表与资料表的一对一关系怎么维护
sys_user 里的主键 id 会被 student_info.user_id 和 company_info.user_id 引用,两张资料表与用户表都是一对一关系。我见过有人图省事,直接在用户表里拼上学号、姓名、公司名称、统一信用代码这些字段,结果就是角色一多,表变得不伦不类。宁可多建两张表,也别把不同角色的字段堆在一个账户表里。
登录后的用户信息可以从 sys_user 查出角色,再按角色去查询对应的扩展表,得到完整的用户信息。这个步骤在前端叫“获取用户详情”,在后端往往就是一个简单的条件查询加一个业务查询,逻辑清晰,也方便以后扩展。
2.3 就业信息表的设计决定了统计报表好不好写
就业信息表是整个系统里最需要用心设计的一张表。它不能只记录一个“已就业/未就业”字段,否则根本支撑不了后面的统计报表。我建议至少要包含这些字段:学生ID、就业单位名称、岗位名称、薪资范围、入职时间、工作城市、就业类型(签就业协议、升学、应征入伍、自主创业等)、就业凭证附件路径、审核状态。
为什么要留就业类型?因为高校的就业统计里,“升学”和“签就业协议”会被计入不同口径的毕业去向,没有这个字段,报表只能笼统显示一个数字。为什么要留附件路径?因为后续管理员需要核验就业证明材料,没有凭证,这数据对上报表就是无源之水。一张表的设计,直接决定了两个星期后写统计接口时会不会想砸键盘。
2.4 建表脚本里值得注意的细节
我给 job_info 和 employment_info 这两张表的建表脚本做个示范,实际全套SQL比这个多,但关键写法都在里面了:
CREATE TABLE job_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '岗位ID', company_id BIGINT NOT NULL COMMENT '所属企业ID', title VARCHAR(100) NOT NULL COMMENT '岗位名称', job_type VARCHAR(50) COMMENT '岗位类型', salary_min DECIMAL(10,2) COMMENT '最低薪资', salary_max DECIMAL(10,2) COMMENT '最高薪资', work_city VARCHAR(50) COMMENT '工作城市', degree_requirement VARCHAR(20) COMMENT '学历要求', recruit_num INT DEFAULT 1 COMMENT '招聘人数', job_desc TEXT COMMENT '岗位描述', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', deadline DATETIME COMMENT '招聘截止时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '发布时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='招聘岗位表'; CREATE TABLE employment_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL COMMENT '学生ID', company_name VARCHAR(200) COMMENT '就业单位名称', job_title VARCHAR(100) COMMENT '岗位名称', salary_range VARCHAR(50) COMMENT '薪资范围', entry_date DATE COMMENT '入职时间', work_city VARCHAR(50) COMMENT '工作城市', employment_type VARCHAR(20) COMMENT '就业类型', certificate_url VARCHAR(255) COMMENT '就业凭证路径', status TINYINT DEFAULT 0 COMMENT '0待确认 1已确认', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='就业信息登记表';两个细节值得说:一是所有表都用 InnoDB 引擎和 utf8mb4 字符集,后者能完整支持中文和特殊符号,老项目的 utf8 在遇到生僻字时很容易出问题;二是字段带 COMMENT,这个习惯能帮你省掉大量翻代码对字段的时间,尤其是三个月后再回来看这个项目的时候。
3. SpringBoot后端:工程结构、登录鉴权与核心接口
3.1 后端工程结构先规划好
一套能直接运行的后端工程,最怕的就是所有代码堆在 controller 里。我建议按下面这种分层结构组织:
com.example.employment ├── controller/ # 接口层 │ ├── AuthController.java │ ├── StudentController.java │ ├── CompanyController.java │ └── AdminController.java ├── service/ # 业务逻辑层 │ ├── impl/ ├── mapper/ # MyBatis-Plus的Mapper接口 ├── entity/ # 数据库实体 ├── dto/ # 请求参数对象 ├── vo/ # 返回视图对象 ├── common/ # 通用返回结果、异常类、常量 ├── config/ # 跨域配置、拦截器配置 ├── utils/ # JWT工具、密码加密工具 └── GraduateEmploymentApplication.javacontroller 只负责接收参数和返回结果,service 负责写业务逻辑,mapper 负责和数据库打交道。这样的代码在排错时会舒服很多。
3.2 登录接口与JWT鉴权的具体实现思路
登录接口是第一个要写的接口,也是后面所有接口的地基。它的逻辑不复杂:
后端接收到用户名和密码后,先拿用户名去 sys_user 表查用户,查不到直接返回“用户名或密码错误”;查到了就用 BCrypt 密码校验器去比对加密后的密码,一致则生成一个 JWT Token 返回给前端,不一致同样返回错误。
密码在数据库里一定不能存明文。我在系统里只引入了 spring-security-crypto 这个轻量依赖来使用 BCryptPasswordEncoder,并没有把完整的 Spring Security 引进来,因为完整的权限框架对这个项目来说过于重了,反而会带来很多默认拦截规则,增加“直接运行”的难度。这就是一个很典型的取舍:工具类可以单拿,框架不一定要全套上。
JWT生成后,后续所有请求在请求头里携带 Authorization 字段,后端用一个拦截器统一去解析和校验。拦截器里通过后,把用户ID和角色放进请求属性里,接口里随时可以取出来用:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { throw new BusinessException(401, "未登录或登录已过期"); } Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } }这个拦截器不用区分角色,因为所有业务接口都需要登录。角色校验则留给更细粒度的做法,比如在需要管理员权限的接口上增加一个自定义注解,或者在接口方法里手动判断当前用户的角色。系统只有三种角色的时候,手动判断就够用了,不需要引入复杂的权限框架。
3.3 统一返回格式与全局异常处理不能省
接口返回格式如果不统一,前端处理起来会非常痛苦。我一般定义一个 Result 类,结构固定为 code、msg、data 三个字段,成功时 code 为 200,失败时为业务错误码,401 统一表示未登录。
配合统一返回类的还有全局异常处理器,用 @RestControllerAdvice 把所有业务异常接住,转换成统一的错误结构。不然系统一旦报个空指针,前端拿到一堆看不懂的堆栈信息,体验立刻回到石器时代。
public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.msg = "操作成功"; result.data = data; return result; } public static <T> Result<T> error(String msg) { Result<T> result = new Result<>(); result.code = 500; result.msg = msg; return result; } }3.4 岗位发布与投递的核心业务逻辑
岗位发布的主体是企业。企业账号登录后,通过 company_id 关联企业信息,新增岗位时把 company_id 一并写入 job_info。岗位发布后默认是上架状态,企业也可以手动下架,但不删除历史数据,因为投递记录表里关联着岗位ID,删除会造成大量孤儿数据,报表也没法统计历史招聘情况。
学生投递岗位时,delivery_record 表会插入一条投递记录。这里要注意幂等控制:同一个学生对同一个岗位不能重复投递。在代码里就是提交前先查一下 delivery_record 里有没有 student_id + job_id 的记录,有就提示“请勿重复投递”,没有才插入。这个逻辑虽然简单,但少了它学生连点几次提交按钮,企业端就会看到好几条一模一样的简历。
投递记录的 status 字段可以由企业操作流转:待查看 → 已查看 → 已邀约/已录用/已拒绝。前端根据状态渲染不同颜色的标签,学生就能在自己投递列表里看到进展。这套流程本质上就是一个状态机建模,不需要额外引工作流引擎。
3.5 就业率统计接口的SQL写法
这套系统的管理员端最核心的页面就是就业数据统计。如果之前就业信息表的字段设计得合理,到这里反而最简单,无非就是分组统计加条件聚合:
SELECT s.college AS 学院, COUNT(*) AS 学生总数, SUM(CASE WHEN e.employment_type IS NOT NULL AND e.employment_type != '未就业' THEN 1 ELSE 0 END) AS 已就业人数, ROUND( SUM(CASE WHEN e.employment_type IS NOT NULL AND e.employment_type != '未就业' THEN 1 ELSE 0 END) / COUNT(*) * 100, 2 ) AS 就业率 FROM student_info s LEFT JOIN employment_info e ON s.id = e.student_id GROUP BY s.college;这里的 LEFT JOIN 很关键,必须用左连接,否则没有填写就业信息的学生会被直接过滤掉,统计出来的总数就会比实际学生数少。很多第一次写这个统计的同学都栽在这里。如果还需要按专业细分,GROUP BY 后面再加上 s.major 字段即可。
4. Vue前端:页面结构、权限路由与接口请求封装
4.1 前端工程结构长什么样
Vue 工程我建议用 Vite 创建,目录规划如下:
src/ ├── api/ # 每个模块的接口方法 │ ├── auth.js │ ├── student.js │ ├── company.js │ └── admin.js ├── assets/ ├── components/ # 通用组件 ├── layout/ # 后台布局,侧边栏+顶栏+主内容区 ├── router/ │ └── index.js # 路由表 ├── store/ # Pinia状态管理 ├── utils/ │ ├── request.js # Axios实例封装 │ └── auth.js # Token存取 └── views/ ├── login/ ├── student/ ├── company/ └── admin/layout 是整个后台界面的外壳,登录成功后的所有页面都渲染在它里面。侧边栏菜单根据角色动态生成,顶栏放用户信息和退出按钮。这种骨架式写法避免了每个页面都重复写侧边栏。
4.2 登录后的Token存储与路由守卫
Token 在前端一般存 localStorage,配合 Pinia 存用户基本信息。页面跳转通过路由守卫控制,只有拿到 Token 才能进入系统内页面,否则全部重定向到 /login。路由守卫代码大致是:
router.beforeEach((to, from, next) => { const token = getToken() if (to.path === '/login') { next() } else if (!token) { next('/login') } else { next() } })角色控制放在菜单渲染层面。我这里的做法是,侧边栏菜单数组里每一项标注允许显示的角色,比如“岗位管理”允许 student 和 company,而“就业统计”只允许 admin。登录后根据当前用户角色过滤菜单数组,这样接口层面已经做了鉴权,前端再控制一下菜单展示,体验就完整了。
4.3 Axios实例封装要覆盖三个关键点
请求封装是前端工程里最值得花时间写好的部分。我封装的 request.js 主要做三件事:请求拦截器里给每个请求头自动加上 Authorization;响应拦截器里统一拿到后端 Result 结构,code 为 200 就直接返回 data,否则提示错误;遇到 401 状态码时清除本地 Token 并跳转登录页。
service.interceptors.response.use( (response) => { const res = response.data if (res.code === 200) { return res } if (res.code === 401) { removeToken() router.push('/login') return Promise.reject(new Error('登录已过期')) } ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg || '请求失败')) }, (error) => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )有了这一层封装,页面里调用接口只需要关注业务代码,不需要到处处理错误弹窗和 Token 过期问题。这也是“可直接运行”这个承诺能落到实处的关键。
4.4 三端核心页面的拆解思路
学生端最重要的页面是岗位列表和投递记录。岗位列表支持按岗位名称、城市、薪资范围筛选,列表分页展示,每条记录右侧有“投递”按钮。投递成功后,在投递记录列表里能看到状态标签从“待查看”一步步变成“已邀约”或“已录用”。
企业端最核心的页面是岗位管理页和应聘管理页。岗位管理页就是一套表格的增删改查,列表展示岗位上下架状态;应聘管理页展示该企业下所有岗位收到的投递记录,点击一条记录可以看到学生的简历详情,并做出反馈操作。
管理员端最核心的页面是企业审核页和就业统计页。企业审核页需要展示营业执照等附件信息,然后做通过或驳回操作;就业统计页用 ECharts 图表展示各学院就业率,饼图看去向分布,柱状图看各学院人数对比。图表数据从统计接口一次拿回来,前端按格式塞给图表组件即可。
4.5 一个容易忽略的前端细节:表单校验与字段一致性
这个系统里学生要填简历、企业要填岗位信息、管理员要填公告内容,涉及的表单很多。前端组件库自带的表单校验规则一定要用起来,比如岗位名称必填、薪资必须是数字、企业信用代码长度固定。校验规则要和后端字段校验保持一致。
还有一个实际操作中的坑:前端表单里的字段名必须和后端接收的 DTO 字段名完全一致,否则就会出现前端填了一堆数据,后端收到了全是 null 的情况。这个问题排查起来很费劲,因为前后端日志都不报错。建议定义接口时先用 Swagger 或手写的接口文档把字段确定下来,前端照着写。
5. 从零到一运行起来:配置步骤和启动阶段的常见坑
5.1 环境版本选择
这套系统对运行环境的要求不高,但如果版本太新也可能踩坑,我列一下我试过的稳定组合:
| 组件 | 推荐版本 | 备注 |
|---|---|---|
| JDK | 8 或 11 | 8最稳,11也没问题 |
| Maven | 3.6+ | 用IDEA内置的更省事 |
| MySQL | 5.7 或 8.0 | 8.0需要配置时区 |
| Node.js | 16 或 18 | Vite对新版本要求较高 |
| npm | 随Node附带 | 建议保留 lock 文件 |
5.2 后端启动前必须改的几个配置
拿到源码后第一步是创建数据库,执行项目里的 sql 脚本,把表和初始数据导入。然后打开 application.yml,修改数据库地址、用户名、密码三个值:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/graduate_employment?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 20MB max-request-size: 50MB这里有两个高频报错点:MySQL 8.0 的驱动类名是 com.mysql.cj.jdbc.Driver,不是老旧的 com.mysql.jdbc.Driver;数据库连接 URL 里的 serverTimezone 不写,启动后会报时区错误。新一点的 MySQL 驱动版本如果通信链路不安全也可能报 SSL 警告,URL 里加上 useSSL=false 就行。
5.3 前端启动和接口代理配置
前端依赖安装用 npm install,如果遇到 ERESOLVE 依赖树冲突,可以用 npm install --legacy-peer-deps 绕过。启动命令是 npm run dev,默认端口是 5173。
前端开发服务器和后端接口不在同一个端口,跨域问题必须处理。我的做法是利用 Vite 的代理能力,把前端发起的 /api 请求转发到后端 8080 端口。在前端项目根目录的 vite.config.js 里配置如下:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })后端所有接口统一以 /api 开头,前端请求路径写 /api/xxx,经过代理转发到后端后,实际会到达 http://localhost:8080/api/xxx。这个方案开发阶段很好用,不需要后端额外写跨域配置。如果要部署到生产环境,再用 Nginx 做反向代理,思路一样。
5.4 启动顺序和验证流程
后端在 IDEA 里直接运行启动类即可,看到日志打印出 Tomcat started 就说明成功了。前端在终端执行 npm run dev,浏览器访问 http://localhost:5173,能看到登录页就说明前端起来了。用初始管理员账号登录,进入后台随便点几个页面,再测试一下学生账号投递岗位,走一遍完整流程,确认数据库里新增了记录,整套系统就是真正跑通了。
5.5 我把启动阶段的高频报错整理成了排查清单
| 报错现象 | 可能原因 | 解决办法 |
|---|---|---|
| 8080端口被占用 | 本机有其他服务占用 | 改 server.port,或杀掉占用进程 |
| Access denied for user | 数据库账号密码错误 | 检查 yml 里的用户名密码 |
| Unknown database | 数据库没有创建 | 先执行建库语句 |
| The server time zone value | MySQL时区未配置 | URL加 serverTimezone=Asia/Shanghai |
| Failed to start bean 'documents' | 文件上传目录不存在 | 检查配置里的上传目录路径并手动创建 |
| npm ERESOLVE | 依赖版本冲突 | npm install --legacy-peer-deps |
| 前端请求404 | 后端没有/api前缀或代理路径配错 | 检查后端Controller RequestMapping和vite代理 |
| 登录报“用户名或密码错误” | 初始账号被改过或加密方式不匹配 | 直接查sys_user表确认数据 |
这个表是我实际排查过多次后整理出来的,基本覆盖了“源码能直接运行”这句话背后最容易翻车的八个环节。
6. 真实体会:这套系统的短板和几个值得扩展的方向
6.1 目前这套系统离生产级还有多少距离
我必须说实话,这类系统标着“可直接运行”,不等于可以直接上生产。安全方面目前只能算基础版:JWT 没有刷新机制,过期后用户只能重新登录;没有验证码机制,容易被脚本暴力尝试;登录密码虽然加密了,但缺少登录失败次数限制。业务方面,简历目前更多是文本填写加附件上传,没有实现在线预览;通知公告功能有,但没有站内信提醒机制,学生登录后才能看到。
如果只是课程设计或团队内部使用,这些都不是问题。如果要推向生产环境,至少得补上操作日志、验证码、密码找回这三个模块。
6.2 我觉得最值得做的三个扩展
第一个扩展方向是 Excel 导入导出。就业数据统计完,管理员有极大可能要把报表导出成 Excel 交给上级。后端用 EasyExcel 工具类,前端一个下载按钮,工作量不大,但对系统的实用价值提升非常明显。
第二个扩展方向是消息通知机制。当企业的反馈状态发生变化时,给学生发一个站内消息提醒;当学生投递简历时,给企业推送一条新消息。这类功能不需要引入消息队列,数据库里加一张消息表,前端轮询或者用 WebSocket 都能实现。
第三个扩展方向是统计维度的细化。当前统计到学院级别就够了,但真实场景里可能还要按专业、按班级、按性别、按生源地等维度钻取。数据表设计时已经预留了这些字段,扩展也就只是加接口和加图表的问题。
6.3 最后给接手的同学几句实务建议
如果你准备拿这套系统作为毕业设计或者项目实战练手,我建议先原样跑通,再去做个性化修改。先跑通的意义在于,你能从前到后理解整个数据流转链路,后面改功能时才知道动了前端一个按钮,后端哪个接口、哪张表会跟着受影响。
改功能时最忌讳只改前端不改数据库。比如岗位列表要加一个“工作性质”字段,前端表单加了输入框,后端实体没加字段,数据库表没加列,结果就是前端提交报错,排查半天。正确顺序是:先改数据库表,再改后端实体和接口,最后改前端页面。
保留原始SQL脚本也非常重要。你要是依赖 MyBatis-Plus 的自动建表功能改了表结构,后面想回退或者换一台电脑部署,没有SQL脚本就只能凭记忆重建表,非常被动。每次调整表结构后,记得把最新的建表语句导出来更新到项目的 sql 目录下。这套系统整体框架是稳的,把它跑熟、吃透,再按自己的需求去延伸,你收获的绝对不止是“一个毕业设计能通过”这么简单。