每年毕业季都能看到不少人在各个技术社区问Java毕设到底做什么题目好我的回答通常很直接——如果你想要一个既有技术深度、又贴近真实业务场景、还能把简历写漂亮的项目就业信息发布网系统这个方向值得认真考虑。它的业务模型不复杂但完整涵盖用户认证、信息发布、检索匹配、投递管理、后台审核等典型模块既能体现Spring Boot的核心功底又不会因为业务过于宏大导致做不完。我这次带一个学员完整从零搭了一套基于Spring Boot的就业信息发布网系统包括整套源码、数据库脚本和毕业设计论文的写作框架。这篇文章把整个实战过程、核心模块拆解、代码实现和踩过的坑全部整理出来方便打算选这个题目的同学直接参考。1. 就业信息发布网系统的整体设计与技术选型1.1 业务需求梳理三类用户、三条主流程在动笔写代码之前先花时间把用户角色和核心流程捋清楚这比急着启动项目重要十倍。就业信息发布网表面上是一个信息展示平台但深入看它天然包含三个角色每个角色都有自己的操作闭环。求职者学生/个人用户需要注册登录、完善简历、浏览职位、按条件检索职位、投递简历、查看投递反馈企业方需要注册企业账号、发布职位、管理在招岗位、查看收到的简历、更新招聘状态平台管理员则负责用户审核、职位审核、分类管理、数据统计、公告发布。三条主线缺一不可角色划分直接决定了后面的表结构设计和权限控制逻辑。我在跟学员做需求梳理时习惯用“用户故事”的方式把功能点列出来。比如“作为一个求职者我希望按城市和岗位名称搜索职位这样可以快速筛选出合适的机会。”把这些故事整理成表格后就形成了一张完整的功能清单后续建表、写接口、做页面都有了明确依据。1.2 为什么选Spring Boot企业级开发的黄金标准技术选型是整个项目中第一个要决策的问题。市面上Java方向的后端框架很多但Spring Boot成为毕业设计和中小型项目的首选不是没道理的。它解决了传统SSH/SSM框架配置繁琐、依赖冲突多、部署步骤复杂等痛点内置了Tomcat打一个jar包就能直接跑起来这对毕设项目来说极其友好——评委看到你会在演示时一键启动项目好感度会明显提升。Spring Boot还提供了完善的生态整合能力。拿这个就业系统举例我们使用Spring Boot 2.7.x版本为基础整合了MyBatis-Plus作为持久层框架、Spring Security做权限认证、Thymeleaf模板引擎渲染管理后台页面、MySQL存储业务数据。这套组合拳覆盖面广、学习成本低、社区资料多遇到问题时搜索引擎基本都能找到解决方案。和Gin、Flask等轻量框架相比Spring Boot虽然启动占用资源略高但它对事务管理、类型安全、项目管理上的规范约束恰好能帮助毕业生建立起企业级开发的基本思维。面试官看到简历上写着Spring Boot MyBatis-Plus MySQL的技能组合也会认为你有基本的后端开发能力。为了增强项目的差异化优势还可以在非核心模块加上Redis缓存热点职位数据把Spring Boot的扩展能力体现出来。1.3 前端方案的取舍直接采用服务端渲染快速出效果很多人在做这类系统时会纠结一个问题前端到底用不用Vue这类框架做前后端分离我带学员时通常给一个务实的建议——如果主要目标是完成一个功能完整、能稳定演示的系统优先使用服务端渲染方案具体是Thymeleaf Bootstrap jQuery的组合。Thymeleaf最大的优势在于服务端把数据渲染好以后直接把HTML页面返回浏览器开发过程中不需要处理跨域问题、不需要额外搭建Node环境、不需要维护前后端两套项目代码。对于一个人完成的毕设来说这能节省大量联调和沟通成本。Bootstrap则让我们在不写复杂CSS的前提下借助现成的栅格系统和组件库就把页面做得干净整齐。即便是没有系统学过前端的同学也能在两三天内搭出完整的界面效果。比如职位列表页在服务端通过MyBatis-Plus分页查询出数据放到Model里对应的position-list.html中用th:each遍历输出代码非常直观table classtable table-hover thead tr th职位名称/th th企业名称/th th工作城市/th th薪资范围/th th发布时间/th th操作/th /tr /thead tbody tr th:eachposition : ${page.records} td th:text${position.title}/td td th:text${position.companyName}/td td th:text${position.city}/td td th:text${position.salaryMin} - ${position.salaryMax}/td td th:text${#temporals.format(position.createTime, yyyy-MM-dd)}/td td a th:href{/position/detail/ ${position.id}} classbtn btn-sm btn-primary查看详情/a /td /tr /tbody /table1.4 项目目录结构包组织方式直接影响代码可维护性好的项目结构能让你在写论文时少花很多力气也能让指导老师在审阅代码时第一时间看到你的工程素养。我建议严格按照分层架构把代码按职责切分清楚。com.university.job ├── common │ ├── Result.java // 统一返回体 │ ├── ResultCode.java // 错误状态码 │ └── GlobalExceptionHandler.java // 全局异常处理 ├── config │ ├── SecurityConfig.java // Spring Security配置 │ ├── MybatisPlusConfig.java // MyBatis-Plus配置 │ └── WebMvcConfig.java // 拦截器、资源映射配置 ├── controller // 接口入口层 ├── service // 业务逻辑层 │ └── impl ├── mapper // 数据访问层 ├── entity // 实体类 ├── dto // 前端传参/返回参数封装 ├── vo // 视图对象组合多表数据 ├── utils // 工具类 └── JobApplication.java // 启动类在代码目录结构上我看到不少人喜欢一股脑把所有类都放到controller包下最多加一个entity包整个项目几百个文件堆在一起。这种方法写完确实快但后续排查问题、完善论文里的系统设计图时会很头疼。分层架构看似多写几个包但类之间的协作关系和调用链路清晰多了这对毕业答辩时讲解系统架构也有很大帮助。2. 数据库设计与核心表结构实现2.1 建表思路围绕业务闭环设计核心数据模型数据库设计是整个系统最基础、也最容易出问题的环节。如果表结构设计不合理后面写代码时会处处别扭业务逻辑只能用大量if else去弥补结构上的缺陷。就业信息发布网的建表要围绕三条角色主线展开同时把角色之间的交互关系通过外键逻辑关联起来。我设计的核心表一共有9张用户表user、企业信息表company、职位表position、简历表resume、投递记录表delivery_record、收藏表favorite、职位类别表category、公告表notice、管理员操作日志表operation_log。其中用户表和企业表之间是一对一关系一个注册用户可以维护一条企业扩展信息职位表和企业表是多对一关系一家企业可以发布多个职位投递记录表连接职位表和简历表形成用户投递的行动轨迹。2.2 关键表字段说明从代码生成到业务落地的真实设计以职位表position为例字段设计直接决定业务功能能否顺畅实现。除了id、title、description这类常规字段以外我特别强调几个容易被忽略的字段设计思路CREATE TABLE position ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键ID, company_id bigint NOT NULL COMMENT 发布企业ID, category_id bigint DEFAULT NULL COMMENT 职位分类ID, title varchar(100) NOT NULL COMMENT 职位名称, description text COMMENT 职位描述, city varchar(50) DEFAULT NULL COMMENT 工作城市, salary_min decimal(10,2) DEFAULT NULL COMMENT 最低薪资, salary_max decimal(10,2) DEFAULT NULL COMMENT 最高薪资, education_required varchar(20) DEFAULT NULL COMMENT 学历要求, work_experience varchar(20) DEFAULT NULL COMMENT 工作经验要求, is_hot tinyint DEFAULT 0 COMMENT 是否热门职位 0否 1是, status tinyint DEFAULT 0 COMMENT 状态 0待审核 1已发布 2已下架, view_count int DEFAULT 0 COMMENT 浏览次数, deadline date DEFAULT NULL COMMENT 招聘截止日期, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_company_id (company_id), KEY idx_city_status (city, status) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT职位信息表;薪资字段我用decimal而不是直接用字符串因为后续可能需要在首页做“按薪资区间筛选”的功能用数字类型才能在SQL里做数值比较。status字段是业务状态标志0代表管理员还没审核1代表已经发布2代表企业主动下架这个字段避免了直接删数据保证了操作的可追溯性。create_time和update_time统一建后面分页排序、论文里的功能展示都用得上。特别说一下联合索引idx_city_status。就业招聘场景最常见的查询是“按城市查正在招聘的职位”如果不对city和status建联合索引表数据量上来以后全表扫描会很慢。尽管毕设数据量不大但建索引这个动作体现的是SQL优化的意识论文里写出来也是加分项。2.3 用户权限表设计用角色字段完成访问控制在用户表设计中我用了最简单的角色控制方案而不是单独设计RBAC权限表。user表中设置一个role字段取值范围为USER求职者、COMPANY企业、ADMIN管理员。后续在Spring Security的配置里通过对URL进行角色匹配来实现访问控制。CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录用户名, password varchar(200) NOT NULL COMMENT 加密密码, phone varchar(20) DEFAULT NULL, email varchar(100) DEFAULT NULL, role varchar(20) DEFAULT USER COMMENT 角色 USER/COMPANY/ADMIN, avatar varchar(255) DEFAULT NULL COMMENT 头像地址, status tinyint DEFAULT 1 COMMENT 状态 1正常 0禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;用户名用户名必须唯一密码字段存储的是BCrypt加密后的结果而不是明文这是安全底线。角色字段在后续编写拦截器时直接通过认证对象获取不需要再查一遍用户表使用起来非常方便。2.4 投递记录表的扩展设计思路投递记录表delivery_record是整个系统中关联性最强的一张表它连接了用户、简历和职位三个维度。每次用户点击“投递简历”按钮系统会在这个表里插入一条记录同时更新status字段来跟踪投递进展。CREATE TABLE delivery_record ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 投递用户ID, position_id bigint NOT NULL COMMENT 职位ID, resume_id bigint DEFAULT NULL COMMENT 使用的简历ID, status tinyint DEFAULT 0 COMMENT 状态 0待查看 1已查看 2已邀约 3已拒绝, delivery_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_position (user_id, position_id), KEY idx_position_id (position_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投递记录表;uk_user_position这个唯一索引很有讲究避免同一个用户对同一个职位重复投递。在Service层我还会先做一次查询校验双重保险避免脏数据。对于毕设系统来说加上这个唯一索引意味着数据库层面已经限制住了重复投递就算代码有bug也不至于出现多条一模一样的记录。这样即使被评审老师质疑“如果用户重复点击投递按钮怎么办”也能从数据库约束和技术设计两个层面给出有力回答。3. 核心功能模块实现与代码实战3.1 用户注册登录Spring Security JWT的无状态认证方案用户注册登录是每个系统都有的功能但实现方式差异巨大。在这套就业信息发布网系统中我基于常见的毕业设计场景做了适配采用JWTJSON Web Token的无状态认证方案。为什么要用JWT而不是传统的Session传统Session方案在服务端保存会话每个请求带着JSESSIONID去服务端查session。这在单体项目中用着没毛病但它的缺点也很明显——服务端需要维护大量session状态而且写接口文档时不够直观。JWT的核心思路是把部分用户信息加密后生成一个token客户端在请求头里携带这个token服务端解析token即可完成身份识别无需查询session。这种方案非常契合前后端交互的场景也方便以后往微服务架构演进。JWT工具类的核心代码不复杂关键是注意token过期时间、密钥管理和解析时的异常处理。Component public class JwtUtils { private static final String SECRET your-secret-key-2024-job-platform; private static final long EXPIRE_TIME 1000 * 60 * 60 * 24; // 24小时 public String generateToken(Long userId, String username, String role) { Date now new Date(); Date expireDate new Date(now.getTime() EXPIRE_TIME); return Jwts.builder() .setHeaderParam(typ, JWT) .setSubject(username) .claim(userId, userId) .claim(role, role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } public boolean isTokenExpired(String token) { Claims claims parseToken(token); return claims.getExpiration().before(new Date()); } }在Spring Security的配置类里需要注册JWT过滤器。这个过滤器会从请求头里获取token解析后把用户信息放进SecurityContext中实现一次请求的临时登录状态。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtUtils jwtUtils; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header request.getHeader(Authorization); if (StringUtils.hasText(header) header.startsWith(Bearer )) { String token header.substring(7); try { Claims claims jwtUtils.parseToken(token); Long userId claims.get(userId, Long.class); String username claims.getSubject(); String role claims.get(role, String.class); ListSimpleGrantedAuthority authorities Collections.singletonList(new SimpleGrantedAuthority(ROLE_ role)); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(username, null, authorities); authentication.setDetails(new CustomUserDetail(userId, username, role)); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // token不合法则不设置认证信息交给后续鉴权逻辑处理 } } filterChain.doFilter(request, response); } }3.2 职位发布与审核企业与管理员的协同工作流职位发布功能涉及企业和管理员两个角色的协作。企业端提交职位信息后不能直接上架展示需要先经过管理员审核。这个审核机制从业务角度模拟了真实招聘平台的运营规则从技术角度也形成了多角色间状态机流转的典型案例。企业发布职位的Controller层代码大致是这样PostMapping(/company/position/add) public Result addPosition(RequestBody PositionAddDto dto, HttpServletRequest request) { CustomUserDetail detail SecurityUtil.getCurrentUser(request); if (detail null) { return Result.error(ResultCode.UNAUTHORIZED); } Position position new Position(); BeanUtils.copyProperties(dto, position); position.setCompanyId(detail.getUserId()); position.setStatus(0); // 待审核 position.setViewCount(0); positionService.save(position); return Result.success(职位提交成功等待平台审核); }管理员审核后职位状态从0变为1求职者才能在前台搜索到。有状态的业务数据在做论文时也更容易写出业务流程图这是选题时值得考虑的一点。3.3 职位检索与分页MyBatis-Plus条件构造器的实践职位搜索是就业系统最核心的查询场景。求职者通常会按关键词职位名称、城市和分类做组合筛选。这里如果直接手写SQL拼接条件一多会非常繁琐且容易漏条件。MyBatis-Plus的LambdaQueryWrapper帮了大忙代码可读性和扩展性都好了很多。public PagePositionVo searchPositions(String keyword, String city, Long categoryId, int pageNum, int pageSize) { PagePosition page new Page(pageNum, pageSize); LambdaQueryWrapperPosition wrapper new LambdaQueryWrapper(); wrapper.eq(Position::getStatus, 1) .and(StringUtils.hasText(keyword), w - w.like(Position::getTitle, keyword) .or() .like(Position::getDescription, keyword)) .eq(StringUtils.hasText(city), Position::getCity, city) .eq(categoryId ! null, Position::getCategoryId, categoryId) .orderByDesc(Position::getCreateTime); PagePosition result positionMapper.selectPage(page, wrapper); return convertToVo(result); }这里有一个容易踩坑的地方wrapper.eq(status, 1)是为了只展示已审核通过的职位但如果后续在管理后台也想复用这个查询方法就会导致管理员无法看到待审核职位。一个更好的做法是调整设计为私有方法searchPositionsByStatus(status, ...)把状态作为入参让不同入口传不同的状态值。我的建议是想把复用的查询逻辑抽成方法时先问问自己这个方法在所有调用场景的公共异常分支是否一致而不是盲目追求代码复用。另外LambdaQueryWrapper比普通QueryWrapper的优势在于编译期字段校验。如果实体类中改了属性名普通字符串方式查不出来问题但Lambda方式在编译阶段就会报错可以提前发现代码错误。3.4 简历管理与投递流程事务操作的落地实现简历管理和投递流程是一对联动功能。求职者完善简历后才能在职位详情页点击“投递”。投递操作至少涉及两步查询简历是否存在、插入投递记录。为了确保数据一致性我在这两步上加了Transactional注解避免递归前后两步操作之间的异常导致数据不一致。Transactional(rollbackFor Exception.class) public boolean deliverResume(Long userId, Long positionId) { // 1. 查询用户简历 Resume resume resumeMapper.selectOne( new LambdaQueryWrapperResume().eq(Resume::getUserId, userId)); if (resume null) { throw new BusinessException(请先完善个人简历后再投递); } // 2. 判断职位是否存在且可投递 Position position positionMapper.selectById(positionId); if (position null || position.getStatus() ! 1) { throw new BusinessException(职位不存在或已下架); } // 3. 检查是否重复投递 Long count deliveryMapper.selectCount( new LambdaQueryWrapperDeliveryRecord() .eq(DeliveryRecord::getUserId, userId) .eq(DeliveryRecord::getPositionId, positionId)); if (count 0) { throw new BusinessException(您已投递过该职位请勿重复投递); } // 4. 插入投递记录 DeliveryRecord record new DeliveryRecord(); record.setUserId(userId); record.setPositionId(positionId); record.setResumeId(resume.getId()); record.setStatus(0); deliveryMapper.insert(record); // 5. 职位浏览次数1投递数1这里顺便做了个统计 positionMapper.incrementDeliveryCount(positionId); return true; }写到这里我最想分享的一个经验是不要在一个事务里做太多与业务无关的操作。比如发送通知消息、调用外部短信接口这类耗时的操作如果实习服务比较耗时会导致事务时间过长并发时出现锁冲突。这套毕业设计系统里投递简历事务就只保留核心的查询和插入逻辑统计浏览数字这种低频操作即使失败也不影响主流程。3.5 站点数据总览让领导一眼看懂系统价值的看板为了把系统从“能用”提升到“好看”的层次我在企业端和管理员端都加了一个数据看板模块。顶部蜂鸣计数卡片分别显示职位总数、求职者总数、企业总数、今日投递数中间用简单条形图展示最近7天各职位收到投递的数据。ECharts是一个好选择用法非常简单从CDN引入后拿到数据初始化图表即可div iddeliveryChart stylewidth: 100%; height: 300px;/div script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script script fetch(/api/company/delivery/trend) .then(res res.json()) .then(data { var chart echarts.init(document.getElementById(deliveryChart)); chart.setOption({ title: { text: 近7天职位投递趋势 }, tooltip: {}, xAxis: { data: data.dates }, yAxis: {}, series: [{ name: 投递数, type: bar, data: data.counts }] }); }); /script后端对应的查询接口只需要按日期维度去投递记录表做分组统计。使用MySQL的DATE_FORMAT函数按天聚合非常方便GetMapping(/api/company/delivery/trend) public Result getDeliveryTrend(HttpServletRequest request) { CustomUserDetail detail SecurityUtil.getCurrentUser(request); ListMapString, Object list deliveryMapper.countByDate(detail.getUserId(), 7); // 返回 dates 数组和 counts 数组 }对应mapper里的SQL是select idcountByDate resultTypemap SELECT DATE_FORMAT(delivery_time, %Y-%m-%d) AS date, COUNT(*) AS count FROM delivery_record WHERE position_id IN ( SELECT id FROM position WHERE company_id #{companyId} ) AND delivery_time DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE(delivery_time) ORDER BY date ASC /select4. 系统安全防护与上线部署4.1 密码加密与XSS过滤毕设也要有安全底线很多同学觉得毕设项目往内网一放就行不需要考虑安全问题。这种观念在毕业设计中很容易被评审老师抓住弱点。至少在密码存储、SQL注入、XSS攻击这三个方面我给这个系统全部加了防护。密码加密使用的是Spring Security内置的BCryptPasswordEncoder。BCrypt算法自动加盐即使两个用户的密码相同加密后的密文也不一样可以有效防止彩虹表攻击。用户注册时调用encoder.encode(password)存储登录时调用encoder.matches(rawPassword, encodedPassword)验证。不建议使用MD5因为MD5在如今的计算能力下已经可以被轻松破解。XSS跨站脚本攻击的防护主要在入口处加了一个全局过滤器对请求参数中的特殊字符进行清洗。比如把
