1. 从零梳理需求:就业信息管理系统到底要做哪些事
在开始列技术栈、写代码之前,把需求彻底理清楚,是这类信息管理系统项目最容易被忽略的一步。很多同学拿到“毕业就业信息管理系统”这个题目就急着去建表,结果写着写着发现角色不对、字段对不上、页面之间互相矛盾,返工成本反而更高。我自己在做这个项目的第一周,也经历了“推翻重来”的过程,所以这一节先把需求框架讲透,后面写代码才有方向。
这个系统的核心目标,是把学校就业部门、学生、招聘企业、招聘岗位、就业去向这几条线串起来,让信息从“学生填写”到“企业查看”再到“管理员统计”形成一个闭环。它本质上不是一个炫技的项目,而是一个典型的信息流管理平台,因此重点应该放在角色权限边界、数据流转路径、核心业务状态管理这三件事上。
从用户视角来看,系统分三类角色。学生要能注册登录、完善个人简历、浏览招聘岗位、投递简历、查看就业公告,同时还要能填写自己的毕业去向,比如签约公司、职位、薪资范围、就业城市。企业端要能发布职位、管理在招岗位、查看投递过来的学生简历、标记简历处理状态。管理员端则负责审核企业资质、审核职位信息、管理公告、查看整体就业数据,部分毕设项目还需要支持导出Excel统计表。
功能模块拆下来大概是这样的:
| 模块 | 学生端 | 企业端 | 管理员端 |
|---|---|---|---|
| 账号体系 | 注册、登录、修改密码 | 注册、登录、资质信息维护 | 管理员账号、用户禁用/启用 |
| 招聘信息 | 浏览岗位、按条件筛选、投递简历 | 职位发布、编辑、上下架 | 职位审核、违规职位下架 |
| 简历与投递 | 维护个人信息、查看投递记录 | 查看收到的简历、更新处理状态 | 数据总览,按学院/专业统计 |
| 就业管理 | 填写就业去向、查看记录 | 本企业已录用学生名单 | 就业率统计、导出报表 |
| 公告通知 | 查看公告详情 | 查看公告详情 | 发布公告、删除公告 |
这个需求表建议你在动手前就画出来,因为它决定了整个数据库的表结构。比如“投递记录”这个状态,学生端要看到“已投递、被查看、已邀约、已录用、未通过”,企业端要能更新这些状态,管理员端可能只需要统计录用人数,那么这张表的状态字段设计就要统一,不能一个是数字一个是字符串。还有“企业审核”这件事容易被漏掉。如果企业注册后直接能发职位,系统里会出现大量垃圾招聘信息,所以建议在企业注册后加一个“待审核、已通过、已拒绝”的审核状态,管理员审核通过后企业才能发布职位。
业务流程上,核心链路是“企业发职位 -> 学生浏览投递 -> 企业查看简历并更新状态 -> 学生确认就业去向”。我建议把这条链路单独画一张时序图放在项目文档里,写代码时按这条链路去倒推每个接口需要哪些参数、返回什么数据。另一个容易被忽略的流程是“就业信息上报”。有些学校要求毕业生离校前填写就业去向,那学生端就应该有一个表单页面,包含公司名称、职位、薪资、城市、是否已签三方协议等字段,提交后管理员端能看到汇总数据。这个功能看起来简单,但涉及字段校验和重复提交的问题,后面数据库设计时会具体说。
需求阶段的最后一步是确定系统的非功能需求,比如密码不能明文存储、接口要校验登录状态、列表要分页、文件上传要限制类型。这些不需要做成多么复杂的安全方案,但要通过代码体现出来,这在答辩或文档评审时是一个加分项。
2. 技术选型不等于堆框架:为什么是SpringBoot+Vue+MyBatis+MySQL
技术选型是这个项目里面看似最简单、实际上最容易纠结的一个环节。标题里给出的SpringBoot+Vue+MyBatis+MySQL是当前前后端分离项目非常成熟的一套组合,但如果你只是“因为它流行”所以选了,后面遇到问题时容易不知道本质原因。我以自己的选择过程为例,把这套技术栈背后真正的理由说清楚。
先看后端。SpringBoot的核心价值在“约定大于配置”,它把Spring MVC、事务管理、Jackson序列化、嵌入式Tomcat这些基础能力整合好了,我们只需要在pom.xml里引入依赖,写配置类和业务代码,不用再像传统SSM项目那样堆一堆XML配置。对于这个系统,后端需要的能力无非是REST接口、数据库访问、登录鉴权、文件上传、定时任务这些,SpringBoot都能比较优雅地支持。MyBatis作为持久层框架,胜在SQL可控。毕业就业系统里有大量多表关联统计,比如“按学院统计就业率”、“按企业统计招聘人数”,这种统计类SQL直接用MyBatis写SQL更直观,比全自动ORM在调优和理解上都要容易可控一些。
前端选择Vue,核心原因是组件化和生态。Vue的组件化开发方式非常适合把“登录页、职位列表、简历表单、后台管理布局”拆成独立组件,多人协作或者自己维护都清晰。Vue Router负责前端路由,Vuex或Pinia负责全局状态,Axios负责HTTP请求,这套组合在前后端分离场景下是标准方案。需要注意Vue 2和Vue 3的差异。如果只是做毕设或学习,Vue 2的生态资料更多,Element UI组件库也很成熟;但如果是新项目,我更推荐Vue 3 + Vite + Element Plus,性能和长期维护性更好。本项目的源码最终用的就是Vue 3。
MySQL作为数据库不用多解释,免费、稳定、资料多,毕业设计答辩时老师也熟悉。但有几个细节选型时要想清楚:MySQL版本建议用8.0以上,因为8.0的字符集默认是utf8mb4,支持emoji,认证插件是caching_sha2_password,连接时JDBC驱动的配置和5.7不同;数据库引擎用InnoDB,支持事务和外键,校园数据量级完全够用。
这套技术栈的组合优势在于“每一层都有清晰的替换空间”。比如你现在用MyBatis,后面想换MyBatis-Plus,改造并不大;前端Vue 3不好用,可以降级到Vue 2;MySQL想换成PostgreSQL,后端只要改方言和部分SQL。对学习者来说,这种可替换性意味着你掌握的是通用的前后端分离架构能力,而不是绑定某个特定工具。我在实际做的时候也验证过,换组件库从Element UI到Element Plus大概只改了一天的组件代码。
版本约定这里我列一个自己验证过的组合,避免大家花时间在版本冲突上:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 1.8或11 | 推荐1.8,兼容性最好,11也稳定 |
| SpringBoot | 2.7.x | 不要选3.x,3.x要求JDK17,部分资料不适用 |
| MyBatis | 3.5.x | 配合mybatis-spring-boot-starter 2.3.x |
| MySQL | 8.0.x | JDBC驱动用mysql-connector-j 8.0.x |
| Vue | 3.4.x | 搭配Vite 5.x |
| Element Plus | 2.x | UI组件库 |
| Node.js | 18.x或20.x | 前端构建环境 |
| Maven | 3.8.x | 后端构建工具 |
这套组合在Windows、macOS、Linux都跑过,只要跟着这个版本表走,基本上不会遇到“启动就报错”的尴尬。
3. 数据库先行:核心表设计与字段取舍
我一直认为信息管理系统最重要的不是代码写得花哨,而是数据库表设计是否合理。表结构一旦定了,业务逻辑和前端页面基本就围绕表结构展开。这个项目我最终设计了9张核心表,这里挑最关键的几张说清楚设计思路和字段取舍。
第一类是用户与角色表。我设计了一张sys_user表统一存放登录账号,字段包括id、username、password、role_type、status、create_time。role_type用整数标识:1学生、2企业、3管理员。为什么不设计三张独立的登录表?因为登录流程是统一的,账号密码校验逻辑只需要写一次,登录后的角色权限通过role_type区分。密码字段存的是BCrypt加密后的密文,不是明文。status字段用来做禁用账号,管理员把某企业账号禁用后,该账号无法登录系统。
第二类是学生和企业信息表。学生信息表student_profile和sys_user一对一关联,字段包括real_name、student_no、college、major、grade、phone、resume_url。企业信息表company则包括company_name、credit_code、industry、scale、address、license_url、status。这里要注意,登录需要的账号密码和昵称放在sys_user,业务相关的详细资料放在各自的档案表,这样避免把用户表塞得太满。
第三类是核心业务表:职位表和投递表。职位表job_position的字段设计是这个系统的关键点之一:
CREATE TABLE `job_position` ( `id` int NOT NULL AUTO_INCREMENT, `company_id` int NOT NULL COMMENT '关联企业表', `title` varchar(100) NOT NULL COMMENT '职位名称', `category` varchar(50) DEFAULT NULL COMMENT '职位类别', `salary_min` int DEFAULT NULL COMMENT '最低薪资,单位K', `salary_max` int DEFAULT NULL COMMENT '最高薪资,单位K', `city` varchar(50) DEFAULT NULL COMMENT '工作城市', `education` varchar(20) DEFAULT NULL COMMENT '学历要求', `experience` varchar(20) DEFAULT NULL COMMENT '经验要求', `description` text COMMENT '职位描述', `status` tinyint DEFAULT '1' COMMENT '0下架 1招聘中 2待审核', `view_count` int DEFAULT '0' COMMENT '浏览次数', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='招聘岗位表';薪资字段我建议拆成salary_min和salary_max两个整数,而不是直接存一个字符串“8K-12K”。原因很简单:如果要按薪资范围筛选岗位,字符串字段没法高效查询,拆成两个整数后直接用BETWEEN查询即可。学历要求、经验要求这类字段用字符串类型来存,比如“本科”、“3-5年”,因为它们的取值有限且固定,不需要过度设计成字典表。
投递表job_application是状态流转的核心:
CREATE TABLE `job_application` ( `id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL COMMENT '关联学生档案表', `job_id` int NOT NULL COMMENT '关联岗位表', `status` tinyint DEFAULT '0' COMMENT '0已投递 1被查看 2邀约 3录用 4未通过', `resume_url` varchar(255) DEFAULT NULL COMMENT '投递时的简历地址', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_student_job` (`student_id`, `job_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='投递记录表';这里有一个细节:投递时会把学生当前的resume_url冗余一份到投递记录表里。原因是学生后续可能更新简历,如果只关联学生表,历史投递记录里的简历地址可能已经变了,企业查看时的数据就失真了。冗余字段这个操作,在业务上叫“快照”,在做简历、订单这类有时效性的业务时非常常见。
就业信息表employment_info记录毕业去向,字段包括student_id、company_name、position、salary、city、is_signed(是否签三方协议)、entry_date、create_time。这张表是管理员统计就业率的数据来源,所以尽量让字段独立清晰,方便聚合查询。公告表、企业审核记录表我就不贴完整SQL了,设计逻辑比较直白。
建表时还有一个容易犯的错误:所有时间字段类型统一用datetime,不要一半用timestamp一半用datetime;所有主键统一用int AUTO_INCREMENT或bigint,不要一会儿用id一会儿用uid;所有关联字段记得加索引,尤其是投递表的student_id和job_id。这个系统数据量不大,索引不用太多,但联合索引idx_student_job在查询“某个学生的全部投递记录”、“某个职位的收到简历列表”时会有明显帮助。
我在设计数据库时还做了另一个建表脚本,用于初始化管理员账号。这个脚本在部署阶段会用到:
INSERT INTO sys_user (username, password, role_type, status, create_time) VALUES ('admin', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', 3, 1, NOW());这串$2a$10$开头的是BCrypt加密后的“admin123”。因为Spring Security内置的BCryptPasswordEncoder每次加密同一个密码得到的密文都不同,所以直接把加密后的结果写死在SQL里,比写一个明文密码然后犹豫要不要在配置里解密更省事。
4. 后端实战:从Maven工程到核心接口落地
数据库设计完成后,进入后端编码阶段。这一节我会按实际开发顺序把工程结构、关键配置和几个核心接口的写法走一遍,并解释每一步为什么这么做。
4.1 项目结构与依赖配置
后端工程用Spring Initializr创建,包名建议用com.example.employment,分模块如下:
com.example.employment ├── EmploymentApplication.java ├── config │ ├── CorsConfig.java │ ├── WebMvcConfig.java │ └── MybatisPlusConfig.java ├── controller │ ├── AuthController.java │ ├── JobController.java │ ├── ApplicationController.java │ ├── StudentController.java │ └── AdminController.java ├── service │ ├── UserService.java │ ├── JobService.java │ └── ApplicationService.java ├── mapper │ ├── UserMapper.java │ ├── JobMapper.java │ └── ApplicationMapper.java ├── entity │ ├── SysUser.java │ ├── JobPosition.java │ └── JobApplication.java ├── dto │ ├── LoginRequest.java │ ├── JobQueryDTO.java │ └── LoginResponse.java ├── common │ ├── Result.java │ ├── GlobalExceptionHandler.java │ └── JwtUtil.javapom.xml里最核心的依赖就这么几个:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </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> </dependency>MyBatis不一定要用MyBatis-Plus。这个项目我故意只用原生MyBatis,就是希望把SQL写在XML里,能看到每一条查询语句。如果你后续想偷懒,引入MyBatis-Plus后直接用BaseMapper就能少写大量CRUD,但学习阶段先掌握原生SQL更重要。application.yml里有两个地方容易写错,我单独说明一下。
spring: datasource: url: jdbc:mysql://localhost:3306/employment_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.employment.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplserverTimezone=Asia/Shanghai必须写,否则MySQL 8.0连接时会报时区错误;allowPublicKeyRetrieval=true是配合MySQL 8.0的caching_sha2_password认证插件需要的,初期没加的时候本地跑得好好的,部署到云上后突然连不上,多半就是这个参数的问题。map-underscore-to-camel-case开启后,数据库的company_name字段能自动映射到实体的companyName属性,省去手写resultMap的麻烦。log-impl配置成StdOutImpl,开发阶段控制台会打印SQL语句,排查MyBatis问题必备。
4.2 统一响应与全局异常处理
前后端分离项目,接口返回值需要一个统一格式。我的Result类长这样:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }所有Controller的返回值都用Result包装,前端拿到的JSON就是这样:
{ "code": 200, "message": "success", "data": { "id": 1, "title": "Java开发工程师" } }GlobalExceptionHandler用@RestControllerAdvice捕获业务异常和参数校验异常,统一转成Result.error返回。这样做的意义在于,前端axios拦截器里只用判断code是否为200,其余都走统一的错误提示逻辑,代码就变得非常干净。注意业务异常要自己定义,不要用它来包未知的Exception,否则调试时所有错误都变成了“系统异常”,根本定位不到问题。
4.3 JWT登录鉴权的实现思路
登录鉴权是前后端分离项目里的核心问题。传统Session方案在跨域场景下要处理Cookie的SameSite、跨域携带凭证等一堆问题,所以我直接用JWT做无状态登录。流程是:用户登录成功后,后端生成一个包含用户ID、用户名、角色类型的JWT字符串返回给前端,前端把它存在localStorage里,每次请求时放在Authorization请求头,后端用一个拦截器校验Token并解析出当前用户信息。
JWT生成工具我用的是io.jsonwebtoken,核心代码不长:
public String generateToken(Integer userId, String username, Integer roleType) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .claim("roleType", roleType) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000L)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); }Token有效期我设置成7天。实际项目里有人用2小时,但毕设和内部系统7天更省事,不至于让学生填个简历填一半登录过期。拦截器我这里用的是Spring MVC的HandlerInterceptor,而不是Spring Security的过滤器链,原因是为了降低学习曲线。如果你已经会Spring Security,直接用当然更好;如果只是想先把系统跑通,手写拦截器足够。
拦截器核心思路是放行登录、注册、公告查询这些不需要鉴权的接口,其余接口校验请求头里的Token,解析成功后把用户ID放到ThreadLocal或RequestContextHolder里,Controller里直接用。遇到Token过期或非法,返回401状态码,前端路由守卫检测到401后跳回登录页。
4.4 职位管理接口与分页查询
职位列表是学生端访问频率最高的接口,查询条件多,写法最能体现MyBatis的功底。我先定义查询DTO:
@Data public class JobQueryDTO { private Integer pageNum = 1; private Integer pageSize = 10; private String keyword; private String city; private String category; private Integer salaryMin; private Integer education; private Integer status = 1; // 默认只查招聘中 }Mapper接口:
public interface JobMapper { List<JobPosition> selectJobList(@Param("dto") JobQueryDTO dto); Long selectJobCount(@Param("dto") JobQueryDTO dto); }XML里这么写:
<select id="selectJobList" resultType="com.example.employment.entity.JobPosition"> SELECT * FROM job_position <where> <if test="dto.status != null"> AND status = #{dto.status} </if> <if test="dto.keyword != null and dto.keyword != ''"> AND (title LIKE CONCAT('%', #{dto.keyword}, '%') OR description LIKE CONCAT('%', #{dto.keyword}, '%')) </if> <if test="dto.city != null and dto.city != ''"> AND city = #{dto.city} </if> <if test="dto.salaryMin != null"> AND salary_max >= #{dto.salaryMin} </if> </where> ORDER BY create_time DESC LIMIT #{dto.pageSize} OFFSET #{dto.pageNum} </select>查询逻辑里我用LIMIT #{dto.pageSize} OFFSET #{dto.pageNum}做分页,没有引入PageHelper插件。这个量级的系统手写分页完全够用,少一个依赖就少一个配置坑。注意where条件里用<where>标签而不是直接写WHERE 1=1,<where>会自动去掉多余的AND,代码更规范。
分页返回体我也封装了一个PageResult,包含total、list、pageNum、pageSize四个字段。前端Element Plus的el-pagination组件直接接收这个结构就能正常渲染,省去前端做数据转换。
接口层面,我推荐把所有业务接口按“读取”和“写入”分开命名,像/api/job/list是查询,/api/application/submit是投递。这样一个接口拉一个表,职责清晰,后面做权限控制也能直接按前缀处理。
5. 前端实战:Vue工程搭建与前后端联调细节
前端是绝大多数第一次做前后端分离项目的同学最容易卡住的地方,因为前端工程化依赖Node生态,需要注意的细节比后端还多。我把从创建Vue工程到前后端联调跑通的关键点完整过一遍。
5.1 Vue工程初始化与代理配置
前端工程我用Vite创建,命令很简单:
npm create vite@latest employment-web -- --template vue cd employment-web npm install npm install vue-router@4 pinia axios element-plus npm run dev这里有个常见坑:npm install在Windows上如果网络不好,会出现ERESOLVE unable to resolve dependency tree的报错,一般用npm install --legacy-peer-deps就能绕过。另外Node版本太低会直接报Vite requires Node.js version >=18,所以开发前先确认node -v的结果。
vite.config.js里配置开发环境代理,这是解决联调阶段跨域问题的核心:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })配置之后,前端代码里请求/api/job/list时,Vite开发服务器会把它转发到http://localhost:8080/api/job/list,浏览器里不存在跨域问题。上线之后,代理就交给Nginx处理,这部分放到部署章节。
5.2 Axios封装与路由守卫
Axios实例一定要封装一层。我新建src/utils/request.js,设置baseURL为/api,然后加请求拦截器和响应拦截器。请求拦截器负责在每次请求前把localStorage里的Token添加到请求头:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } ) export default request路由守卫放在src/router/index.js里,判断用户未登录时不能进入需要鉴权的页面:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login' || to.path === '/register') { next() } else if (!token) { next('/login') } else { next() } })这层守卫虽然不能做真正的权限控制,但用户体验上能保证未登录用户进不了系统页面。真正的接口权限还是依赖后端的角色校验,不要依赖前端路由。
5.3 列表页与表单页的核心写法
职位列表页我用了Element Plus的el-table加el-pagination,模板结构是这样的:
<template> <div class="job-list"> <el-form :inline="true" :model="queryForm"> <el-form-item label="关键词"> <el-input v-model="queryForm.keyword" placeholder="职位/岗位" /> </el-form-item> <el-form-item label="城市"> <el-input v-model="queryForm.city" placeholder="工作城市" /> </el-form-item> <el-form-item> <el-button type="primary" @click="loadJobs">查询</el-button> </el-form-item> </el-form> <el-table :data="jobList" stripe> <el-table-column prop="title" label="职位名称" /> <el-table-column prop="companyName" label="企业名称" /> <el-table-column prop="city" label="城市" width="100" /> <el-table-column label="薪资范围" width="120"> <template #default="{ row }"> {{ row.salaryMin }}K - {{ row.salaryMax }}K </template> </el-table-column> <el-table-column label="操作" width="150"> <template #default="{ row }"> <el-button type="primary" link @click="handleApply(row)">投递</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="queryForm.pageNum" v-model:page-size="queryForm.pageSize" :total="total" layout="total, prev, pager, next" @current-change="loadJobs" /> </div> </template>表格里如果返回字段比较多,记得把companyName通过联表查询返回,而不是让前端拿companyId再去查一次接口,那样会多出大量请求,页面性能很差。所以我后端职位列表的SQL是直接JOIN company表,把企业名称一起查出来的。
5.4 联调阶段的常见问题
联调阶段最常见的三个问题:跨域、字段名对不上、日期格式太丑。跨域通过上面的代理配置能解决;字段名对不上多半是因为后端返回的是companyName,前端模板里写成了company_name,用Vue DevTools查看响应数据,照着实际字段名改就行;日期格式问题,在后端实体类的日期字段上加上@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),返回给前端的就是标准时间字符串。
另一个容易踩的坑是前端请求POST数据时,没有设置Content-Type。大多数情况下axios会自动处理,但如果你用form-data传参,后端如果用的是@RequestBody接收JSON,两边就对不上。建议全部约定使用JSON格式,前端post请求时直接传对象,axios会默认序列化成JSON,后端用@RequestBody接收。
6. 完整部署流程:从本地打包到阿里云上线
很多人本地跑得好好的,一部署就各种出问题。我自己在阿里云部署这套系统时也踩了不少坑,这一节把从零到上线的完整流程和关键操作写清楚。这里以Linux服务器为例,系统用CentOS 7或Ubuntu Server 22.04都可以。
6.1 本地环境验证
部署前先在本地把前端打包一次,确认没有编译错误:
cd employment-web npm run build后端打包前,先把application.yml里的数据库地址改成服务器的内网或公网IP,然后执行:
mvn clean package -DskipTests打包成功后,target/目录下会生成employment-0.0.1-SNAPSHOT.jar。这个jar包是SpringBoot内置Tomcat的可执行jar,所有依赖都整合在里面,拷贝到服务器就能跑。前端构建产物在dist/目录,是纯静态文件,交给Nginx托管即可。
6.2 后端打包与Java环境部署
服务器上先装JDK:
sudo apt update sudo apt install openjdk-11-jdk -y java -version然后把jar包上传到服务器,比如放在/opt/employment/目录下。前端dist目录压缩后传上去,解压到/var/www/employment-web/。启动后端进程,我习惯用nohup:
cd /opt/employment nohup java -jar employment-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod > app.log 2>&1 &如果你想让它开机自启或崩溃后自动重启,用systemd注册一个服务更靠谱。在/etc/systemd/system/employment.service写:
[Unit] Description=Employment System After=network.target [Service] User=root WorkingDirectory=/opt/employment ExecStart=/usr/bin/java -jar /opt/employment/employment-0.0.1-SNAPSHOT.jar Restart=always RestartSec=5 SuccessExitStatus=143 [Install] WantedBy=multi-user.target然后执行:
systemctl daemon-reload systemctl enable employment systemctl start employment用systemctl status employment查看服务状态,用journalctl -u employment -f查看实时日志。这一步非常值得做,以后重启服务器后系统能自动恢复,不用手动敲java命令。
6.3 MySQL安装与远程访问配置
服务器上安装MySQL 8.0:
sudo apt install mysql-server -y sudo mysql_secure_installation注意MySQL 8.0在Ubuntu上用root账户默认是auth_socket插件认证,只能本机通过sudo连接。为了让后端服务能连上MySQL,我建议创建一个专门的应用账号:
CREATE USER 'employment'@'localhost' IDENTIFIED BY '你的密码'; GRANT ALL PRIVILEGES ON employment_system.* TO 'employment'@'localhost'; FLUSH PRIVILEGES;后端和MySQL在同一台服务器时,用localhost连接就行,不建议开启MySQL的公网远程访问权限,除非你的运维水平足够。把建表SQL脚本上传到服务器执行:
mysql -u employment -p employment_system < /path/to/init.sql导入后执行SHOW TABLES;确认表都建好了。
6.4 Nginx部署前端与反向代理
安装Nginx后,修改配置文件/etc/nginx/sites-available/default:
server { listen 80; server_name your_domain_or_ip; root /var/www/employment-web; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html;这行是Vue Router的history模式必须配置的,不写的话刷新页面会404。location /api/把前端发来的/api请求反向代理到SpringBoot的8080端口。配置后重启Nginx:
sudo nginx -t sudo systemctl reload nginx现在访问服务器IP,就能看到前端登录页了。如果你买了域名,别忘了在域名解析里加一条A记录指向服务器IP,然后把server_name改成你的域名。
6.5 部署后的自检清单
部署完成后,我建议按这个清单从头到尾走一遍,避免上线后被小问题卡住:
- 浏览器访问首页,能正常打开登录页。
- 学生注册一个账号,能完成登录。
- 企业注册账号,但此时状态是“待审核”,不能发布职位。
- 管理员审核通过企业账号,企业重新登录后可以发布职位。
- 发布一条职位,学生端能看到,投递后企业端能收到。
- 管理员端能看到职位投递统计、就业信息汇总。
- 服务器重启后,后端服务自动恢复,MySQL正常启动,Nginx正常代理。
如果哪一步不正常,先看对应日志。后端用journalctl -u employment -f,Nginx用tail -f /var/log/nginx/error.log,MySQL报错用tail -f /var/log/mysql/error.log。绝大部分部署问题的根因都能从这三类日志里找到。
7. 真实踩坑清单与优化方向
这个项目做完,我把踩过的坑和后面回头看觉得可以优化的点做个梳理,给准备拿这套系统当毕设或练手项目的读者一个参考。
7.1 跨域与Cookie问题
开发阶段用Vite代理后,跨域问题基本不出现。但如果你不用代理,而是直接在前端请求http://localhost:8080/api/...,浏览器就会拦截跨域请求。后端的CorsConfig可以这么解决:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意,如果使用了JWT放在localStorage,其实前端并不需要allowCredentials(true);只有当你使用Cookie时才需要。我这里开着的初衷是兼容后续可能改造成Cookie会话模式,但实际使用中这样会引入额外的OPTIONS预检请求,所以如果你没用到Cookie,建议把allowCredentials设为false,减少一次预检开销。
7.2 MyBatis映射命名冲突
开启map-underscore-to-camel-case后,绝大多数字段都能自动映射,但有两个坑。一个是实体类里如果有is_signed这种以“is”开头的字段,Java属性名会生成isSigned,MyBatis在映射时可能会自动把getIsSigned()方法当成getSigned(),导致查询返回的字段一直是null。解决办法是数据库字段改成signed_flag,或者实体类里明确用@TableField或resultMap标注。
另一个坑是SQL里用到了带别名的聚合字段,比如COUNT(*) AS apply_count,如果实体类属性叫applyCount,映射规则能对上,没问题。但如果你把查出来的结果装进一个没有对应属性的DTO里,会自动丢失,也不会报错。这种隐含问题最耗时间,排查方法就是把后端返回的JSON和SQL结果对比,逐字段核对。开发阶段把MyBatis的log-impl打开,控制台能看到完整SQL和参数,排查效率会高很多。
7.3 时区与数据库连接参数
这个坑我在本地几乎没遇到,部署到云上后突然爆发。现象是MySQL连接时报The server time zone value 'CST' is unrecognized,或者Public Key Retrieval is not allowed。解决方案就是application.yml里的JDBC URL加上serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true。另外,如果服务器时区不是中国标准时间,即使连接成功,NOW()返回的时间也可能差8个小时。建议部署时执行一次:
timedatectl set-timezone Asia/ShanghaiMySQL服务重启后,所有时间字段都按北京时间写入。
7.4 项目还能怎么扩展
如果你想让这个项目在答辩或作品集里更有亮点,以下几个方向是我觉得低成本高收益的扩展点。
第一,加入消息通知功能。学生投递后,企业收到新简历通知;企业更新投递状态后,学生收到通知。通知可以存在数据库里,前端轮询或WebSocket推送。这个功能能明显提升系统完整度,但注意别一上来就做WebSocket,先用简单的“未读消息数+列表”即可。
第二,引入定时任务做数据统计。用SpringBoot的@Scheduled注解,每天凌晨统计一次各学院就业率、各专业平均薪资,存到统计表里。管理员端打开报表页面直接查表即可,不用每次实时大表聚合,性能会好很多。
第三,简历文件上传这块,目前我用的是本地存储,上传的文件直接放在服务器磁盘。生产环境建议改成阿里云OSS或MinIO,否则服务器磁盘满了很难清理。切换步骤也不复杂,引入OSS SDK,把上传接口保存的路径从本地地址改成OSS的URL即可。
第四,如果只想快速扩展后端开发效率,可以把MyBatis升级成MyBatis-Plus,牺牲一部分SQL控制力换来自动CRUD和分页插件,适合赶工期或后期维护。如果你对SQL本身还想继续打磨,那就保持原生MyBatis,这两个方向没有绝对优劣。
最后分享一个我在部署时反复用到的技巧:改完任何配置后,先停服务、清缓存、再启动,不要直接reload。Nginx改完配置要nginx -t检查语法,SpringBoot改完配置要重启进程,Vue改完代码要重新npm run build并确认dist目录已更新。很多“为什么改了没生效”的问题,最后都是缓存或没重启导致的。部署这层看似简单,真正稳定跑起来需要耐心,一步一步按依赖关系来:先MySQL,再后端,最后Nginx。按这个顺序排错,你的上线之路会顺很多。