简介:高校就业管理系统是一套基于SSM架构的Java Web毕业设计源码,适用于高校就业信息发布、学生数据管理与后台维护等场景,服务对象为计算机相关专业毕业生与就业系统开发学习者,也可作为就业管理平台的业务改造参考。项目整合Spring、SpringMVC、Vue、AJAX、Maven、MySQL、MyBatisPlus等技术,采用B/S模式,涵盖用户信息管理、图片素材与视频素材等模块,既可用于课程设计,也能帮助读者理解前后端交互与数据库设计。压缩包整体约9.33MB,文件细分统计暂未提供,包内包含完整工程代码和环境配置信息,配合JDK1.8、MySQL5.7及SQLyog/Navicat等工具即可导入运行。已有137人浏览学习,适合需要完成高校就业主题毕业设计或熟悉SSM+Vue开发流程的读者,可直接对照源码梳理系统设计、接口实现与数据库结构,节省从零搭建的时间。
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、不用JSP+Servlet"。第一个判断维度是部署成本。老一代JSP+Servlet项目要单独装Tomcat、把war包丢到webapps下,部署环境一变就出问题。Spring Boot 内嵌 Tomcat,java -jar 就能跑,很适合学校机房和云服务器这种不追求花哨基础设施的环境。
第二个维度是查询和统计。就业系统里分页、条件查询、聚合统计SQL占了绝大多数需求。MyBatis-Plus 的 LambdaQueryWrapper 写起来直观,内置分页插件,对单表 CRUD 几乎零成本;Hibernate 虽然也灵活,但配置复杂,新手容易在懒加载、一级缓存这些概念上翻车。用 MyBatis-Plus 还有一个隐藏收益:面试时聊起"源码级"的问题,可以从逻辑删除、分页插件拦截SQL这些点讲,比说"用了ORM框架"有说服力。
第三个维度是生态。MySQL 免费、资料多,高校里的教学数据库基本是它。PostgreSQL 功能更强,但一旦遇到权限、编码、备份恢复的问题,身边能请教的人少。数据库选型要考虑维护成本,不是越花哨越好。如果以后要做读写分离、数据仓库,MySQL 往云数据库迁移的路径也最顺。
2.3 项目骨架搭建:Maven依赖与配置文件里不能省的参数
我常用 Maven 标准工程,groupId 用 com.university,artifactId 叫 job-system。包结构分成 config、controller、service、mapper、entity、dto、common、util 这几层,和大部分源码的目录习惯一致,别人接手也容易看懂。
pom.xml 里最关键的依赖如下:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true 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参数说明:allowPublicKeyRetrieval=true 是针对 MySQL 8 的 SHA2 认证插件,不加的话第一次连接偶发 Public Key Retrieval is not allowed;serverTimezone 必须是 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 学生、企业、招聘信息三张核心表的字段设计
表设计是整个项目的根基。我见过不少功能看着齐全的源码,表结构却乱得像草稿纸:没有唯一约束、字段含义不明、时间类型混用,结果在统计阶段全线崩溃。先看第一组表。
学生表 student:
CREATE 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 都要写反引号,特别烦。
企业表 company:
CREATE 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 或分机号。
招聘信息表 job:
CREATE 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,学生端只能看到 status=1 的职位。
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 新记录。状态机要严格:只有 status=0 的记录可以流转到1或2,已审核的协议不能静默改回待审核,这样既能避免管理员误操作,也能保留操作痕迹。agreement_file 存的是服务器本地路径或对象存储 key,不直接存 Base64。
3.3 全局规范:逻辑删除、公共字段与操作日志
除了业务字段,七张表都要做三个统一动作。第一个是公共字段,每张表都带 id、create_time、update_time,MyBatis-Plus 的 MetaObjectHandler 可以在插入更新时自动填充,代码里少写一堆 setCreateTime。第二个是逻辑删除字段 deleted,学生毕业两年后要不要清理数据?我的建议是不要物理删除,万一后面要补报统计,删掉的历史记录找不回来,用逻辑删除字段把记录标记为删除即可。第三个是操作日志表 operation_log:
CREATE 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。核心思路是遍历实体注解,拼出 DDL,Java 类型映射成 MySQL 类型:
@Component public class TableAutoCreator implements ApplicationRunner { @Resource private DataSource dataSource; @Override public void run(ApplicationArguments args) throws Exception { List<TableInfo> 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(",$", ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); } 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时应该映射为 TEXT,Integer 有时候是状态码只需要 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 请求拦截器也要带上 token:
axios.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 PageResult<Job> pageQuery(JobQueryDTO dto, Long companyId) { LambdaQueryWrapper<Job> 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); Page<Job> 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; } }不加这个 Bean,selectPage 返回 total 正常但 records 是全表数据,分页失效。分页参数 pageNum/pageSize 在 Page 里是从1开始的,前端传第0页会查不出数据,接口层做一次统一转换会更稳。
4.3 就业统计报表:一条SQL算出就业率
就业率是系统的核心输出。统计维度通常按学院、专业、学历。student 表里加一个 status 字段:1未就业、2有意向、3已签约、4已就业。核心 SQL:
SELECT 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_type=2 的学生纳入"非失业"口径。建议把统计口径做成一个枚举配置,放进代码里,不要写死在 SQL,否则不同老师问一次改一次,最后没人知道哪个数是准的。
导出 CSV 的简单写法:
public void exportStats(HttpServletResponse response) throws IOException { List<Map<String, Object>> rows = statsMapper.selectStats(2025); response.setContentType("text/csv;charset=UTF-8"); response.setHeader("Content-Disposition", "attachment;filename=stats.csv"); Writer writer = response.getWriter(); writer.write("college,major,total,employed,rate\n"); for (Map<String, 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-starter,3.x 的导入路径和 2.x 不同,配错会导致拦截器没生效。
5.2 中文写入数据库变成问号
现象:学生填的姓名、企业发布的职位标题,insert 成功后 SELECT 出来全是 ???。
原因:三个位置至少有一个字符集不一致。建表时用了默认 latin1,或者 JDBC url 没带 characterEncoding=utf8,或者 Spring Boot 的 HTTP 请求编码没强制 UTF-8。
解决:先执行 SHOW CREATE TABLE student 确认 DEFAULT CHARSET,改表用 ALTER TABLE student CONVERT TO CHARACTER SET utf8mb4;然后确认 url 里同时有 useUnicode=true 和 characterEncoding=utf8;最后在 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 里没带 serverTimezone,MySQL 驱动默认使用服务器时区,而 MySQL 服务器时区如果是 UTC,存进去的 datetime 就会少8小时。另一种情况是实体类用了 java.util.Date,Jackson 和 JDBC 的时区解析不一致,输出到前端时又偏移一次。
解决:url 加 serverTimezone=Asia/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 接口一直401,token 明明在请求头里
现象:用 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 BOM,BOM 是几个特殊字节,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 LambdaQueryWrapper<User>() .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 快得多。先登录拿 token:
curl -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 换成随机字符串,删除测试数据,然后备份数据库。这些事二十分钟能做完,但能省掉一整天的手忙脚乱。希望帮到你。
本文还有配套的精品资源,点击获取