简介:一份基于Spring Boot的大学生就业需求分析系统毕业设计资源包,主要面向计算机相关专业毕业生、课程设计学生及Java初阶开发者。系统围绕求职者与招聘单位双端设计,覆盖简历创建、职位搜索、简历投递、面试准备、职位发布、简历筛选和面试安排等完整业务流程,适合作为毕业设计、课题参考或项目实训模板。包体内含792个文件,以java源码、vue前端页面、js逻辑、css样式、html静态页、sql数据库脚本及说明文档为主,附带svg图标、gif演示图与mp4操作演示,整体约26.92MB,目录结构清晰便于按模块查阅。已有68人学习下载,可帮助快速理解Spring Boot项目分层架构与前后端交互方式,同时提供可直接导入运行的工程文件和数据库脚本,节省从零搭建环境的时间,适合用于论文配套演示与实际功能二次开发,也有助于深入理解需求分析、数据建模及系统实现全过程。
1. 基于SpringBoot的大学生就业需求分析系统,拆开之后最该带走什么
基于SpringBoot的大学生就业需求分析系统,名字里的“分析”两个字容易让人误以为核心是统计报表。真正拆完这套Java毕业设计工程才发现,大头工作量在求职者与招聘单位双向的业务闭环上:求职者侧要完成简历创建、职位搜索、简历投递、进度查询;招聘单位侧要完成职位发布、简历筛选、面试邀约。只有把这条链路完整跑通,SpringBoot的配置管理、ORM映射、分页查询、状态流转和登录鉴权才算真正串联起来。
这套系统适合两类人。一类是准备Java毕业设计的学生,可以用它把基于SpringBoot的工程从骨架搭建到部署上线的完整路径走一遍;另一类是负责Java技术带教的人,这套表结构和状态机设计可以直接裁剪成企业内部招聘平台的第一版底座。下面按我拆这个项目的顺序,把数据库建模、JWT认证、职位搜索、投递状态机和部署脚本逐一展开。
2. 数据库建模先行——就业需求分析系统的表设计与索引取舍
2.1 三个角色与两条业务主线如何收敛成表关系
在写SpringBoot代码之前,先把业务用例收敛成数据关系。系统内有三个角色:求职者、招聘单位、管理员。求职者与招聘单位之间并不直接建立外键关联,而是通过职位表和投递记录表间接联系。求职者维护一份简历,招聘单位发布多个职位,求职者对职位发起投递,投递记录同时挂简历ID和职位ID。这个设计避免了用户表承载过重,也方便在答辩时把业务闭环讲清楚。
管理员角色在表结构层面不新增业务表,只通过 t_user 的 role 字段区分。管理端关注的职位需求量、投递量、各企业发布量等数据,全部通过对 t_position 和 t_application 的聚合查询获得。比如统计各行业职位需求分布,一条 group by 语句就够;统计投递转化率,对 t_application 按 status 分组即可。把统计口径建立在真实业务表上,比另建一张冗余统计表更扎实,数据对得上,也经得起追问。
2.2 五张核心表的结构定义与字段说明
下面是我在类似系统里常用的一组表结构。五张核心表分别是用户表、简历表、职位表、投递记录表和面试安排表。以简历表和职位表为例看字段设计思路:
CREATE TABLE `t_resume` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '归属用户ID', `real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `education` varchar(20) DEFAULT NULL COMMENT '最高学历', `school` varchar(100) DEFAULT NULL COMMENT '毕业院校', `major` varchar(100) DEFAULT NULL COMMENT '专业', `work_years` int(11) DEFAULT '0' COMMENT '工作年限', `skill_tags` text COMMENT '技能标签,逗号分隔', `self_evaluation` text COMMENT '自我评价', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='求职者简历表'; CREATE TABLE `t_position` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '职位名称', `company_name` varchar(100) NOT NULL COMMENT '公司名称(冗余字段)', `industry` varchar(50) DEFAULT NULL COMMENT '所属行业', `salary_low` int(11) DEFAULT NULL COMMENT '薪资下限,单位K', `salary_high` int(11) DEFAULT NULL COMMENT '薪资上限,单位K', `city` varchar(50) DEFAULT NULL COMMENT '工作城市', `description` mediumtext COMMENT '职位描述', `requirement` mediumtext COMMENT '任职要求', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0-下架 1-上架', `creator_id` bigint(20) NOT NULL COMMENT '发布人ID', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_create_time` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='招聘职位表';这段SQL包含两个值得展开的设计点。一是简历表对用户表的 1:1 约束用唯一索引 uk_user_id 保证,简历可以反复修改,但不会出现同一用户两份简历的脏数据;二是职位表冗余了 company_name 字段,因为招聘单位发布职位时已经填写了企业信息,列表页展示无需二次联表。技能标签用 text 存逗号分隔,在小规模系统里 like 匹配足够,不必刻意拆标签中间表,答辩被问“为什么违反第三范式”时,可以给出读取频率和查询路径的理由。
其余三张表与核心状态字段汇总如下:
| 表名 | 存放内容 | 关键关联字段 | 状态字段设计 |
|---|---|---|---|
| t_user | 账号密码、角色、所属企业 | company_id | role:0求职者 1招聘单位 2管理员 |
| t_resume | 简历主体 | user_id | 无状态,通过是否有记录判断 |
| t_position | 职位主体 | creator_id | status:0下架 1上架 |
| t_application | 投递行为记录 | resume_id, position_id | status:0待查看 1已查看 2面试邀约 3已拒绝 |
| t_interview | 面试安排 | application_id, position_id | round:轮次;status:0待确认 1已确认 2已完成 |
投递记录表还需要高频过滤字段的联合索引,按两种最常见的查询路径建立:
ALTER TABLE `t_application` ADD INDEX `idx_rid_pid` (`resume_id`, `position_id`), ADD INDEX `idx_pid_status` (`position_id`, `status`);这里两个索引对应的场景分别是“我的投递列表”和“职位下的候选人筛选”。根据最左前缀原则,idx_rid_pid 能同时覆盖按 resume_id 查询和按 resume_id + position_id 查询两种条件;idx_pid_status 则是为固定职位下按状态筛选候选人的高频操作准备的。如果后续 t_application 数据量到万级,这两个索引能让绝大多数查询保持在索引扫描级别,不需要回表扫描全量数据。
2.3 部署前先解决的时区与字符集问题
常见做法是把处理参数直接写进连接 URL。我在迁移到 MySQL 8 时遇到过通宵排查的问题:本地连接正常,部署到 Windows 服务器后抛The server time zone value '?й?' is unrecognized or represents more than one time zone。这个异常信息在中文环境经常乱码,很容易被误判成编码问题,实际上是连接串缺了时区参数。
spring: datasource: url: jdbc:mysql://localhost:3306/job_analysis?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true注意allowPublicKeyRetrieval=true这个参数。MySQL 8 默认认证插件是 caching_sha2_password,部分 JDBC 驱动在没有 SSL 加密连接时,首次认证需要拿服务器公钥,不带这个参数会报Public Key Retrieval is not allowed。项目里如果是 MySQL 5.7,可以不加;8.0 建议保留。这个细节在部署阶段比写业务代码更容易卡住,先写进连接串能少踩很多坑。
提示:如果项目工程里带了 element.min.css、app.37d929b9.css 这类前端静态资源包,统一拷贝到
src/main/resources/static目录下,SpringBoot 会自动映射为根路径静态资源,无需额外写一个静态文件控制器。
3. SpringBoot工程骨架与JWT认证链路的实现
3.1 依赖选型:SpringBoot 2.7.x 加 MyBatis-Plus 的取舍
针对毕业设计、课程设计和中小型系统,我会优先选 Spring Boot 2.7.x 配 MyBatis-Plus 3.5.x。理由有三:2.7.x 是 Spring Boot 3 大规模普及前最后一个支持 JDK 8 的主线版本,部署环境不用额外折腾JDK版本;MyBatis-Plus 提供 LambdaQueryWrapper 和内置分页插件,解决单表读写的样板代码能力比原生 MyBatis 强很多;社区资料量最大,网上排查问题基本能覆盖。Java 8 加 SpringBoot 2.7 的组合对当前大多数毕设和中小项目仍是稳定首选。
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.7.18</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> </dependencies>依赖层面有一个非常容易被忽略的点:JJWT 0.11.x 必须同时引入 jjwt-api、jjwt-impl、jjwt-jackson 三个 artifact。网上很多教程只贴 api 和 impl,运行时解析 token 会抛ClassNotFoundException: io.jsonwebtoken.lang.Classes。另外,Spring Boot 2.7 对应的 mybatis-plus 版本不要误配成mybatis-plus-spring-boot3-starter,那个是 Spring Boot 3 专用。
| 依赖 | 版本 | 作用 |
|---|---|---|
| spring-boot-starter-web | 由 2.7.18 parent 管理 | Web 容器与 MVC |
| mybatis-plus-boot-starter | 3.5.5 | ORM、条件构造器、分页 |
| mysql-connector-java | 8.0.33 | MySQL 8 驱动 |
| jjwt-api / impl / jackson | 0.11.5 | JWT 签发与校验三件套 |
3.2 application.yml 里必须写对的三处配置
第一个是数据源连接参数,刚才已经提到时区与公钥;第二个是 MyBatis-Plus 的驼峰映射与日志;第三个是连接池参数。HikariCP 是 SpringBoot 默认连接池,不需要额外引入依赖,但参数必须显式声明。
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0| 配置项 | 推荐值 | 说明 |
|---|---|---|
| maximum-pool-size | 20 | 单机部署够用,过大反而增加数据库上下文切换 |
| minimum-idle | 5 | 保留常驻连接,避免流量突增时重复建连 |
| map-underscore-to-camel-case | true | 数据库下划线字段自动映射Java驼峰属性 |
| log-impl | StdOutImpl | 开发期打印SQL,上线前换成 NoLoggingImpl 或注释 |
逻辑删除配置让 applicationMapper 的删除操作自动变成 update,对投递记录这种需要留痕的表很有用。分页插件必须有一个配置类显式注册,否则selectPage会退化成内存分页:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }没有这个类,selectPage虽然不报错,但会退化成查全表后在内存中手动分页。数据量小的时候看不出问题,t_position 里插两万条测试数据之后,页面会明显卡顿。答辩演示阶段插数据验证性能时,这一步是首要检查项。
注意:
log-impl: StdOutImpl会在控制台逐条打印SQL,方便调试,但上线前必须关闭,否则日志文件一天能涨到几百MB。
3.3 JWT Token 校验与登录用户上下文注入
登录接口校验账号密码通过后,签发一个包含 userId 和 role 的 token。后续请求在拦截器里解 token,把用户信息放进 request attribute,业务层直接从参数拿当前用户,避免每个接口手动接收 token 再解析。
@Component public class JwtInterceptor implements HandlerInterceptor { @Value("${jwt.secret}") private String secret; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } try { SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); Claims claims = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token.substring(7)) .getBody(); request.setAttribute("userId", claims.get("userId", Long.class)); request.setAttribute("role", claims.get("role", Integer.class)); return true; } catch (JwtException | IllegalArgumentException e) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"token无效或已过期\"}"); return false; } } }拦截器先放行 OPTIONS 预检请求,然后从 Header 中取 Authorization Bearer token;解析失败统一返回 401 JSON,不让异常抛到全局处理器变成一屏堆栈。Keys.hmacShaKeyFor要求密钥长度不少于 32 字节,jwt.secret 建议直接放一个 32 位以上的随机串,不要用 "abc123",否则会直接抛 WeakKeyException。注册拦截器时注意排除登录注册接口,否则把自己锁在门外:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login", "/api/auth/register", "/api/position/list"); } }这段配置里,职位列表接口排除在鉴权之外,因为未登录用户也能浏览职位;但投递、简历、面试相关接口全部走拦截器。角色校验在 Controller 层用注解或手动判断 role 值,比如发布职位只允许 role=1 的账号访问,管理员接口只允许 role=2,逻辑简单且可以精确控制到每个接口。
4. 职位搜索、简历投递与面试邀约的实现细节
4.1 职位多条件搜索与分页的正确写法
职位搜索是求职者端打开频率最高的接口,需要支持按行业、城市、薪资区间、关键词的组合查询,并带有分页。MyBatis-Plus 的 LambdaQueryWrapper 用条件函数包装查询条件,避免手动拼接 SQL 时出现类似where 1=1的写法。
public Page<PositionVO> searchPosition(PositionQuery query) { Page<Position> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Position> wrapper = new LambdaQueryWrapper<Position>() .eq(StringUtils.hasText(query.getIndustry()), Position::getIndustry, query.getIndustry()) .eq(StringUtils.hasText(query.getCity()), Position::getCity, query.getCity()) .ge(query.getMinSalary() != null, Position::getSalaryLow, query.getMinSalary()) .le(query.getMaxSalary() != null, Position::getSalaryHigh, query.getMaxSalary()) .like(StringUtils.hasText(query.getKeyword()), Position::getTitle, query.getKeyword()) .eq(Position::getStatus, 1) .orderByDesc(Position::getCreateTime); IPage<Position> result = positionMapper.selectPage(page, wrapper); return PageResult.from(result); }StringUtils使用 Spring 自带的org.springframework.util.StringUtils.hasText,条件为 false 时,MyBatis-Plus 会跳过该条件,不拼进 SQL。薪资过滤用ge和le分别对应“最低薪资不低于下限”和“最高薪资不高于上限”,职位表薪资拆成 low 和 high 两个字段,用区间判断比单一字段更符合业务直觉。keyword 用 like 搜 title 即可,不要拖上 description 和 requirement 两个大字段,避免模糊匹配直接让索引失效。
这里有两个隐藏问题。第一,薪资单位是 K 时,前端传 15 代表 15K,后端直接用整数比较,不要因为前端传的是字符串而多做一个无意义的类型转换;第二,orderByDesc(Position::getCreateTime)依赖索引,建表时idx_status_create_time已经包含 create_time,上架状态加时间排序恰好命中索引。
4.2 简历投递状态机与防重复校验
投递行为是求职者端最核心的写操作。设计上我用一个 t_application 表记录每次投递,状态码从 0 到 3,状态流转规则如下:
| 状态码 | 状态名 | 触发动作 | 求职者端展示文案 |
|---|---|---|---|
| 0 | 待查看 | 求职者向职位发起投递 | 已投递,等待目标企业查看 |
| 1 | 已查看 | 招聘单位打开投递详情 | 目标企业已查看,请留意后续通知 |
| 2 | 面试邀约 | 招聘单位创建面试安排 | 收到面试邀约,请确认时间 |
| 3 | 已拒绝 | 招聘单位标记不合适 | 暂未匹配当前岗位需求 |
状态机的核心落在投递这个动作上,Service 层需要做一层顺序校验:
@Transactional(rollbackFor = Exception.class) public Long applyPosition(Long userId, Long positionId) { Resume resume = resumeMapper.selectOne(new LambdaQueryWrapper<Resume>() .eq(Resume::getUserId, userId)); if (resume == null) { throw new BusinessException(400, "请先完善个人简历"); } Long count = applicationMapper.selectCount(new LambdaQueryWrapper<Application>() .eq(Application::getResumeId, resume.getId()) .eq(Application::getPositionId, positionId)); if (count > 0) { throw new BusinessException(400, "您已投递过该职位,请勿重复投递"); } Position position = positionMapper.selectById(positionId); if (position == null || position.getStatus() != 1) { throw new BusinessException(400, "该职位已下架"); } Application application = new Application(); application.setResumeId(resume.getId()); application.setPositionId(positionId); application.setStatus(0); applicationMapper.insert(application); return application.getId(); }这段代码的关键点有三个。事务注解保证插入失败时前面的校验不产生副作用;防重复投递的判断放在 Java 层而不是数据库唯一约束,原因是重复投递需要返回可读的业务文案,单纯靠唯一索引报 DuplicateKeyException 会让前端很难处理;职位下架检查放在计数之后,虽然多一次查询,但拦截判断的顺序更符合业务直觉。答辩时这段逻辑可以直接展开,不需要额外修饰。
4.3 面试邀请的业务流转
面试模块的粒度到“邀请-确认-完成”即可。招聘单位在投递详情页点击“邀请面试”,后端在 t_interview 表插入一条记录,同时把 t_application.status 更新为 2;求职者端看到待确认的面试邀请,确认后状态改为已确认;面试结束招聘单位录入结果,状态改为已完成。
public void inviteInterview(Long applicationId, String address, LocalDateTime interviewTime, String contact) { Application application = applicationMapper.selectById(applicationId); if (application == null || application.getStatus() < 1) { throw new BusinessException(400, "候选人简历未被查看,不能发起面试邀约"); } Interview interview = new Interview(); interview.setApplicationId(applicationId); interview.setPositionId(application.getPositionId()); interview.setAddress(address); interview.setInterviewTime(interviewTime); interview.setContact(contact); interview.setRound(1); interview.setStatus(0); interviewMapper.insert(interview); Application update = new Application(); update.setId(applicationId); update.setStatus(2); applicationMapper.updateById(update); }这里的状态校验是为了保证招聘单位不会对一个已拒绝的候选人发起邀约。application.getStatus() < 1把状态 0 排除在邀约条件之外,同时允许已查看(1)和已处于面试状态(2)的记录继续追加邀请,比如一面完成后约二面。如果后续需求要支持多轮面试,t_interview 的 round 字段从 1 递增,面试表与投递表的 1:N 关系也能容纳,不需要改表结构。
5. 一键部署脚本、上线自检与性能兜底
5.1 三个 bat 脚本的分工:install / run / build
工程解压后常见到1-install.bat、2-run.bat、3-build.bat三个文件,这是把初始化、运行、打包分开的三个入口。我一般按下面这个逻辑组织:
@echo off REM 1-install.bat 首次安装:初始化数据库并拉取依赖 chcp 65001 echo [1/3] 检测 MySQL 服务... net start | find "MySQL" > nul if errorlevel 1 net start MySQL80 echo [2/3] 创建数据库与表结构... mysql -uroot -proot -e "CREATE DATABASE IF NOT EXISTS job_analysis DEFAULT CHARSET utf8mb4;" < sql/init.sql echo [3/3] 编译并安装本地依赖... call mvn clean install -DskipTests echo 安装完成 pause@echo off REM 2-run.bat 日常启动 chcp 65001 set APP_JAR=target\job-analysis-0.0.1-SNAPSHOT.jar if not exist %APP_JAR% ( echo 未找到JAR包,请先执行 1-install.bat pause exit /b 1 ) start "job-analysis" java -jar %APP_JAR% --server.port=8080 echo 服务启动中,本地访问 http://localhost:8080@echo off REM 3-build.bat 打包产出 chcp 65001 call mvn clean package -DskipTests if errorlevel 1 ( echo 打包失败 pause exit /b 1 ) copy /y target\job-analysis-0.0.1-SNAPSHOT.jar dist\app.jar echo 打包成功,产物位于 dist\app.jar pauseinstall 只在第一次部署时执行,负责拉起 MySQL、导入初始化 SQL 和编译项目;run 是日常启动入口,先检查 jar 是否存在再拉起服务,避免空跑的窗口一闪而过;build 在改完代码要出部署产物时使用。chcp 65001把控制台代码页切到 UTF-8,否则 bat 里的中文注释在 Windows 上会乱码。三个脚本边界是“安装一次、运行多次、按需构建”,实际部署时把 sql 目录放到工程根目录即可。
5.2 上线前必查的连接池与索引参数
把前面几章涉及到的连接坑汇总成一张自查表:
| 现象 | 优先排查项 |
|---|---|
| 连接报 Access denied | 账号密码、host 是否允许远程 |
| 抛 time zone 异常 | 连接 URL 缺 serverTimezone=Asia/Shanghai |
| 中文乱码 | 连接 URL 缺 characterEncoding=utf8 |
| Public Key Retrieval is not allowed | 连接 URL 缺 allowPublicKeyRetrieval=true |
| 端口被占用 | 换端口或 netstat -ano 找 PID 关闭进程 |
索引方面建议在两万条测试数据量时自查一遍执行计划:
EXPLAIN SELECT * FROM t_application WHERE position_id = 1 AND status = 0;如果type列是 ALL,说明索引没有命中,需要确认联合索引是否建立、字段顺序是否符合最左前缀原则。position_id, status这个顺序能同时满足按职位过滤和按职位加状态过滤两种查询;如果建反成status, position_id,单独按 position_id 查询时索引直接失效。这个细节在演示时用两万条数据验证,说服力要比口头描述强很多。
本文还有配套的精品资源,点击获取