☰
基于SpringBoot+SSM+Thymeleaf的兼职平台系统设计与实现
2026/10/10 9:33:42 网站建设 项目流程

做兼职平台这个题,我在不同阶段见过三种做法:一种是纯 JSP + Servlet 硬怼,页面里全是 Java 代码;一种是上来就前后端分离,Vue + Spring Boot 各搞一套,光是跨域和鉴权就折腾一星期;还有一种是拿 Spring Boot + SSM 整合做服务端渲染,配合 Ajax 做局部交互,数据模型清楚,代码结构也规整,应付课程设计、毕业设计甚至小范围上线都够用。

今天要聊的这套"基于 JavaWeb 和 MySQL 的 Spring Boot 兼职平台系统",走的就是第三条路。技术栈是 Java + Spring Boot + SSM + HTML + Thymeleaf + Maven + Ajax + MySQL,典型的老牌组合,但恰恰因为"老",网上资料多、坑少,非常适合作为练手项目来完整走一遍。我接下来说的,不是我凭空想出来的架构,而是我实际把这类项目从零搭到能跑、能部署的一整套过程,包括数据库怎么设计、工程怎么分层、前后端怎么配合、上线踩了哪些坑。

如果你正准备做一个类似的管理系统、信息发布平台,或者你就是在纠结 SSM 和 Spring Boot 到底怎么配合,这篇文章可以直接当参考。

1. 为什么是 Spring Boot + SSM + Thymeleaf:这套技术栈到底给兼职平台带来什么

1.1 业务场景:兼职平台的核心流程

先别急着写代码,先把业务想清楚。兼职平台这个"系统"听起来简单,实际拆开一看,涉及的角色和状态流转比想象中多。

平台面向的核心用户有三类:学生(求职者)、企业(招聘方)、管理员(平台运营方)。学生要能注册、登录、浏览兼职信息、搜索筛选、申请职位、收藏职位、查看申请状态;企业要能注册、登录、发布兼职、管理自己发布的职位、查看申请者列表、更新招聘状态;管理员则要能审核企业、管理用户、管理兼职信息、处理举报或违规内容。

这三类角色如果都堆在一个页面里处理,后面一定会乱。所以这个项目在技术上的第一个关键决策,就是用同一套服务端模板渲染配合异步请求,在角色权限上做严格的区分。这也是为什么选了 SSM + Thymeleaf 而不是纯粹的前后端分离——因为这个项目的信息展示逻辑复杂,但交互深度没那么高,服务端渲染天然适合这种场景。

1.2 技术选型:谁来解决什么问题

很多初学者看到"SSM"和"Spring Boot"同时出现会懵,觉得这俩不是一个层次的东西吗?确实,严格来说 SSM 是 Spring + Spring MVC + MyBatis 三个框架的整合,而 Spring Boot 是一个快速开发脚手架。但在实际项目里,这两者并不矛盾:Spring Boot 负责自动配置和启动,Spring MVC 负责控制层,Spring 负责容器管理和事务,MyBatis 负责数据库访问。你完全可以理解为"Spring Boot 壳子 + SSM 骨架"。

这套组合里各个角色的分工是这样的:

组件职责在这个项目里的具体任务
Spring Boot启动框架、自动配置内嵌 Tomcat,外部只需要一个 jar 包就能跑
Spring MVC控制层接收请求、参数校验、返回页面或 JSON
Spring容器管理、事务控制管理 Service 层 Bean,声明式管理事务
MyBatis数据持久层写 SQL 映射,操作 MySQL 表数据
Thymeleaf模板引擎渲染 HTML 页面,服务端循环输出兼职列表
Ajax前端交互局部提交申请、收藏、异步刷新状态
Maven依赖管理和构建统一管理 jar 版本,打包部署
MySQL数据存储用户、职位、申请记录等数据落库

选这个组合的最大原因是可控。Thymeleaf 虽然性能不如纯静态资源,但它的页面渲染逻辑和 Java 代码离得很近,出现 bug 时很好排查,不像前后端分离项目,前端报个错你还得去浏览器 Network 里一根根找接口。Ajax 则用在"收藏职位""申请兼职""审核通过"这类需要局部刷新、不打断用户浏览的操作上。

