
简介高校就业管理系统是一套基于SSM架构的Java Web毕业设计源码适用于高校就业信息发布、学生数据管理与后台维护等场景服务对象为计算机相关专业毕业生与就业系统开发学习者也可作为就业管理平台的业务改造参考。项目整合Spring、SpringMVC、Vue、AJAX、Maven、MySQL、MyBatisPlus等技术采用B/S模式涵盖用户信息管理、图片素材与视频素材等模块既可用于课程设计也能帮助读者理解前后端交互与数据库设计。压缩包整体约9.33MB文件细分统计暂未提供包内包含完整工程代码和环境配置信息配合JDK1.8、MySQL5.7及SQLyog/Navicat等工具即可导入运行。已有137人浏览学习适合需要完成高校就业主题毕业设计或熟悉SSMVue开发流程的读者可直接对照源码梳理系统设计、接口实现与数据库结构节省从零搭建的时间。1. 高校就业系统一个Java Web项目背后真实存在的业务痛点每年九月的秋招季高校就业办老师的电脑里通常躺着四五份Excel表格学生就业意向表、企业招聘台账、签约信息登记表、就业率月报表。这些表格由不同的人维护格式不统一数据靠微信和邮件来回传到了月度统计的时候就业率数字经常对不上。高校就业系统这个题目之所以常见就是因为它解决的问题太真实了——把学生、企业、招聘、签约、统计整个链条放到一个Web平台上让每个角色打开浏览器就能操作。对准备用Java做毕业设计或刚入行Java Web开发的人来说这是一个需求边界清晰、能充分展示Spring Boot MyBatis系列技术栈、又方便在面试里讲透的练手项目。下面我按自己实际做这类系统的思路从业务建模、表设计到代码落地一步步拆开讲。2. 先拆业务再选技术高校就业系统的模块边界与Java技术选型2.1 就业系统的核心业务模块三端闭环与七张核心表如果你去搜高校就业系统源码会发现很多版本只有学生表、企业表、招聘表登录进去就完事了但就业办老师真正需要的统计导出功能反而没有实现。动手前一定要先画一张业务闭环图学生端三个动作填意向、投简历、传协议企业端两个动作发布职位、查收简历管理端三个动作审核、统计分析、导表。整个系统必须闭环学生维护信息 → 企业发布岗位 → 学生投递 → 签约 → 管理端审核 → 统计汇报。对应到数据库表至少这六张user登录账号、student学生档案、company企业档案、job招聘信息、intention就业意向、agreement就业协议。我一般还会加一张 operation_log 操作日志表记录管理员每次审核动作方便就业办追溯。这样一来正好七张核心表第三章会逐一展开。这里面最容易设计错的是把学生和企业各做一张登录表。我从实用角度建议统一认证表把学生、企业、管理员的账号密码角色都放进 user再通过 student 和 company 表存扩展字段。理由有两个一是登录逻辑只写一套不需要三种账号各自的校验逻辑二是统计时不需要跨用户表 join直接按 student.college 分组就行。这种设计在高校就业系统源码里也常见但对刚接触的人不一定能想通。2.2 为什么是 Spring Boot MyBatis-Plus MySQL选型的三个判断维度作为Java工程师选型时要能回答为什么不用SSH、不用JSPServlet。第一个判断维度是部署成本。老一代JSPServlet项目要单独装Tomcat、把war包丢到webapps下部署环境一变就出问题。Spring Boot 内嵌 Tomcatjava -jar 就能跑很适合学校机房和云服务器这种不追求花哨基础设施的环境。第二个维度是查询和统计。就业系统里分页、条件查询、聚合统计SQL占了绝大多数需求。MyBatis-Plus 的 LambdaQueryWrapper 写起来直观内置分页插件对单表 CRUD 几乎零成本Hibernate 虽然也灵活但配置复杂新手容易在懒加载、一级缓存这些概念上翻车。用 MyBatis-Plus 还有一个隐藏收益面试时聊起源码级的问题可以从逻辑删除、分页插件拦截SQL这些点讲比说用了ORM框架有说服力。第三个维度是生态。MySQL 免费、资料多高校里的教学数据库基本是它。PostgreSQL 功能更强但一旦遇到权限、编码、备份恢复的问题身边能请教的人少。数据库选型要考虑维护成本不是越花哨越好。如果以后要做读写分离、数据仓库MySQL 往云数据库迁移的路径也最顺。2.3 项目骨架搭建Maven依赖与配置文件里不能省的参数我常用 Maven 标准工程groupId 用 com.universityartifactId 叫 job-system。包结构分成 config、controller、service、mapper、entity、dto、common、util 这几层和大部分源码的目录习惯一致别人接手也容易看懂。pom.xml 里最关键的依赖如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency /dependencies这里有两个参数容易踩坑。mybatis-plus 3.5.7 之前的某些版本对 Spring Boot 3 兼容不好如果你用的是 Spring Boot 3.x需要引入 mybatis-plus-spring-boot3-starter 而不是 mybatis-plus-boot-starter。mysql 坐标在 Spring Boot 3 里是 com.mysql:mysql-connector-j如果继续写 mysql-connector-java启动时直接报找不到类。application.yml 配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/job_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0参数说明allowPublicKeyRetrievaltrue 是针对 MySQL 8 的 SHA2 认证插件不加的话第一次连接偶发 Public Key Retrieval is not allowedserverTimezone 必须是 Asia/Shanghai不写会导致时间差8小时这是后面避坑章节的主角。max-file-size 给学生上传协议扫描件留的默认1MB 一个 PDF 就会被拒。log-impl 配成 StdOutImpl 后控制台会打印完整SQL排查问题非常直观上线前记得关掉否则日志量太大。启动类这样写SpringBootApplication MapperScan(com.university.job.mapper) public class JobApplication { public static void main(String[] args) { SpringApplication.run(JobApplication.class, args); } }MapperScan 是 MyBatis 的注解把它放在启动类上所有 Mapper 接口直接注册省得每个接口加 Mapper。到这里执行 mvn spring-boot:run项目已经能起来访问 localhost:8080 会看到一个报错页这说明 Spring 本身没问题只是还没写 Controller。下一步建表。3. 把数据库表设计出来就业系统最关键的7张表与字段取舍3.1 学生、企业、招聘信息三张核心表的字段设计表设计是整个项目的根基。我见过不少功能看着齐全的源码表结构却乱得像草稿纸没有唯一约束、字段含义不明、时间类型混用结果在统计阶段全线崩溃。先看第一组表。学生表 studentCREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 关联user.id, student_no VARCHAR(20) UNIQUE NOT NULL, name VARCHAR(50) NOT NULL, gender TINYINT DEFAULT 1 COMMENT 1男 2女, college VARCHAR(100) COMMENT 学院, major VARCHAR(100) COMMENT 专业, class_name VARCHAR(50) COMMENT 班级, phone VARCHAR(20), email VARCHAR(100), education VARCHAR(20) DEFAULT 本科, graduation_year INT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_id (user_id) );student_no 是学生登录账号必须唯一。college 和 major 是两个统计维度后面就业率按院系、专业分组时全靠它们。graduation_year 用 INT 不用 DATE因为只需要年份做筛选时可以直接等于或小于。注意 class_name 不能叫 class因为 class 是MySQL保留字硬要用的话每条 SQL 都要写反引号特别烦。企业表 companyCREATE TABLE company ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 关联user.id, company_name VARCHAR(100) NOT NULL, credit_code VARCHAR(30) UNIQUE COMMENT 统一社会信用代码, industry VARCHAR(50), scale VARCHAR(20) COMMENT 20-99人/100-499人, address VARCHAR(255), contact_name VARCHAR(50), contact_phone VARCHAR(20), status TINYINT DEFAULT 0 COMMENT 0待认证 1已认证 2已拉黑, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_id (user_id) );credit_code 加唯一索引防止同一家公司重复注册。status 做企业认证状态管理员审核通过后企业才有权限发职位。contact_phone 用 VARCHAR(20)不要用 INT手机号有可能带 86 或分机号。招聘信息表 jobCREATE TABLE job ( id BIGINT PRIMARY KEY AUTO_INCREMENT, company_id BIGINT NOT NULL, title VARCHAR(100) NOT NULL, category VARCHAR(50) COMMENT 技术/职能/市场, salary_min INT COMMENT 月薪单位K, salary_max INT, city VARCHAR(50), degree_required VARCHAR(20) DEFAULT 本科, position_count INT DEFAULT 1, description TEXT, status TINYINT DEFAULT 0 COMMENT 0待审核 1发布中 2已下线, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_company_id (company_id), KEY idx_status (status) );salary_min 和 salary_max 用整数不要用字符串5k-8k因为后面筛选月薪大于8K时字符串没法比较。description 用 TEXT 存长文。status 是发布状态机管理员审核通过才变成1学生端只能看到 status1 的职位。3.2 就业意向与签约表状态机字段的核心设计就业意向表 intention 记录学生毕业后打算就业、升学、创业、暂不就业。它是就业率统计的第一层来源。CREATE TABLE intention ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, intention_type TINYINT COMMENT 1就业 2升学 3创业 4暂不就业, target_city VARCHAR(100), target_industry VARCHAR(50), salary_expectation INT, remark VARCHAR(255), status TINYINT DEFAULT 0 COMMENT 0待确认 1已确认, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_student (student_id) );注意这个 UNIQUE KEY uk_student(student_id)一个学生只能有一条当前意向修改时执行 UPDATE 而不是 INSERT。很多翻车现场就是因为缺这个唯一键同一学生产生多条意向统计时 count 全是重复的。就业协议表 agreement 保存最终签约信息CREATE TABLE agreement ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, company_id BIGINT NOT NULL, job_title VARCHAR(100), sign_date DATE, agreement_file VARCHAR(255) COMMENT 协议扫描件路径, status TINYINT DEFAULT 0 COMMENT 0待学校审核 1已审核 2已驳回, reject_reason VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_agreement (student_id) );这里再放一个 UNIQUE KEY 约束 student_id是为了防止同一个学生签多份协议。现实中存在毁约再签但管理系统通常只保留当前有效协议如果真有毁约走 UPDATE 覆盖 status而不是 INSERT 新记录。状态机要严格只有 status0 的记录可以流转到1或2已审核的协议不能静默改回待审核这样既能避免管理员误操作也能保留操作痕迹。agreement_file 存的是服务器本地路径或对象存储 key不直接存 Base64。3.3 全局规范逻辑删除、公共字段与操作日志除了业务字段七张表都要做三个统一动作。第一个是公共字段每张表都带 id、create_time、update_timeMyBatis-Plus 的 MetaObjectHandler 可以在插入更新时自动填充代码里少写一堆 setCreateTime。第二个是逻辑删除字段 deleted学生毕业两年后要不要清理数据我的建议是不要物理删除万一后面要补报统计删掉的历史记录找不回来用逻辑删除字段把记录标记为删除即可。第三个是操作日志表 operation_logCREATE TABLE operation_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, operator_id BIGINT NOT NULL, operator_name VARCHAR(50), action_type VARCHAR(50) COMMENT audit_job/audit_agreement, target_id BIGINT, detail VARCHAR(500), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );管理员审核职位、审核协议、修改学生状态时都插入一条日志。别小看这张表就业办在月度汇报时如果需要证明我们审核了每家企业的招聘信息它是最直接的证据。3.4 根据 Java 实体类生成建表 SQL 的实践与边界标题里的 Java 源码项目经常要演示实体类如何对应数据库表。严格来说MyBatis-Plus 本身不提供实体类自动建表能力但可以用 TableInfoHelper 写一个初始化组件在开发环境启动时自动执行 CREATE TABLE IF NOT EXISTS。核心思路是遍历实体注解拼出 DDLJava 类型映射成 MySQL 类型Component public class TableAutoCreator implements ApplicationRunner { Resource private DataSource dataSource; Override public void run(ApplicationArguments args) throws Exception { ListTableInfo tableInfos TableInfoHelper.getTableInfos(); for (TableInfo info : tableInfos) { String sql buildCreateSql(info); try (Connection conn dataSource.getConnection(); Statement stmt conn.createStatement()) { stmt.executeUpdate(sql); } } } private String buildCreateSql(TableInfo info) { StringBuilder sb new StringBuilder(CREATE TABLE IF NOT EXISTS info.getTableName() (); for (TableFieldInfo field : info.getFieldList()) { sb.append().append(field.getColumn()).append( ) .append(mapType(field.getPropertyType().getSimpleName())) .append(,); } return sb.toString().replaceAll(,$, ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;); } private String mapType(String javaType) { switch (javaType) { case String: return VARCHAR(255); case Integer: return INT; case Long: return BIGINT; case LocalDateTime: return DATETIME; case BigDecimal: return DECIMAL(10,2); default: return TEXT; } } }这个组件的边界很清楚String 字段长度大于255时应该映射为 TEXTInteger 有时候是状态码只需要 TINYINT这些启发式规则在一个通用工具里很难全做对。所以我的结论是自动建表适合开发环境快速初始化生产环境必须用经过审核、带索引和注释的正式 DDL 脚本。如果项目用了 Flyway就把正式 DDL 放 migration 目录启动时自动执行且记录版本比这个临时组件靠谱得多。4. 核心功能实现从登录鉴权到就业统计报表的编码落地4.1 三种角色的JWT登录密码加密与token拦截用户表 user 是所有角色的统一入口CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(100) NOT NULL, role TINYINT NOT NULL COMMENT 1学生 2企业 3管理员, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );密码必须用 BCryptPasswordEncoder 加密不要用 MD5。登录接口RestController RequestMapping(/api/auth) public class AuthController { Resource private AuthService authService; PostMapping(/login) public Result login(RequestBody LoginDTO dto) { User user authService.login(dto.getUsername(), dto.getPassword()); if (user null) { return Result.error(账号或密码错误); } String token JwtUtil.createToken(user.getId(), user.getRole()); return Result.ok(token); } }JWT 工具类public class JwtUtil { private static final String SECRET change-me-to-a-random-secret; private static final long EXPIRE 1000 * 60 * 60 * 12; public static String createToken(Long userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parse(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }SECRET 在线上必须改为随机值并放到环境变量里写死在代码里是安全隐患。EXPIRE 设 12 小时比较合适学生可能一个上午都在投简历token 太短要反复登录太长则改密码后旧 token 依然有效。拦截器实现public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null validate(token)) { request.setAttribute(userId, JwtUtil.parse(token).getSubject()); return true; } response.setStatus(401); return false; } }注册时加白名单否则登录接口自己也会被拦截Configuration public class WebConfig implements WebMvcConfigurer { Resource private JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register); } }注意 excludePathPatterns 里漏掉静态资源会导致页面 css/js 加载不出来。前端的 axios 请求拦截器也要带上 tokenaxios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; });这里把 token 放在 Authorization 头后端解析时也要读同一个头别一个用 header 一个用参数出了 401 很难排查。4.2 招聘信息发布审核与分页查询企业账号登录后只能管理自己公司的职位。控制器里从 token 中取 userId再查 company 获得 companyId所有查询都带 companyId 条件防止越权。服务层除了 CRUD还要加状态校验企业未认证时不允许发布职位。Service public class JobServiceImpl implements JobService { Resource private JobMapper jobMapper; Resource private CompanyMapper companyMapper; public void saveJob(Long userId, Job job) { Company company companyMapper.selectByUserId(userId); if (company.getStatus() ! 1) { throw new BusinessException(企业未认证不能发布招聘信息); } job.setCompanyId(company.getId()); job.setStatus(0); // 新职位先待审核 jobMapper.insert(job); } public PageResultJob pageQuery(JobQueryDTO dto, Long companyId) { LambdaQueryWrapperJob wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(dto.getTitle())) { wrapper.like(Job::getTitle, dto.getTitle()); } if (companyId ! null) { wrapper.eq(Job::getCompanyId, companyId); } if (dto.getStatus() ! null) { wrapper.eq(Job::getStatus, dto.getStatus()); } wrapper.orderByDesc(Job::getCreateTime); PageJob page new Page(dto.getPageNum(), dto.getPageSize()); jobMapper.selectPage(page, wrapper); return new PageResult(page); } }分页插件配置是 MyBatis-Plus 最容易遗漏的一步Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }不加这个 BeanselectPage 返回 total 正常但 records 是全表数据分页失效。分页参数 pageNum/pageSize 在 Page 里是从1开始的前端传第0页会查不出数据接口层做一次统一转换会更稳。4.3 就业统计报表一条SQL算出就业率就业率是系统的核心输出。统计维度通常按学院、专业、学历。student 表里加一个 status 字段1未就业、2有意向、3已签约、4已就业。核心 SQLSELECT college, major, COUNT(*) AS total, SUM(CASE WHEN status 4 THEN 1 ELSE 0 END) AS employed, ROUND(SUM(CASE WHEN status 4 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS rate FROM student WHERE graduation_year #{year} GROUP BY college, major ORDER BY rate DESC;如果升学也算就业就需要把 intention 表中 intention_type2 的学生纳入非失业口径。建议把统计口径做成一个枚举配置放进代码里不要写死在 SQL否则不同老师问一次改一次最后没人知道哪个数是准的。导出 CSV 的简单写法public void exportStats(HttpServletResponse response) throws IOException { ListMapString, Object rows statsMapper.selectStats(2025); response.setContentType(text/csv;charsetUTF-8); response.setHeader(Content-Disposition, attachment;filenamestats.csv); Writer writer response.getWriter(); writer.write(college,major,total,employed,rate\n); for (MapString, Object row : rows) { writer.write(row.get(college) , row.get(major) , row.get(total) , row.get(employed) , row.get(rate) \n); } writer.flush(); }这段代码在 Excel 打开时大概率中文乱码原因和解决办法会在避坑章节单独说。如果想彻底避开编码问题直接用 EasyExcel 导出 .xlsx 是最省心的。5. 避坑手册就业系统开发中的5个典型踩坑记录就业系统功能不复杂但数据流长角色多坑往往不在某个框架点而在数据从录入到统计的中间链路上。这里列5个我实测复现率极高的问题。5.1 分页查询返回 total 正常records 却是空列表现象分页接口返回 { total: 123, records: [] }页面一片空白看日志却发现 selectPage 执行了正常的 count 查询和 select 查询。原因MyBatis-Plus 的 PaginationInnerInterceptor 没有注册。selectPage 在不带分页插件时只会查询全部数据然后内存截断但 MyBatis-Plus 设计成没有拦截器时不生成 limit因此 records 拿不到内容total 却来自 count 查询现象很有迷惑性。解决在 config 包里确认 MybatisPlusConfig 存在且 Bean 方法返回的拦截器里 addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL))。如果已经加了还不行检查引入的包是不是 mybatis-plus-spring-boot3-starter3.x 的导入路径和 2.x 不同配错会导致拦截器没生效。5.2 中文写入数据库变成问号现象学生填的姓名、企业发布的职位标题insert 成功后 SELECT 出来全是 ???。原因三个位置至少有一个字符集不一致。建表时用了默认 latin1或者 JDBC url 没带 characterEncodingutf8或者 Spring Boot 的 HTTP 请求编码没强制 UTF-8。解决先执行 SHOW CREATE TABLE student 确认 DEFAULT CHARSET改表用 ALTER TABLE student CONVERT TO CHARACTER SET utf8mb4然后确认 url 里同时有 useUnicodetrue 和 characterEncodingutf8最后在 application.yml 加 HTTP 编码server: servlet: encoding: charset: UTF-8 enabled: true force: trueforce: true 很关键不写的话只对 request 生效response 的默认编码可能还是 ISO-8859-1。5.3 创建时间比北京时间少了8小时现象create_time 字段显示 07:00当前北京时间是 15:00相差8小时。原因JDBC url 里没带 serverTimezoneMySQL 驱动默认使用服务器时区而 MySQL 服务器时区如果是 UTC存进去的 datetime 就会少8小时。另一种情况是实体类用了 java.util.DateJackson 和 JDBC 的时区解析不一致输出到前端时又偏移一次。解决url 加 serverTimezoneAsia/Shanghai实体日期字段统一用 LocalDateTime。已经错乱的历史数据不能用配置修回来写一条 update 把 create_time 加8小时即可类似 update student set create_time date_add(create_time, interval 8 hour) where create_time 2024-01-01。这个教训我吃过一次上线两周后统计报表时间轴全部错位只能半夜改库。5.4 接口一直401token 明明在请求头里现象用 Postman 带 Authorization 头请求接口正常前端网页却一直 401。原因常见三个。前端 axios 拦截器只在某个组件里注册页面路由切换后拦截器不存在token 存的是 localStorage刷新后丢失或者 JWT 验签的 SECRET 与生成时不一致JwtUtil 写成静态类密钥从不同配置读取导致前后不一致。解决前端的 axios 拦截器做成全局单例在 main.js 或入口文件创建不要写在组件里。后端 JwtUtil 的 SECRET 从 application.yml 读取并通过 ConfigurationProperties 注入确保生成和解析用的是同一个值。排查时先看浏览器 Network 的请求头里 Authorization 有没有值再看后端日志有没有 JwtException 或 SignatureException能定位到具体哪一层。5.5 CSV 导出后 Excel 打开乱码现象用 Notepad 打开 CSV 文件中文正常用 Excel 打开全是乱码。原因CSV 本质是纯文本文件Excel 在 Windows 下默认按本地编码 GBK 解析无 BOM 的 UTF-8 文件中文字符被拆开自然乱码。解决最简单的方法是输出 UTF-8 BOMBOM 是几个特殊字节Excel 看到它就知道按 UTF-8 解码byte[] bom new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; response.getOutputStream().write(bom);在写表头之前先输出这三个字节即可。如果不想和 BOM 打交道直接改用 EasyExcel 导出 .xlsx内部是 XML 编码不会出现这种兼容性问题。注意 BOM 只能写在文件开头如果已经写了表头再补Excel 依然乱码。5.6 问题排查的通用顺序就业系统联调时我按接口→SQL→前端的顺序排查。先看浏览器 Network 的响应状态码401 处理拦截器500 看后端日志堆栈如果报 SQL 异常把 log-impl 开启后复制控制台 SQL 到 Navicat 单独跑一遍前端问题先确认接口返回是否规范再检查渲染逻辑。最忌讳一上来就加断点调试前端往往花半小时才发现是后端接口 path 少了一个斜杠。6. 上线前最后一步数据初始化与接口验证的三个技巧系统写完最容易翻车的地方是换了一台机器就跑不起来因为测试环境里积累了手点的数据。我习惯在项目里放一个初始化组件每次启动时检查默认账号是否存在不存在就自动创建。这样不论是本机还是部署到服务器第一步都是干净的。Component public class DataInitializer implements ApplicationRunner { Resource private UserMapper userMapper; Override public void run(ApplicationArguments args) { if (userMapper.selectCount(new LambdaQueryWrapperUser() .eq(User::getUsername, admin)) 0) { User admin new User(); admin.setUsername(admin); admin.setPassword(new BCryptPasswordEncoder().encode(admin123)); admin.setRole(3); admin.setStatus(1); userMapper.insert(admin); } } }这个类放在 init 包下不要混进业务 service避免以后的业务改动影响到初始化逻辑。密码 admin123 只用于演示部署时必须改成强密码。验证接口我喜欢用 curl比打开 Postman 快得多。先登录拿 tokencurl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:admin123}拿到 token 后请求统计接口curl http://localhost:8080/api/stats/summary \ -H Authorization: token检查返回 JSON 里的 total 和 employed 字段是否和首页展示一致。然后按真实业务顺序跑一遍浏览器学生登录、填写意向、企业发布职位、管理员审核、查看统计报表。这个流程走完系统才算真正可用。上线前还有一个我吃过亏的习惯把 mybatis-plus 的 log-impl 从 StdOutImpl 改成 NoLoggingImpl防止敏感的学生手机号、身份证号被打印到日志文件里顺手把 JWT 的 SECRET 换成随机字符串删除测试数据然后备份数据库。这些事二十分钟能做完但能省掉一整天的手忙脚乱。希望帮到你。本文还有配套的精品资源点击获取