基于SpringBoot+SSM+Thymeleaf的兼职平台系统设计与实现

📅 发布时间:2026/10/10 9:33:49
基于SpringBoot+SSM+Thymeleaf的兼职平台系统设计与实现
做兼职平台这个题我在不同阶段见过三种做法一种是纯 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控制层接收请求、参数校验、返回页面或 JSONSpring容器管理、事务控制管理 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;学生和企业分开建资料表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不要用 utf8emoji 和一些特殊字符存不进去。金额字段用 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 Initializrstart.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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies !-- Web 启动器Spring MVC 内嵌 Tomcat -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Thymeleaf 模板引擎 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency !-- MyBatis 与 Spring Boot 整合 -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- 参数校验 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- 热部署开发时改代码不用重启 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-devtools/artifactId scoperuntime/scope optionaltrue/optional /dependency !-- Lombok减少 getter/setter -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalse 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: truemap-underscore-to-camel-case: true这一行必须开。数据库字段是create_time实体类是createTime开了这个配置 MyBatis 自动帮你做映射不然你写的实体类和表结构对不上查出来一堆 null。连接串里的serverTimezoneAsia/Shanghai也值得解释一下新版 MySQL 驱动如果没指定时区会直接抛The server time zone value 乱码 is unrecognized或者CST相关的异常。useSSLfalse则是避免本地环境 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; } }注册拦截器走 WebMvcConfigurerConfiguration 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企业发布兼职的流程是前端表单提交到 ControllerController 把数据封装成实体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 idinsertJob parameterTypecom.example.parttime.entity.Job useGeneratedKeystrue keyPropertyid 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}) /insertkeyPropertyid让我在 Service 层执行完insertJob(job)之后可以直接读job.getId()比如立即跳转到新职位的详情页。4.3 申请与状态流转并发场景下的防重复申请学生申请兼职的链路是这个系统里最容易出 bug 的地方因为涉及到并发。学生狂点申请按钮前端虽然可以禁用按钮但恶意用脚本多线程请求一样会打过来。前面我们已经在数据库层面加了uk_job_student唯一索引此时 MyBatis 的 insert 语句再配合ON DUPLICATE KEY UPDATE或者直接捕获DuplicateKeyException就能做成幂等。我的做法是正常 insertService 层捕获数据库抛出的唯一索引冲突异常捕获后直接返回你已经申请过这份兼职了。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(您已经申请过该职位); } }三层防护数据库唯一索引兜底、业务层提前判断、前端按钮禁用。这三层下来重复申请这个并发难题基本被堵死。企业端处理申请的状态流转也不难但要注意只能处理status0待处理的申请避免那种学生已经取消企业却点了通过的状态错乱。简单的乐观锁思路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 classfavorite-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:fragmentsiteHeader(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) { PageResultJob 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 typehidden idcsrfToken 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 对象带code401前端收到后统一弹窗提示登录已过期请重新登录并跳转登录页。5.3 搜索、分页、排序这是最容易被低估的三个功能搜索和分页看着简单实际在 MyBatis 里写好了很见功力。我这里的查询语句用动态 SQLselect idpageQuery resultTypecom.example.parttime.entity.Job SELECT * FROM job WHERE status 1 if testcategory ! null and category ! AND category #{category} /if if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR description LIKE CONCAT(%, #{keyword}, %)) /if if testsort salary ORDER BY salary DESC /if if testsort time ORDER BY create_time DESC /if if testsort 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.activeprod说到配置文件这里有一个惨痛教训第一次我图省事把 MySQL 密码明文写在application.yml里又把整个项目推到代码仓库结果密码泄露被提示风险。后来的经验是配置文件和环境分离application-dev.yml放本地开发账号密码application-prod.yml放线上账号密码而且 prod 文件不进 Git 仓库线上部署时用--spring.config.additional-location指定外部配置文件。这样本地开发、线上部署都方便敏感信息也不至于满天飞。6.2 MySQL 连接报错的常见排查顺序项目上线后最常碰到的三个 MySQL 问题我把它们按出现频率排个序The server time zone value ... is unrecognized前面说了URL 里加serverTimezoneAsia/Shanghai解决。Access denied for user rootlocalhost检查密码是不是有特殊字符如果密码里有、#之类YAML 里要加引号包裹否则被解析成注释或缺字符。Public Key Retrieval is not allowed新版 MySQL 8 驱动在连接时需要公钥如果没做 SSL 配置URL 里加上allowPublicKeyRetrievaltrue。这里我要特别强调第 2 个。YAML 对特殊字符的处理坑了我好几次密码是abc#2024直接写出来#后面的全被 YAML 当注释吃掉了数据库连接一直失败。排查了半天才发现是配置被截断。现在我的习惯是凡是账号密码类的值一律用单引号包起来防止各种意外解析。6.3 内嵌 Tomcat 的端口冲突与优雅停机服务器上如果已经跑了一个 Tomcat 或其他服务占用 8080Spring 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的组合单看每一个技术点都不新鲜但把它们组合起来做一个兼职平台反而是一个很锻炼人的过程。我在实际做完这个项目后最大的感受是这个项目的难点不是代码本身而是角色的数据边界和业务状态流转。学生、企业、管理员三种角色每个角色的操作集合不同数据权限也不同兼职信息从发布到审核到招聘完成中间每个状态的变化都牵扯不同角色的操作把这些想清楚了代码反而是水到渠成的事。如果你按这篇文章把数据库设计好、把项目骨架搭起来再对照核心链路写完登录、发布、申请、收藏这四个功能这个系统已经能跑通主干流程了。剩下的就是打磨细节搜索体验、状态提示、页面样式。遇到报错不要慌按看日志 → 查参数 → 查表结构 → 查版本兼容这个顺序排查大部分问题都能在半小时内定位。后面做完之后你可以尝试在这个基础上扩充一些功能比如短信通知、简历上传、评价系统这个技术骨架完全能撑得住。