2. 数据库先行:兼职平台的表结构与状态设计

2.1 用户表与角色的拆分:别把三类角色塞进一张表

我在不少项目里见过一种省事做法:搞一张 user 表,用一个role字段区分学生、企业、管理员,然后所有角色共用一个表结构。单看登录功能,这样做确实简单,但一旦涉及业务扩展就很痛苦——学生要存学校、专业、年级,企业要存营业执照号、企业简介、联系人,管理员要存工号、权限范围,这些字段互不相通,硬塞一张表里,最后一半字段都是 null,查起来还慢。

我这个项目的做法是:用户主表只存账号密码和角色,详细资料分表存。这样既保留了"一个账号体系统一登录"的便利,又让各角色的扩展字段井水不犯河水。

CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(32) NOT NULL COMMENT '登录用户名', `password` VARCHAR(128) NOT NULL COMMENT 'MD5加盐后的密码', `role` TINYINT NOT NULL COMMENT '1-学生 2-企业 3-管理员', `phone` VARCHAR(20) DEFAULT NULL, `email` VARCHAR(64) DEFAULT NULL, `avatar` VARCHAR(255) DEFAULT NULL, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常 0-封禁', `create_time` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

学生和企业分开建资料表:

CREATE TABLE `student_profile` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `real_name` VARCHAR(32) DEFAULT NULL, `school` VARCHAR(64) DEFAULT NULL, `major` VARCHAR(64) DEFAULT NULL, `grade` VARCHAR(20) DEFAULT NULL, `skill_tags` VARCHAR(255) DEFAULT NULL, UNIQUE KEY `uk_user` (`user_id`) ); CREATE TABLE `company_profile` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `company_name` VARCHAR(128) NOT NULL, `contact_person` VARCHAR(32) DEFAULT NULL, `contact_position` VARCHAR(32) DEFAULT NULL, `license_no` VARCHAR(64) DEFAULT NULL, `company_desc` TEXT, UNIQUE KEY `uk_user` (`user_id`) );

这里我踩过一个坑:一开始用户表里直接设计了real_name字段,后来企业也要填联系人,学生还要填学校,改来改去把表结构弄得乱七八糟。所以我的建议是,用户主表越精简越好,扩展信息一律分表。

2.2 兼职信息表和申请记录表:状态机是关键

兼职信息表是这个平台的核心业务表。我设计的字段重点不在于多,而在于状态要能表达业务的整个生命周期。

CREATE TABLE `job` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `company_id` INT NOT NULL COMMENT '关联企业用户ID', `title` VARCHAR(128) NOT NULL, `category` VARCHAR(32) DEFAULT NULL COMMENT '分类:家教/跑腿/文案/技术等', `salary` DECIMAL(10,2) DEFAULT NULL COMMENT '薪资,按小时或按次', `salary_unit` VARCHAR(10) DEFAULT '小时' COMMENT '时薪/日薪/月薪', `location` VARCHAR(255) DEFAULT NULL, `work_time` VARCHAR(128) DEFAULT NULL, `description` TEXT, `requirement` TEXT COMMENT '任职要求', `headcount` INT DEFAULT 1 COMMENT '招聘人数', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待审核 1-招聘中 2-已满员 3-已下线', `apply_count` INT DEFAULT 0 COMMENT '已申请人数', `view_count` INT DEFAULT 0, `create_time` DATETIME NOT NULL, `update_time` DATETIME DEFAULT NULL, `expire_time` DATETIME DEFAULT NULL COMMENT '招聘截止时间' );

status这个字段是整个表的灵魂。待审核的职位不应该出现在学生端,招聘中表示可以继续申请,已满员表示前端要禁用申请按钮,已下线是企业手动停招。这些状态不是随意枚举的,而是根据业务流程倒推出来的。

申请记录表则需要设计成一个状态机,因为一次申请至少要经历"待处理 → 已通过 / 已拒绝 / 已取消"这几个状态。学生取消了申请,企业就不能再处理;企业通过了申请,学生端要能看到"已录用";学生确认到岗后还能走一个"已完成"状态,为后续评价做铺垫。

