
简介基于SpringBoot与Thymeleaf实现的招聘系统完整项目面向Java Web初学者、正在准备求职面试的开发者以及需要完成毕业设计的高校学生旨在帮助读者掌握SpringBoot自动配置、服务端模板渲染与持久层开发的综合运用。资源包共1116个文件、44.07MB涵盖Java源码、class编译文件、HTML页面、CSS与JS样式脚本、gif和png图片素材、XML及YML配置、JAR依赖库等目录结构清晰便于按功能模块阅读。该项目包含用户注册登录、职位发布与检索、简历投递管理、个人中心等典型业务模块结合Thymeleaf在服务端动态渲染页面并集成Redis缓存与Spring Security权限控制适合作为毕业设计蓝本或二次开发基础。研读代码可系统理解SpringBoot的分层架构、数据库交互流程、安全认证配置以及前后端数据传递方式对提升企业级Java项目开发能力有明显帮助。目前已有103人学习/下载。1. 为什么是SpringBootThymeleaf来写招聘系统做招聘类系统时很多团队会先纠结要不要上前后端分离。如果目标是快速落地职位发布、简历投递、HR后台管理这个闭环而不是给App提供接口SpringBootThymeleaf这种服务端渲染方案反而更省事。页面由Controller和模板引擎在服务端生成浏览器能直接收录也不存在跨域问题对“java springboot 毕业设计”这种场景特别合适。SpringBoot负责把数据库连接、事务、Web容器一次性拉起来Thymeleaf用接近原生HTML的语法在服务端输出页面。这个组合可以只打一个jar包运行不依赖独立前端工程也不需要维护Node构建链。所以它经常出现在招聘管理后台、内部职位门户这类中小型项目里。源码包里出现的UserController、HRController、PositionController、Position和RedisConfiguration正好是一条完整链路普通用户看职位、HR管职位、Redis缓存热点数据。后面几章就按这条链路把项目拆开讲。2. SpringBoot自动装配与Thymeleaf模板渲染SpringBoot把Web项目的基础设施都收编到自动配置里。以招聘系统为例引入spring-boot-starter-web之后DispatcherServlet会由DispatcherServletAutoConfiguration自动装配引入spring-boot-starter-thymeleaf后ThymeleafAutoConfiguration自动注册模板引擎和视图解析器。开发者不用在一个个XML配置文件里找视图解析器只需要按约定把模板放到src/main/resources/templates目录。这个约定在招聘系统里体现得很直接PositionController返回字符串“position/list”时Tomcat执行完控制器逻辑后ThymeleafViewResolver会找到templates/position/list.html把Model中放好的职位数据填进去再作为HTML响应给浏览器。如果打开页面发现改了模板不生效先看是不是thymeleaf.cache没有关闭本地开发一定要把它设为false。2.1 起步依赖与自动配置pom.xml与application.yml招聘系统最怕在项目搭建阶段卡住。SpringBoot把这种卡顿压缩到了一个起步依赖里。在pom.xml中加spring-boot-starter-web之后内嵌Tomcat和SpringMVC都会自动装好再加spring-boot-starter-thymeleaf模板引擎也就绪了。一个常见的做法是使用这样的maven坐标parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.13/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies这段依赖里spring-boot-starter-parent统一管理版本号所以子依赖可以不写版本。spring-boot-starter-thymeleaf会自动引入Thymeleaf 3.x以及和SpringBoot匹配的集成包自动配置类会帮你注册视图解析器。如果在IDEA里创建SpringBoot项目时一直卡在下载依赖多半是Maven仓库地址问题第5章会讲镜像切换方法。对应的application.yml可以这样写spring: application: name: recruit-system thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html encoding: UTF-8 datasource: url: jdbc:mysql://localhost:3306/recruit?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true这里的ddl-auto: update表示Hibernate会根据Position实体自动更新表结构表不存在就建表。如果项目的数据访问层换成MyBatis就没有这个自动建表能力需要自己准备schema.sql或者交给DBA处理。表里几个关键配置如下配置项作用注意点spring.thymeleaf.cache是否缓存模板开发期设false生产环境建议truespring.thymeleaf.prefix模板前缀默认就是classpath:/templates/spring.datasource.urlJDBC连接地址记得加serverTimezone和useUnicodespring.jpa.hibernate.ddl-auto自动建表/更新表生产环境要评估风险spring.jpa.show-sql打印SQL开在日志会更规范2.2 Controller如何把Position数据交给Thymeleaf理解了自动配置之后要弄清楚数据是怎么从后端流到模板的。在SpringMVC里控制器方法的返回值决定视图名Model是数据和视图之间的桥。招聘系统的首页可以这样写Controller public class PositionController { private final PositionRepository positionRepository; public PositionController(PositionRepository positionRepository) { this.positionRepository positionRepository; } GetMapping(/) public String index(Model model) { model.addAttribute(positions, positionRepository.findTop10ByOrderByPublishTimeDesc()); return index; } }这里用的是Controller而不是RestController。SpringBoot面试题里经常问区别RestController是Controller加ResponseBody方法返回值会直接写入HTTP响应体不会走视图解析器。招聘系统要返回Thymeleaf模板所以必须用Controller。Model对象最终会放进HttpServletRequest的属性中Thymeleaf的${positions}就是从它里面取数据的。PositionRepository返回的ListPosition会被自动填充到Model中。这里故意把查询方法写成findTop10ByOrderByPublishTimeDesc()语义很直观取按发布时间倒序的前10条职位。如果职位列表多了后面第3章会换成带分页的搜索接口。现在先保持精简下一步把数据渲染到页面。2.3 招聘列表页的th:each与th:if实战当数据进入Model后Thymeleaf模板要做的就是把位置和格式固定下来。招聘网站首页的关键区块一般是“最新职位”可以用一个div循环输出div classjob-list th:if${!#lists.isEmpty(positions)} div classjob-item th:eachpos : ${positions} h3 th:text${pos.title}Java工程师/h3 span th:text${pos.salary}15K-25K/span span th:text${pos.city}北京/span a th:href{/position/{id}(id${pos.id})}查看详情/a /div /div p th:if${#lists.isEmpty(positions)}暂时没有在招职位/pth:each的语法类似Java的增强for循环pos可以理解为循环变量。${pos.title}会调用Position的getTitle()方法th:text负责把标签体替换成表达式结果。{/position/{id}(id${pos.id})}是URL表达式最终生成类似/position/12的地址。用th:if配合#lists.isEmpty()做空列表判断比在Java代码里把字符串拼好再输出要干净得多。这里有一个容易踩的坑如果positions本身是null直接访问${#lists.isEmpty(positions)}在旧版Thymeleaf可能会报错。常见的做法是让Repository永远返回空集合而不是返回null这样模板层就不用做双重判断。招聘系统里职位列表这种查询JPA返回空List是很自然的行为所以用上面的写法是安全的。到这里SpringBoot自动装配和Thymeleaf模板渲染的调用链就完整了依赖进入classpath后自动配置生效Controller把Position塞进Model模板引擎按名称找到HTML并输出。3. 数据访问与缓存Position、Repository与RedisConfiguration招聘系统的数据访问层围绕两件事展开Position实体怎么落库热点职位怎么缓存。源码包里的Position.class是领域模型RedisConfiguration.class是Redis配置类。先看实体定义再看Repository最后把Redis加进来。3.1 Position实体与JPA Repository的CRUD在JPA设计里实体类与表是一一映射的。招聘系统的Position可以这样建Entity Table(name position) public class Position { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name title, nullable false, length 128) private String title; private String city; private String description; Column(name salary_min) private Integer salaryMin; Column(name salary_max) private Integer salaryMax; Column(name publish_time) private LocalDateTime publishTime; Column(name status) private Integer status 1; // getter/setter省略 }GeneratedValue(strategy GenerationType.IDENTITY)表示使用数据库自增主键MySQL场景最常见。Column(namesalary_min)把驼峰属性映射到底线字段。这里把薪资拆成最低和最高两个整数比直接用字符串“15K-25K”更适合后续做筛选和统计。status1表示在招0表示已下架这个标记在HR下线职位时会用到。Repository接口的写法是Spring Data JPA的核心public interface PositionRepository extends JpaRepositoryPosition, Long { ListPosition findTop10ByOrderByPublishTimeDesc(); ListPosition findByStatusAndCity(Integer status, String city); Query(select p from Position p where (:keyword is null or p.title like %:keyword% or p.description like %:keyword%) and (:city is null or p.city :city) and p.status 1) PagePosition search(Param(keyword) String keyword, Param(city) String city, Pageable pageable); }方法名findByStatusAndCity会被Spring Data JPA解析成where status ? and city ?不需要写实现。当查询条件增多后方法名会变得又长又难读所以第二个方法直接用Query写JPQL。(:keyword is null or p.title like %:keyword%)这种写法允许keyword为空时不加过滤条件参数为空也不会导致SQL异常。PagePosition配合Pageable能直接返回分页结果招聘系统的搜索页就靠它。如果下载的资源是MyBatis版本ddl-auto: update就不存在需要自己在resources/schema.sql里建表或者用一个初始化脚本在应用启动时执行。这个差异是招聘系统源码包最常见的改造点拿到项目后先确认数据访问层用的是JPA还是MyBatis。3.2 RedisConfiguration缓存序列化配置RedisConfiguration.class在招聘系统里的作用主要是定制RedisTemplate序列化方式。默认的RedisTemplate使用JdkSerializationRedisSerializer缓存到Redis里的数据是一长串带类型信息的二进制用可视化工具查看非常难受而且跨语言读取困难。给招聘系统的缓存配置可以这样写Configuration public class RedisConfiguration { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); StringRedisSerializer stringSerializer new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }key用String序列化value用JSON序列化这是我在项目里的常用做法。GenericJackson2JsonRedisSerializer会在JSON中写入类信息反序列化时能还原成原来的Position对象缺点是class信息会占一点空间但招聘系统的数据结构不复杂这个代价可以接受。afterPropertiesSet()用于确保初始化完成后各个属性真正生效。对应的Redis连接配置写在application.yml里spring: redis: host: localhost port: 6379 database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8lettuce是SpringBoot默认的Redis客户端max-active控制连接池上限。在招聘系统中热门职位列表的缓存key可以设计成recruit:position:hot设置10分钟过期。用Spring Cache可以少写很多代码Cacheable(value position, key hot, unless #result null || #result.size() 0) public ListPosition getHotPositions() { return positionRepository.findTop10ByOrderByPublishTimeDesc(); }注意Cacheable要求SpringBoot入口类或配置类上加EnableCaching。unless表示当返回结果是null或空集合时不缓存避免把无意义的空列表缓存到Redis。招聘系统里职位数据变更不频繁10分钟过期足够保证HR发布新职位后不会太久才看到变化。3.3 职位筛选分页给列表页加上查询参数招聘系统最常见的场景是用户按城市、关键词、薪资范围筛选职位。Controller接收查询参数构造Pageable调用Repository的search方法GetMapping(/positions) public String list(RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size, RequestParam(required false) String keyword, RequestParam(required false) String city, Model model) { Pageable pageable PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, publishTime)); PagePosition result positionRepository.search(keyword, city, pageable); model.addAttribute(page, result); return positions; }PageRequest.of(page - 1, size, sort)第一页传的是0但用户习惯从1开始所以Controller里做一次减一转换。Sort.by(Sort.Direction.DESC, publishTime)表示按发布时间倒序。这里没有处理薪资范围参数因为真正靠谱的薪资筛选是把salaryMin和salaryMax放进查询条件在JPQL里做p.salaryMax :min和p.salaryMin :max而不是用like匹配字符串。请求参数可以整理成一张表方便前端联调时对照参数类型必填说明pageint否页码从1开始默认1sizeint否每页条数默认10keywordstring否职位名称或描述关键词citystring否工作城市精确匹配minSalaryint否最低薪资需自行加入JPQLmaxSalaryint否最高薪资需自行加入JPQL分页结果在前端Thymeleaf里通常要渲染页码。${page.totalPages}是总页数${page.number 1}是当前页${page.totalElements}是总条数。翻页链接保持住查询条件比较烦人一般会把keyword和city放进th:href的query参数里例如{/positions(page${i}, keyword${keyword}, city${city})}。注意到page从0开始模板里要显示页码时要加1构造链接时不需要因为PageRequest会再减一次。4. 登录与权限UserController与HRController的职责边界招聘系统里用户和HR的行为边界不一样。普通用户可以注册、登录、投简历、收藏职位HR只能进入后台发布职位、筛选候选人。源码包里把UserController和HRController分开就是为了让权限控制有一个明确切分点。这一章从Spring Security的认证流程讲到方法级权限控制再到页面上按角色显示按钮。4.1 用SecurityFilterChain做登录认证Spring Security 5.7之后官方推荐用SecurityFilterChain而不是继承WebSecurityConfigurerAdapter。招聘系统中的配置可以这样写Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth - auth .requestMatchers(/, /positions, /position/**, /register, /login, /css/**, /js/**).permitAll() .requestMatchers(/hr/**).hasRole(HR) .anyRequest().authenticated() ) .formLogin(form - form .loginPage(/login) .defaultSuccessUrl(/, true)) .logout(logout - logout .logoutSuccessUrl(/)); return http.build(); } }permitAll()放行了招聘系统的首页、职位详情、注册登录和静态资源。/hr/**要求HR角色其他未匹配的请求只要登录就能访问。formLogin是表单登录登录成功默认跳回首页。hasRole(HR)会自动在当前角色前加ROLE_前缀所以用户表里存的角色字段要写成ROLE_HR或者让UserDetailsService添加时补上前缀。用户注册接口是UserController的核心逻辑。注册时密码必须加密后再存库Controller public class UserController { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public UserController(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository userRepository; this.passwordEncoder passwordEncoder; } PostMapping(/register) public String register(User user) { user.setPassword(passwordEncoder.encode(user.getPassword())); user.setRole(ROLE_USER); userRepository.save(user); return redirect:/login; } }PasswordEncoder用BCryptPasswordEncoder在配置类中声明为Bean即可。BCrypt每次加密生成的密文都不同但matches()可以正确校验这是Spring Security默认推荐的做法。这里不要自己发明MD5加盐算法直接使用官方实现更可靠。user.setRole(ROLE_USER)把角色写死为普通用户注册入口不应该让用户自己传角色字段否则就绕过权限控制了。4.2 基于角色的URL权限控制权限控制只放在URL层还不够HRController内部方法也最好加一道方法级权限。Spring Security的PreAuthorize注解配合EnableMethodSecurity可以实现这个效果。Controller RequestMapping(/hr) public class HRController { PostMapping(/position/publish) PreAuthorize(hasRole(HR)) public String publish(Position position) { position.setStatus(1); positionRepository.save(position); return redirect:/hr/positions; } PostMapping(/position/{id}/offline) PreAuthorize(hasRole(HR)) public String offline(PathVariable Long id) { positionRepository.findById(id).ifPresent(p - p.setStatus(0)); return redirect:/hr/positions; } }PreAuthorize(hasRole(HR))会在进入方法前检查当前登录用户角色。URL层的拦截已经挡住了非HR请求方法级注解的意义在于代码被复用或调用链变化时权限判断不会失效。这是SpringBoot常用注解里比较容易忽略的一个。如果项目里角色不在UserDetails的authorities中即使数据库中用户字段是ROLE_HR注解也校验不过排查时先确认UserDetailsService.loadUserByUsername返回了正确的权限列表。offline方法中findById(id).ifPresent(p - p.setStatus(0))是Java 8的写法避免显式写if判断Position是否存在。这里没有调用repository.save(p)因为findById返回的对象是持久化上下文管理的事务提交时会自动执行update但为了让代码更明确实际项目里还是建议显式save一次。4.3 前端按钮按权限显示操作入口权限控制也涉及页面显示。普通用户访问招聘首页时不应该看到“HR管理后台”入口HR登录后要能看到“发布新职位”按钮。Thymeleaf可以通过Spring Security集成标签做到这一点!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org xmlns:sechttp://www.thymeleaf.org/extras/spring-security body div sec:authorizehasRole(HR) a th:href{/hr/positions}职位管理后台/a a th:href{/hr/position/create}发布新职位/a /div div sec:authorize!isAuthenticated() a th:href{/login}登录/a /div /body /html使用sec:authorize之前需要在pom.xml中加入thymeleaf-extras-springsecurity5或springsecurity6对应的依赖并在Spring Security配置中确保模板渲染时能拿到Authentication对象。sec:authorizehasRole(HR)是服务端渲染时才判断权限不满足时整个div都不会输出不会出现前端源码里能看到隐藏按钮的情况。到这里UserController和HRController的分工就清晰了UserController负责注册和登录HRController负责职位发布与下线Spring Security负责在两端之间划出安全边界。招聘系统的权限设计不必做成动态菜单用注解和标签把入口藏起来已经能覆盖大多数内部系统的需求。5. 拿到源码包后先做这三件事反编译、版本对齐、调通缓存如果下载的招聘系统源码包里只有.class文件不要急着用IDEA打开。先用工具反编译再统一SpringBoot版本最后把数据库和Redis连通这三件事做完项目基本就能跑起来。5.1 先用CFR反编译class文件.class是编译后的字节码直接读没法看业务代码。用CFR这类工具可以把UserController.class还原成可读的Java源码保留方法签名和大部分结构java -jar cfr.jar UserController.class --outputdir ./src/main/java反编译结果不一定100%可编译但招聘系统这种Controller和实体类结构简单还原出来的代码通常只差注解和泛型细节。如果只想快速看方法签名也可以先跑javap -classpath . UserController。我在改源码包时一般先用javap确认Controller的URL路径和参数再决定从哪个方法开始补业务逻辑。5.2 统一SpringBoot版本并配置Maven镜像源码包使用的SpringBoot版本可能和本机环境不一致常见报错是依赖下载失败或启动时Bean冲突。先打开pom.xml确认parent版本再在$HOME/.m2/settings.xml里配置镜像仓库避免IDEA创建项目或下载依赖时超时mirror idaliyunmaven/id mirrorOfcentral/mirrorOf urlhttps://maven.aliyun.com/repository/public/url /mirror如果SpringBoot版本太高部分老项目会出现Failed to introspect Class这类问题此时不要猛升级把版本降到源码包匹配的2.7.x即可。2.7.13是常用版本兼容多数招聘系统的教学代码如果你想用3.x必须同步检查javax与jakarta包名、Spring Security 6的配置差异。spring-boot-maven-plugin的版本也要跟随parent否则打出来的jar可能无法用java -jar直接启动。5.3 检查数据库、Redis与健康检查项目启动前先确认MySQL和Redis在跑。Redis连接失败时启动不会立刻报错第一次访问缓存相关接口才抛RedisConnectionFailureException。用命令先自测redis-cli -h 127.0.0.1 -p 6379 ping mysql -uroot -p -e select 1 curl http://localhost:8080/actuator/healthRedis返回PONG说明连通正常。/actuator/health端点需要先添加spring-boot-starter-actuator依赖不加会返回404。这个端点是SpringBoot面试题里的高频考点健康检查不只告诉我们进程是否活着还会聚合数据库、Redis、磁盘空间的健康状态。启动后如果显示UP再做登录注册和职位发布的完整流程比直接看日志更直观。本文还有配套的精品资源点击获取