CREATE TABLE `job_apply` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `job_id` INT NOT NULL, `student_id` INT NOT NULL, `resume_content` TEXT COMMENT '学生附带的自我介绍或备注', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待处理 1-已通过 2-已拒绝 3-已取消 4-已完成', `apply_time` DATETIME NOT NULL, `process_time` DATETIME DEFAULT NULL COMMENT '企业处理时间', `process_note` VARCHAR(255) DEFAULT NULL, UNIQUE KEY `uk_job_student` (`job_id`, `student_id`) );

注意这个uk_job_student唯一索引,它从数据库层面保证了一个学生不能重复申请同一份兼职,后面业务层就不需要再写繁琐的重复校验。这个细节很多人忽略,等到测试发现重复申请问题才回来补索引。

2.3 建表时容易忽略的细节

  • InnoDB 配合 utf8mb4:不要用 utf8,emoji 和一些特殊字符存不进去。
  • 金额字段用 DECIMAL:兼职时薪可能涉及小数,float 会有精度问题,DECIMAL(10,2) 稳。
  • 创建时间字段设置默认值:,这样插入数据时不用每次手写NOW(),MyBatis 的 insert 语句也能精简不少。
  • 软删除优于物理删除:```sql -- 推荐在表里加一个 deleted 字段,默认0,查数据时都带 deleted = 0
    管理员处理违规职位建议用上下线状态,而不是 DELETE,保证数据可追溯。

3. Spring Boot 工程初始化与 Maven 依赖管理

3.1 直接用 Spring Initializr 还是手动建 pom?

刚开始学的时候我吃过亏,用一个教程里的 pom 直接复制,结果版本相互冲突,启动报错一天都解不掉。后来养成习惯:统一用 Spring Boot 的 BOM 来锁版本。搭建工程可以选 Spring Initializr(start.spring.io 或 IDEA 内置的 Initializr),生成一个干净的 Starter 项目,然后再手动补充 SSM 相关的依赖。这样最稳,因为 Initializr 生成的版本组合是经过测试的。

我用的是 Spring Boot 2.7.x 系列。为什么刻意不选 3.x?原因很简单:3.x 要求 JDK 17,而且很多老教程里 MyBatis 的配置方式有变化。这个项目的定位是成熟稳定、照着做就能跑,2.7 + JDK 8/11 的组合最保险,生态里的资料也是最丰富的。

3.2 pom.xml 核心依赖清单

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <!-- Web 启动器,Spring MVC + 内嵌 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Thymeleaf 模板引擎 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- MyBatis 与 Spring Boot 整合 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- 参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- 热部署,开发时改代码不用重启 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <!-- Lombok,减少 getter/setter --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

有个细节要特别说:mybatis-spring-boot-starter的版本是独立的,不归父 BOM 管,所以必须显式写版本。当年我漏写这行,Spring Boot 自动引入了一个不兼容版本,导致 Mapper 扫描全部失效,启动时候也不报错,一调接口就是Invalid bound statement (not found),这种问题排查起来非常耗时间。

3.3 application.yml 配置:能少写就少写,但关键的不能含糊

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/parttime_job?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你自己的密码 thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html mvc: hiddenmethod: filter: enabled: true mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.parttime.entity configuration: map-underscore-to-camel-case: true

map-underscore-to-camel-case: true这一行必须开。数据库字段是create_time,实体类是createTime,开了这个配置 MyBatis 自动帮你做映射,不然你写的实体类和表结构对不上,查出来一堆 null。

连接串里的serverTimezone=Asia/Shanghai也值得解释一下:新版 MySQL 驱动如果没指定时区,会直接抛The server time zone value '乱码' is unrecognized或者CST相关的异常。useSSL=false则是避免本地环境 SSL 握手报警告和连接失败,尤其你 MySQL 没配置 SSL 证书的时候,这个参数能少很多事。

4. 核心链路实战:登录、发布、申请、收藏的完整实现

4.1 登录与权限拦截:用最简单的方式控制三类角色

登录接口我没有引入 Spring Security,理由是这类项目引入 Security 会大幅增加复杂度——配置不当连静态资源都访问不了。我的做法是自定义拦截器 + Session 存登录态,代码量不大,逻辑透明,也完全够用。

拦截器逻辑分两层。第一层校验是否登录,第二层校验角色,这样可以用一套代码管理所有受保护路径。

@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录页、注册接口、静态资源 String uri = request.getRequestURI(); if (uri.startsWith("/login") || uri.startsWith("/register") || uri.startsWith("/static")) { return true; } User loginUser = (User) request.getSession().getAttribute("loginUser"); if (loginUser == null) { // 未登录统一跳转到登录页 response.sendRedirect(request.getContextPath() + "/login"); return false; } // 检查路径前缀和角色的对应关系 if (uri.startsWith("/student") && loginUser.getRole() != 1) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return false; } if (uri.startsWith("/company") && loginUser.getRole() != 2) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return false; } return true; } }

注册拦截器走 WebMvcConfigurer:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/register", "/static/**", "/error"); } }

密码安全这里说句实话,MD5 已经不安全了,直接用 MD5 存密码我是不建议的。我实际用的是MD5 + 盐值,虽然还是 MD5 但至少加了盐。如果你愿意接触新东西,直接改用 BCrypt 更稳,Spring Security 里就有BCryptPasswordEncoder,把它单独拿来用,不用引入整个 Security。整个项目的安全逻辑保持透明可控,对学生作业来说是加分项,对正式项目也能说明你考虑到了密码存储规范。

4.2 发布兼职信息的完整链路:从页面到底层 SQL

企业发布兼职的流程是:前端表单提交到 Controller,Controller 把数据封装成实体,Service 层写业务校验,最后 Mapper 层执行 insert。

Controller 层代码:

@Controller @RequestMapping("/company/job") public class CompanyJobController { @Autowired private JobService jobService; @PostMapping("/publish") public String publishJob(@Valid JobForm form, HttpSession session, Model model) { User loginUser = (User) session.getAttribute("loginUser"); // 从登录态拿企业ID,而不是信任前端传的 companyId form.setCompanyId(loginUser.getId()); jobService.publishJob(form); return "redirect:/company/job/list"; } }

这里有一个很多新手容易犯的错误:让前端把 companyId 一起传过来。如果前端传的 companyId 是别人家的企业 ID,企业 A 就能借用企业 ID 发职位,这个数据权限就彻底失控了。正确做法是永远从 Session 里的登录用户去取值,前端传什么都不能信。

Service 层的校验逻辑也不复杂,但必须覆盖:

  • 分类是否合法(白名单校验,避免乱传)
  • 薪资必须大于 0
  • 标题长度不能超过限制
  • 招聘截止时间不能早于当前时间

MyBatis 的 insert 写法有一个小技巧,用useGeneratedKeys把自增主键回填到实体类里,后面如果要继续做"发布即关联"的操作就很方便:

<insert id="insertJob" parameterType="com.example.parttime.entity.Job" useGeneratedKeys="true" keyProperty="id"> INSERT INTO job (company_id, title, category, salary, salary_unit, location, work_time, description, requirement, headcount, status, create_time, expire_time) VALUES (#{companyId}, #{title}, #{category}, #{salary}, #{salaryUnit}, #{location}, #{workTime}, #{description}, #{requirement}, #{headcount}, 0, NOW(), #{expireTime}) </insert>

keyProperty="id"让我在 Service 层执行完insertJob(job)之后可以直接读job.getId(),比如立即跳转到新职位的详情页。

4.3 申请与状态流转:并发场景下的防重复申请

学生申请兼职的链路是这个系统里最容易出 bug 的地方,因为涉及到并发。学生狂点申请按钮,前端虽然可以禁用按钮,但恶意用脚本多线程请求,一样会打过来。前面我们已经在数据库层面加了uk_job_student唯一索引,此时 MyBatis 的 insert 语句再配合ON DUPLICATE KEY UPDATE或者直接捕获DuplicateKeyException,就能做成幂等。

我的做法是:正常 insert,Service 层捕获数据库抛出的唯一索引冲突异常,捕获后直接返回"你已经申请过这份兼职了"。

public ApplyResult applyJob(ApplyVO applyVO) { int jobId = applyVO.getJobId(); int studentId = applyVO.getStudentId(); // 1. 先查职位状态,已经下线/已满员不能申请 Job job = jobMapper.findById(jobId); if (job == null || job.getStatus() != 1) { return ApplyResult.fail("职位不存在或不在招聘中"); } // 2. 判断是否已经申请过(业务层也查一次,减少无效插入) int count = applyMapper.countByJobAndStudent(jobId, studentId); if (count > 0) { return ApplyResult.fail("您已经申请过该职位"); } Apply apply = new Apply(); apply.setJobId(jobId); apply.setStudentId(studentId); apply.setStatus(0); try { applyMapper.insertApply(apply); // 3. 职位表申请人数 +1 jobMapper.incrementApplyCount(jobId); return ApplyResult.success(); } catch (DuplicateKeyException e) { return ApplyResult.fail("您已经申请过该职位"); } }

三层防护:数据库唯一索引兜底、业务层提前判断、前端按钮禁用。这三层下来,"重复申请"这个并发难题基本被堵死。

企业端处理申请的状态流转也不难,但要注意只能处理status=0(待处理)的申请,避免那种"学生已经取消,企业却点了通过"的状态错乱。简单的乐观锁思路:UPDATE job_apply SET status = 1 WHERE id = ? AND status = 0,更新结果影响行数为 0 就说明状态已经变了,直接忽略这个操作。

4.4 Ajax 局部刷新的应用点:收藏与状态即时更新

Ajax 在这个系统里不是主角,但它在几个场景里用得恰到好处。最典型的是收藏职位,学生浏览兼职列表时点"收藏"按钮,不应该让整个页面跳转,那样体验太差。用 Ajax 发一个 POST 请求,返回 JSON,前端根据结果改按钮样式。

这要求在 Controller 层区分接口类型。我的策略是:返回页面用 ModelAndView / String 模板,返回数据用 @ResponseBody 或 @RestController。

@Controller @RequestMapping("/student") public class StudentAjaxController { @Autowired private FavoriteService favoriteService; @ResponseBody @PostMapping("/favorite/toggle") public Result toggleFavorite(@RequestParam("jobId") Integer jobId, HttpSession session) { User user = (User) session.getAttribute("loginUser"); boolean favorited = favoriteService.toggleFavorite(user.getId(), jobId); return Result.ok().put("favorited", favorited); } }

页面里的 Ajax 代码,我习惯用 jQuery 的$.ajax,虽然框架已经转向 fetch 了,但 jQuery 在 Thymeleaf 模板里依然是非常稳定的选择,本地引入一个 jQuery 文件就完事:

$(document).on('click', '.favorite-btn', function () { var jobId = $(this).data('job-id'); var $btn = $(this); $.ajax({ url: '/student/favorite/toggle', type: 'POST', data: {jobId: jobId}, dataType: 'json', success: function (res) { if (res.code === 200) { $btn.toggleClass('favorited'); $btn.text(res.favorited ? '已收藏' : '收藏'); } else { alert(res.msg); } }, error: function () { alert('网络异常,请稍后重试'); } }); });

这里的要点是><button class="favorite-btn" th:data-job-id="${job.id}"> <span th:text="${job.favorited ? '已收藏' : '收藏'}">收藏</span> </button>

这样把服务端渲染出来的"是否已收藏"状态和 Ajax 异步更新的状态完美结合。我实际测试下来,这种混合模式在小项目里的开发效率是最高的:列表由服务端渲染,交互动作由 Ajax 完成,不需要像 SPA 那样反复设计接口联调。

5. Thymeleaf 页面渲染与 Ajax 配合的细节经验

5.1 服务端渲染:列表页、详情页、条件筛选

Thymeleaf 的使用其实不难,但有两个点特别容易让新手头疼:一个是公共模板片段,一个是URL 拼接参数。

公共模板片段把头部、底部、导航栏抽取出来,避免每个页面重复写一大段。我用 th:fragment 定义了一个公共头:

<header th:fragment="siteHeader(loginUser)"> <!-- 导航栏内容 --> </header>

在其他页面引用:

<div th:replace="~{common/header :: siteHeader(${session.loginUser})}"></div>

这个做法的价值在于,项目里至少有几十个页面,如果头部结构要加个菜单项,你只需要改一处。如果不用 fragment,你就要全局搜索替换,改完还容易漏。

列表页的条件筛选,我用了 GET 请求拼接查询参数的方式:

@GetMapping("/jobs") public String jobList(@RequestParam(defaultValue = "1") Integer page, @RequestParam(required = false) String category, @RequestParam(required = false) String keyword, Model model) { PageResult<Job> pageResult = jobService.pageQuery(page, 10, category, keyword); model.addAttribute("pageResult", pageResult); model.addAttribute("category", category); model.addAttribute("keyword", keyword); return "job/list"; }

页面里生成分页链接的时候,要把当前的筛选条件原样带上:

<a th:href="@{/jobs(page=${pageResult.current}, category=${category}, keyword=${keyword})}">下一页</a>

这里有个坑:如果筛选条件里有中文关键词,直接用 URL 传参会乱码,Spring Boot 默认的编码能处理 POST,但 GET 查询串需要确认 URL 编码格式。我在项目里对这个问题的处理是,统一用 Thymeleaf 的 URL 编码语法,让浏览器自己完成编码。

为什么不用 POST 做搜索?因为 GET 方式刷新页面后参数会留在地址栏,学生把搜索条件分享给别人时,对方打开链接就能看到同样的搜索结果;而且浏览器对同一 URL 会做缓存,反复搜索不会每次都打一次数据库。

5.2 Ajax 提交时的 CSRF 与 Session 超时处理

Ajax 用 POST 提交有一个一直存在的隐患——跨站请求伪造。虽然我们没有引入 Spring Security,但自己实现 CSRF Token 也是可以做的,而且做法不复杂:在 Session 里存一个随机 token,服务端渲染时放进隐藏字段,Ajax 提交时从页面读取并一并提交,服务端对比校验。

下面是简化版的做法:

<input type="hidden" id="csrfToken" th:value="${session.csrfToken}"/>
$.ajax({ url: '/student/favorite/toggle', type: 'POST', data: { jobId: jobId, csrfToken: $('#csrfToken').val() }, // ... });

服务端用一个过滤器(或拦截器)统一校验 POST 请求的 token,匹配不上直接拒绝。这一步在你做完"演示版"之后如果要真的上线,必须补上,否则恶意网站上套一个表单,你的用户登录状态下点一下链接,就能替你发申请、改资料。

Session 超时也会在 Ajax 场景里漏出马脚。正常页面请求超时后会 302 跳转到登录页,但 Ajax 请求拿到 302 后,浏览器默认会静默跟随跳转,然后返回登录页面的 HTML——你前端却用 JSON 去解析,直接报错,用户还莫名其妙。我的处理方式是在拦截器里判断是否为 Ajax 请求:如果X-Requested-With头是XMLHttpRequest,就不再重定向,而是直接返回一个 JSON 对象带code=401,前端收到后统一弹窗提示"登录已过期,请重新登录"并跳转登录页。

5.3 搜索、分页、排序:这是最容易被低估的三个功能

搜索和分页看着简单,实际在 MyBatis 里写好了很见功力。我这里的查询语句用动态 SQL:

<select id="pageQuery" resultType="com.example.parttime.entity.Job"> SELECT * FROM job WHERE status = 1 <if test="category != null and category != ''"> AND category = #{category} </if> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR description LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="sort == 'salary'"> ORDER BY salary DESC </if> <if test="sort == 'time'"> ORDER BY create_time DESC </if> <if test="sort == 'hot'"> ORDER BY apply_count DESC </if> LIMIT #{offset}, #{size} </select>

排序这里我一开始犯过错,直接把前端传的排序字段拼进 ORDER BY,导致 SQL 注入风险。后来改成在 Java 层做一个白名单映射,只允许salary、time、hot三个值,其余全部回退为默认排序。排序是用户最直观的感受,特别是在兼职平台里,"最新发布"和"薪资最高"是两个最高频的排序需求,排序字段设计不好,页面体验就会觉得"数据是死的"。

6. 打包部署与上线过程中踩过的坑

6.1 Maven 打包:别把 application.yml 里的密码提交到仓库

部署环节的第一个坑在打包。Spring Boot 生成的可执行 jar 是整个项目连同内嵌 Tomcat 一起打包的,理论上只需要java -jar一条命令就能启动。但如果你在 IDEA 里用默认的 package 命令,可能遇到这个问题:

Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:test

解决方案是跳过测试再打包。这当然不是让你不管测试,而是开发阶段本地打包时测试用例可能依赖数据库环境,CI/CD 里跑才是正确姿势。命令是:

mvn clean package -DskipTests

打完包之后在服务器上执行:

java -jar parttime-job-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

说到配置文件,这里有一个惨痛教训:第一次我图省事,把 MySQL 密码明文写在application.yml里,又把整个项目推到代码仓库,结果密码泄露被提示风险。后来的经验是配置文件和环境分离,application-dev.yml放本地开发账号密码,application-prod.yml放线上账号密码,而且 prod 文件不进 Git 仓库,线上部署时用--spring.config.additional-location指定外部配置文件。这样本地开发、线上部署都方便,敏感信息也不至于满天飞。

6.2 MySQL 连接报错的常见排查顺序

项目上线后最常碰到的三个 MySQL 问题,我把它们按出现频率排个序:

  1. The server time zone value ... is unrecognized:前面说了,URL 里加serverTimezone=Asia/Shanghai解决。
  2. Access denied for user 'root'@'localhost':检查密码是不是有特殊字符,如果密码里有@、#之类,YAML 里要加引号包裹,否则被解析成注释或缺字符。
  3. Public Key Retrieval is not allowed:新版 MySQL 8 驱动在连接时需要公钥,如果没做 SSL 配置,URL 里加上allowPublicKeyRetrieval=true。

这里我要特别强调第 2 个。YAML 对特殊字符的处理坑了我好几次:密码是abc#2024,直接写出来,#后面的全被 YAML 当注释吃掉了,数据库连接一直失败。排查了半天才发现是配置被截断。现在我的习惯是,凡是账号密码类的值,一律用单引号包起来,防止各种意外解析。

6.3 内嵌 Tomcat 的端口冲突与优雅停机

服务器上如果已经跑了一个 Tomcat 或其他服务占用 8080,Spring Boot 启动时会直接报Port already in use。我一般改启 8080 之外的一个不常用端口,比如 8088。不过更重要的一个细节是,正式环境我会把启动脚本写成先检查端口占用再启动:

CHECK_PORT=$(ss -lnt | grep ':8088' | wc -l) if [ "$CHECK_PORT" -gt 0 ]; then echo "端口 8088 已被占用,请检查是否有旧进程未停止" exit 1 fi

停服的时候也不要直接kill -9,那样容易导致正在处理的请求中断、数据库事务回滚不干净。正确方式是kill $(cat app.pid),然后等几秒确认进程退出。如果实在要强制,先优雅停用再强杀,减少脏数据概率。

6.4 日志排查:从 500 错误到定位 MyBatis SQL

上线后的 500 错误排查,日志是唯一的线索。Spring Boot 默认日志信息不算太详细,但 MyBatis 可以通过配置把 SQL 打出来:

logging: level: com.example.parttime.mapper: DEBUG

这样在日志里可以看到 MyBatis 实际提交的 SQL 语句和参数,排查大部分数据层问题都够了。注意 mapper 包配成 DEBUG 就够了,配成 TRACE 会连结果集的每一行都打印出来,日志文件膨胀得飞快。

写到最后的一点个人体会

这套"Spring Boot + SSM + Thymeleaf + Ajax + MySQL"的组合,单看每一个技术点都不新鲜,但把它们组合起来做一个兼职平台,反而是一个很锻炼人的过程。我在实际做完这个项目后最大的感受是,这个项目的难点不是代码本身,而是角色的数据边界和业务状态流转。学生、企业、管理员三种角色,每个角色的操作集合不同,数据权限也不同;兼职信息从发布到审核到招聘完成,中间每个状态的变化都牵扯不同角色的操作,把这些想清楚了,代码反而是水到渠成的事。

如果你按这篇文章把数据库设计好、把项目骨架搭起来,再对照核心链路写完登录、发布、申请、收藏这四个功能,这个系统已经能跑通主干流程了。剩下的就是打磨细节:搜索体验、状态提示、页面样式。遇到报错不要慌,按"看日志 → 查参数 → 查表结构 → 查版本兼容"这个顺序排查,大部分问题都能在半小时内定位。后面做完之后,你可以尝试在这个基础上扩充一些功能,比如短信通知、简历上传、评价系统,这个技术骨架完全能撑得住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询