简介:这份毕业设计资料包围绕学籍管理系统展开,面向高校计算机相关专业学生及毕业设计、课程设计开发者,提供从需求分析、系统设计、开发实现到测试部署的完整参考。压缩包共24个文件,以Java源码和class编译文件为主体,辅以bak备份、doc论文与报告、mdb数据库及rar压缩包,整体约323KB,便于对照阅读论文与程序结构。已有346人学习,具备一定参考热度。内容覆盖学生信息、课程、成绩和学籍变动等管理模块,涉及关系型数据库表设计、增删改查功能、MVC架构以及单元测试、集成测试和部署维护思路;配套文档可用于梳理论文框架,源码可帮助理解模块组织和实现细节,适合借鉴系统结构、完成毕业设计写作或进行二次开发。
1. 学籍管理系统毕设:先看清它能干什么,再谈怎么跑
前阵子有人拿着一份学籍管理系统毕业论文的配套源码来找人看,答辩前两天,项目在他自己机器上跑得好好的,换到答辩教室那台笔记本上直接白屏,后端控制台一片红。这类场景太常见了:一份毕业设计工程,代码本身没多难,难的是别人拿到手之后,不知道从哪一层先动。
这份资源的主体就是一套完整的学籍管理系统,围绕「学生—班级—专业—院系」四级归属组织数据,覆盖学号档案维护、成绩录入与查询、学籍异动(休学、复学、转专业、退学)、角色权限菜单、数据统计看板和 Excel 批量导入导出;配套还有论文正文、开题与任务书这类文档材料。它最适合两类人:一类是拿它当毕设底稿、需要在一周内跑通并讲清楚设计思路的在校生;另一类是想完整练一遍 CRUD 全链路、把前后端串起来的初级开发。
2. 技术栈与工程目录:Spring Boot + Vue 这套组合为什么适合毕设
毕设工程的技术选型,从来不是选最先进的,而是选「能讲清楚、能跑起来、能改得动」的。这套学籍管理系统走的是标准的 Java 后端 + 前后端分离前端路线,属于高校毕设里最常见、也最好答辩的一档。
2.1 技术栈组合与各自承担的职责
后端主体是 Spring Boot,持久层用 MyBatis-Plus,数据库 MySQL,登录鉴权走 JWT,前端是 Vue 加 Element UI,图表用 ECharts,Excel 导入导出用 EasyExcel。这套组合的价值在于每个组件都有明确边界,答辩时被问到「为什么用 MyBatis-Plus 而不用 JPA」,你至少能答出「条件构造器写动态查询更顺手,分页插件开箱即用」这种有内容的回答。
| 层次 | 技术 | 常见版本 | 在系统里干的事 |
|---|---|---|---|
| 运行环境 | JDK | 8 / 11 | 后端运行基础 |
| 后端框架 | Spring Boot | 2.7.x | 容器、自动配置、Web 接口 |
| 持久层 | MyBatis-Plus | 3.5.x | 单表 CRUD、分页、逻辑删除 |
| 数据库 | MySQL | 5.7 / 8.0 | 学籍、成绩、异动数据存储 |
| 鉴权 | JWT | jjwt 0.9+ | 无状态登录凭证 |
| 前端框架 | Vue | 2.6.x | 页面渲染与路由 |
| UI 组件 | Element UI | 2.15.x | 表格、表单、弹窗、分页器 |
| 图表 | ECharts | 5.x | 专业分布、成绩区间统计 |
| 导出 | EasyExcel | 3.x | 学籍名单批量导入导出 |
需要提醒一点:如果你手上的这份工程是老一版的 SSM 或者 JSP + Servlet 结构,后面讲的表结构和业务逻辑依然成立,只是依赖注入从注解换成了 XML 配置,页面从 Vue 换成了 JSP,改动思路不变。
2.2 目录结构:后端分层与前端的对应关系
工程骨架决定了你以后改代码时能不能一眼找到位置。这套系统的后端是典型的 controller / service / mapper / entity 四层,前端按 views 目录对应到业务模块,两边的命名基本能对上号。
sms/ ├── sms-server/ 后端工程 │ ├── src/main/java/com/example/sms/ │ │ ├── SmsApplication.java 启动类 │ │ ├── common/ 统一返回、异常、常量 │ │ │ ├── Result.java │ │ │ ├── PageResult.java │ │ │ └── GlobalExceptionHandler.java │ │ ├── config/ 跨域、拦截器、MyBatis-Plus 配置 │ │ ├── controller/ StudentController、ScoreController ... │ │ ├── service/ 业务接口与实现 │ │ ├── mapper/ Mapper 接口 + resources/mapper/*.xml │ │ ├── entity/ 与表一一对应的实体 │ │ ├── dto/ vo/ 入参与出参对象 │ │ └── utils/ JwtUtil、ExcelUtil、DateUtil │ └── src/main/resources/ │ ├── application.yml 数据源、端口、JWT 密钥 │ └── mapper/ 复杂联表查询的 XML └── sms-web/ 前端工程 ├── src/api/ 按模块拆的接口函数 ├── src/views/student/ 学生列表、编辑、异动申请 ├── src/views/score/ 成绩录入与查询 ├── src/router/ 路由表 + 权限守卫 ├── src/store/ Vuex,存 token 与用户信息 └── src/utils/request.js axios 实例与拦截器这套分层最容易被改坏的地方是entity和vo混用。毕设阶段很多人图省事,列表接口直接返回实体,页面上再靠字典翻译院系名,等到要加一个「班级人数」字段就开始到处打补丁。我的习惯是从一开始就把列表出参单独定义成StudentVO,实体只做数据库映射,后面加字段不影响主流程。
2.3 从零启动:环境、配置、初始化数据三步走
拿到工程后先别急着点运行,按顺序做三件事,能省掉后面一大半的排查时间。
第一步,把数据库脚本单独跑一遍。资源里一般会有.sql文件,用命令行导入比用图形化工具更容易发现问题,因为报错会直接打在终端上。
# 创建库并指定字符集,避免后续中文乱码 mysql -u root -p -e "CREATE DATABASE sms DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 导入结构与初始数据 mysql -u root -p sms < sms-server/sql/sms.sql # 确认表数量与初始账号 mysql -u root -p -e "USE sms; SHOW TABLES; SELECT username, role FROM sys_user;"第二步,改application.yml里的数据源和端口。这里有两个参数经常被忽略:连接串里的serverTimezone和useUnicode,前者不对会导致时间字段差 8 小时,后者不对会出现中文问号。
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/sms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你的密码 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # 下划线字段自动映射驼峰属性第三步,前后端分别启动。后端用 Maven 打包或直接跑启动类,前端装依赖后起 dev server,注意前端配置里的接口地址要和后端端口一致。
# 后端 cd sms-server && mvn clean spring-boot:run # 前端 cd sms-web && npm install && npm run serve提示:
npm install如果卡在某个包上,先确认 Node 版本。Vue 2 项目在 Node 17 以上经常报ERR_OSSL_EVP_UNSUPPORTED,降到 Node 16 或加环境变量都能绕过去。
3. 数据库设计:从院系到学籍异动,八张表撑起整个系统
学籍管理系统的复杂度几乎全在数据模型上。表设计得顺,后面的增删改查都是体力活;表设计歪了,光是一个「转专业后历史班级怎么留痕」就能把整个业务逻辑推翻重来。
3.1 实体关系:院系—专业—班级—学生的四级归属
整个系统的数据主线是一条自上而下的归属链:院系下设专业,专业下设班级,班级下挂学生。学生表通过三个外键分别指向这三层,好处是查询任意层级都能直接命中,坏处是数据一致性要靠业务层保证。
除了这条主线,还有几条支线:课程表被成绩表引用,成绩表被学生表引用;学籍异动表独立记录每一次状态变更,不覆盖学生主表的历史;系统用户表与学生表通过学号弱关联,管理员账号和学生账号是两套体系。
这里有个设计取舍值得说:学生和班级的关系,是存class_id外键,还是存班级名称字符串?毕设里常见后者,图省事,但一旦班级改名,历史数据全对不上。我的建议是老老实实存 ID,展示时联表或字典翻译,多写十几行 SQL,换来的是后续改数据不用满库UPDATE。
3.2 建表 SQL:学生表与学籍异动表的关键字段
学生表是整个系统的核心,字段设计要覆盖基本信息、归属信息、状态信息三块。下面这段是精简后的结构,保留了实际项目里真正会用到的字段。
CREATE TABLE `student` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `student_no` varchar(20) NOT NULL COMMENT '学号,业务唯一键', `name` varchar(50) NOT NULL COMMENT '姓名', `gender` tinyint NOT NULL DEFAULT 1 COMMENT '1男 2女', `birth_date` date DEFAULT NULL COMMENT '出生日期', `id_card` varchar(18) DEFAULT NULL COMMENT '身份证号', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `department_id` bigint NOT NULL COMMENT '所属院系', `major_id` bigint NOT NULL COMMENT '所属专业', `class_id` bigint NOT NULL COMMENT '所属班级', `enroll_date` date NOT NULL COMMENT '入学日期', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1在读 2休学 3复学 4转专业 5退学 6毕业', `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_student_no` (`student_no`), KEY `idx_class` (`class_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生学籍主表';异动表是另一张关键表,它的作用是留痕。每一次休学、复学、转专业、退学都往这张表插一条记录,记录变更前后的值、原因、审批状态和操作人,学生主表只保留最新状态。
CREATE TABLE `status_change` ( `id` bigint NOT NULL AUTO_INCREMENT, `student_no` varchar(20) NOT NULL COMMENT '学号', `change_type` tinyint NOT NULL COMMENT '2休学 3复学 4转专业 5退学', `before_value` varchar(100) DEFAULT NULL COMMENT '变更前(如原班级ID)', `after_value` varchar(100) DEFAULT NULL COMMENT '变更后', `reason` varchar(255) DEFAULT NULL COMMENT '异动原因', `audit_status` tinyint NOT NULL DEFAULT 0 COMMENT '0待审 1通过 2驳回', `apply_user` varchar(50) DEFAULT NULL, `audit_user` varchar(50) DEFAULT NULL, `audit_time` datetime DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_student_no` (`student_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学籍异动记录表';注意:
student_no上加了唯一索引,导入 Excel 批量新增时如果数据里有重复学号,整批插入会直接失败。稳妥做法是先查重再插,或者用INSERT IGNORE并把失败行号返回给前端。
3.3 状态字段与逻辑删除:两个最容易被写死的地方
status字段用 tinyint 存枚举值,而不是直接存「在读」「休学」这种中文字符串,理由有三个:索引效率高、改文案不用动数据、状态流转判断时可以直接用数字比较。代价是前端要维护一份字典映射,这份字典最好放在后端接口里返回,前后端共用一份,避免两边写死不一致。
deleted字段是 MyBatis-Plus 逻辑删除的约定字段,配置好之后deleteById会自动变成UPDATE ... SET deleted = 1,查询时自动拼deleted = 0。这个机制在毕设里非常实用,因为学生数据一旦被物理删除,关联的成绩、异动记录全成孤儿数据,答辩演示时误删一条就麻烦了。
配置方式是在application.yml里加两行:
mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0加完之后要注意一个坑:手写的 XML 联表查询不会自动带deleted = 0条件,需要自己在 SQL 里补。这一点后面排查章节还会提到。
4. 后端核心链路:登录鉴权、分页查询与异动事务的完整实现
后端的代码量看着大,真正值得逐行读的其实只有三块:登录怎么发凭证、列表怎么查、异动怎么保证不出错。这三块读懂了,剩下的增删改查都是同一套模板换个实体名。
4.1 登录鉴权:一次请求从进入到返回的完整链路
登录接口要做三件事:校验账号密码、签发 token、返回用户角色和菜单权限。密码不能明文比对,用 BCrypt 做哈希校验是标准做法。
@PostMapping("/login") public Result<LoginVO> login(@RequestBody @Valid LoginDTO dto) { // 1. 按用户名查用户,查不到统一提示,不暴露账号是否存在 SysUser user = userService.getByUsername(dto.getUsername()); if (user == null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { throw new BusinessException("用户名或密码错误"); } // 2. 校验账号状态,禁用账号直接拒绝 if (user.getStatus() == 0) { throw new BusinessException("账号已被禁用"); } // 3. 签发 token,载荷里放 uid 和 role,不放敏感信息 String token = JwtUtil.createToken(user.getId(), user.getRole(), user.getUsername()); // 4. 组装返回体,前端拿 role 决定菜单 LoginVO vo = new LoginVO(); vo.setToken(token); vo.setUsername(user.getUsername()); vo.setRole(user.getRole()); return Result.success(vo); }JwtUtil里最关键的是密钥和过期时间两个参数。密钥写在配置文件里,不要硬编码在代码中;过期时间毕设阶段设 2 小时足够,设太长演示时反而不好验证过期逻辑。
public class JwtUtil { private static final String SECRET = "sms-jwt-secret-key-2024"; // 生产环境放配置中心 private static final long EXPIRE = 2 * 60 * 60 * 1000L; // 2 小时 public static String createToken(Long uid, String role, String username) { return Jwts.builder() .claim("uid", uid) .claim("role", role) .setSubject(username) .setIssuedAt(new Date()) .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(); } }凭证签发之后,后续每个请求靠拦截器校验。拦截器从Authorization头里取 token,解析成功就把用户信息塞进ThreadLocal,业务层随时可取;解析失败直接返回 401,前端拦截器收到 401 后跳登录页。这条链路打通了,后面所有接口都不用再关心鉴权。
提示:拦截器要放行登录接口、静态资源和错误页,否则会出现「登录接口本身也被拦截」的死循环,这是新手最容易翻车的地方之一。
4.2 学生列表分页:条件构造器配合字典翻译
列表页是使用频率最高的功能,也是最能体现后端设计水平的地方。需求通常是:按姓名模糊查、按班级和专业精确查、按状态筛选、分页、按入学时间排序。
用 MyBatis-Plus 的条件构造器写,代码短且可读性好:
@Override public PageResult<StudentVO> pageQuery(StudentQuery query) { // 1. 分页对象,页码和每页条数由前端传,做一下兜底 Page<Student> page = new Page<>( query.getPageNum() == null ? 1 : query.getPageNum(), query.getPageSize() == null ? 10 : query.getPageSize()); // 2. 条件构造器,动态拼接,条件为空时该段不生效 LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<Student>() .like(StringUtils.hasText(query.getName()), Student::getName, query.getName()) .eq(query.getClassId() != null, Student::getClassId, query.getClassId()) .eq(query.getMajorId() != null, Student::getMajorId, query.getMajorId()) .eq(query.getStatus() != null, Student::getStatus, query.getStatus()) .orderByDesc(Student::getEnrollDate); // 3. 分页查询,插件自动拼 limit 和 count Page<Student> result = studentMapper.selectPage(page, wrapper); // 4. 转 VO 并补全字典名称,避免 N+1 查询 List<StudentVO> list = convertAndFill(result.getRecords()); return new PageResult<>(result.getTotal(), list); }这里有个细节值得展开:学生列表要显示「院系名称、专业名称、班级名称」,但学生表里只存了 ID。如果每行都去查一次字典表,一页 10 条就是 30 次查询,这就是典型的 N+1 问题。常见做法是先把本页涉及的 ID 收集起来,批量查字典表转成 Map,再在内存里做替换。
private List<StudentVO> convertAndFill(List<Student> records) { if (CollectionUtils.isEmpty(records)) return Collections.emptyList(); // 一次查出所有涉及的班级ID对应的名称,减少数据库往返 Set<Long> classIds = records.stream().map(Student::getClassId).collect(Collectors.toSet()); Map<Long, String> classMap = classService.nameMapByIds(classIds); return records.stream().map(s -> { StudentVO vo = new StudentVO(); BeanUtils.copyProperties(s, vo); vo.setClassName(classMap.getOrDefault(s.getClassId(), "未知班级")); vo.setStatusText(StatusEnum.textOf(s.getStatus())); // 状态码转中文 return vo; }).collect(Collectors.toList()); }分页插件必须在配置类里注册MybatisPlusInterceptor并加入PaginationInnerInterceptor,少了这一步,selectPage返回的total永远是 0,前端分页器只显示一页,很容易被误判成前端 bug。
4.3 学籍异动:一个事务里完成的四步操作
异动是这套系统里唯一需要严格保证一致性的业务。一次「转专业」操作,要同时做四件事:写一条异动记录、改学生表的专业和班级、更新异动表的审批状态、记录操作日志。任何一步失败都必须整体回滚,否则会出现「学生转走了但异动记录没留」这种脏数据。
先看状态流转规则,这是业务正确性的核心:
| 当前状态 | 允许的异动 | 说明 |
|---|---|---|
| 1 在读 | 休学、转专业、退学 | 正常在读学生可发起 |
| 2 休学 | 复学、退学 | 休学期间不能转专业 |
| 3 复学 | 转专业、退学 | 复学后视同在读 |
| 4 转专业 | 休学、退学 | 转专业完成后的新身份 |
| 5 退学 | 无 | 终态,不可逆 |
| 6 毕业 | 无 | 终态,不可逆 |
把这套规则抽成一个工具类,业务代码里只做一次判断,后面加状态只改这一个地方:
@Transactional(rollbackFor = Exception.class) public void applyChange(StatusChangeDTO dto) { // 1. 加行锁查学生,防止并发提交导致状态错乱 Student student = studentMapper.selectByIdForUpdate(dto.getStudentId()); if (student == null) throw new BusinessException("学生不存在"); // 2. 状态机校验,非法流转直接拦截 if (!StatusRule.canChange(student.getStatus(), dto.getChangeType())) { throw new BusinessException("当前学籍状态不允许该操作"); } // 3. 写异动记录,保留变更前后的值 StatusChange record = new StatusChange(); record.setStudentNo(student.getStudentNo()); record.setChangeType(dto.getChangeType()); record.setBeforeValue(String.valueOf(student.getClassId())); record.setAfterValue(dto.getTargetClassId() == null ? null : String.valueOf(dto.getTargetClassId())); record.setReason(dto.getReason()); record.setAuditStatus(1); // 演示版直接通过,真实场景走审批流 record.setApplyUser(CurrentUser.getUsername()); statusChangeMapper.insert(record); // 4. 更新学生主表,转专业要同时改专业和班级 student.setStatus(dto.getChangeType()); if (dto.getChangeType() == 4 && dto.getTargetClassId() != null) { student.setClassId(dto.getTargetClassId()); student.setMajorId(dto.getTargetMajorId()); } studentMapper.updateById(student); }@Transactional(rollbackFor = Exception.class)里的rollbackFor不能省。Spring 默认只在遇到运行时异常时回滚,如果业务里抛的是受检异常,不加这个参数事务不会生效,这是一个很隐蔽的坑。
selectByIdForUpdate是自定义的加锁查询,SQL 里带FOR UPDATE。毕设阶段并发量几乎为零,但答辩时被问到「两个管理员同时点转专业会怎样」,能答出「用行锁串行化」会是很加分的点。
4.4 前端对接:axios 封装与路由守卫
前端这一层最值得看的是请求封装和路由守卫,其他的都是 Element UI 组件拼装。
// src/utils/request.js import axios from 'axios' import { Message } from 'element-ui' import store from '@/store' import router from '@/router' const request = axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 从环境变量读,切换环境不用改代码 timeout: 10000 }) // 请求拦截:统一带 token request.interceptors.request.use(config => { if (store.getters.token) { config.headers['Authorization'] = 'Bearer ' + store.getters.token } return config }, error => Promise.reject(error)) // 响应拦截:统一处理业务码和登录失效 request.interceptors.response.use(res => { const data = res.data if (data.code === 200) return data.data if (data.code === 401) { store.dispatch('user/resetToken') router.push('/login') return Promise.reject(new Error('登录已过期')) } Message.error(data.msg || '请求失败') return Promise.reject(new Error(data.msg)) }, error => { Message.error(error.message || '网络异常') return Promise.reject(error) }) export default request注意响应拦截里return data.data这一行,它把后端的{code, msg, data}包装拆掉了,所以业务代码里const list = await getStudentPage(params)拿到的直接是数据本体。这个约定一旦定下来就别改,中途改会让所有调用处全部失效。
路由守卫负责两件事:没登录不让进业务页,登录了但角色不对不让进管理页。
router.beforeEach((to, from, next) => { const token = store.getters.token if (!token && to.path !== '/login') { next('/login') // 未登录统一踢回登录页 } else if (token && to.meta.roles) { const role = store.getters.role to.meta.roles.includes(role) ? next() : next('/403') // 角色不匹配走无权限页 } else { next() } })菜单同理,不要在前端写死一整套菜单再靠v-if隐藏,那样用户改个前端代码就能看到全部入口。正确做法是登录接口返回该角色能访问的路由标识,前端按标识过滤菜单,后端接口再做一次权限校验,两层都拦住才算数。
5. 常见问题排查:本地能跑、换台机器就崩的六个坑
这一章按现象、原因、解决三步写,都是这套工程里真实会撞上的问题。遇到报错先对照这里过一遍,比从头读代码快得多。
5.1 环境与配置类问题
现象一:后端启动报Port 8080 was already in use,或者前端能打开但接口全部 404。
原因通常有两个:一是上一轮启动的进程没退干净,端口被占;二是前端baseURL配的端口和后端实际端口不一致。前者在 Windows 上尤其常见,任务管理器里还留着一个 java 进程。
解决方式是先确认端口占用再动手:
# Windows netstat -ano | findstr :8080 taskkill /PID 进程号 /F # macOS / Linux lsof -i:8080 kill -9 进程号如果端口是空的但接口还是 404,就去检查vue.config.js里的proxy配置和request.js里的baseURL,两处必须指向同一个地址。我一般会在启动日志里打一行「服务已启动,端口:8080」,出问题时一眼就能对上。
现象二:页面能打开,控制台报Access-Control-Allow-Origin跨域错误。
原因是前后端分处不同端口,浏览器同源策略拦截。开发阶段有两种解法:后端加 CORS 配置,或者前端配 devServer 代理。推荐后者,因为代理在生产环境可以交给 Nginx 处理,代码里不用留跨域配置。
// vue.config.js module.exports = { devServer: { port: 9528, proxy: { '/api': { target: 'http://localhost:8080', // 后端地址 changeOrigin: true, // 必须开,否则后端拿到的 Host 不对 pathRewrite: { '^/api': '' } // 按后端是否带 /api 前缀决定 } } } }现象三:数据库里存的时间比实际时间少 8 小时,页面显示也跟着错。
原因是 JDBC 连接串没指定时区,驱动按 UTC 解析,写进库就偏移了。解决是在连接串里加serverTimezone=Asia/Shanghai,同时确认 MySQL 服务端的time_zone参数。这个问题在答辩演示「今天刚录入的成绩」时特别容易暴露,提前改掉。
5.2 数据库与数据类问题
现象四:新增学生时报Data too long或页面中文变成问号。
原因基本是字符集不统一:库是utf8mb4,表是latin1,或者连接串里少了characterEncoding=utf8。排查时按库、表、字段、连接串四级依次确认,缺哪补哪。
-- 查库和表的字符集 SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = 'sms'; SHOW CREATE TABLE student;如果表已经建好且数据不多,直接ALTER TABLE student CONVERT TO CHARACTER SET utf8mb4;重刷一遍最快。数据多的时候要谨慎,先备份再动。
现象五:分页查询返回的total一直是 0,前端分页器只有一页。
原因是没有注册分页插件,selectPage只做了limit没做count。解决是在配置类里补上拦截器:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }顺带提醒,DbType要写对,写成其他数据库方言时生成的limit语法可能是错的,报错信息却不直观。
现象六:删除学生时提示外键约束失败,或者删除后成绩列表里出现一堆无名记录。
前者是数据库建了物理外键且没设级联,后者是用了逻辑删除但手写 XML 查询没带deleted = 0。这套工程用的逻辑删除方案,所以外键问题一般不会出现,出问题的都是手写 SQL 忘了过滤。解决是统一在 XML 的条件里补上AND s.deleted = 0,或者干脆在实体上加@TableLogic并在 XML 里改用resultMap承接。
注意:逻辑删除和唯一索引会打架。学生被逻辑删除后,同样的学号再新增会撞唯一索引报错。要么改成物理删除,要么把唯一索引改成
(student_no, deleted)联合唯一。
5.3 前后端联调类问题
现象七:登录成功,但刷新页面就退回登录页。
原因是 token 只存在 Vuex 的 state 里,刷新即丢。Vuex 本身不持久化,需要在store里手动同步到localStorage,初始化时再读回来。
// store/modules/user.js 片段 const state = { token: localStorage.getItem('sms_token') || '', role: localStorage.getItem('sms_role') || '' } const mutations = { SET_TOKEN(state, token) { state.token = token localStorage.setItem('sms_token', token) // 同步落盘 } }更彻底的做法是只把 token 放localStorage,用户信息和权限每次刷新时重新调接口拉取,避免本地缓存的角色和后台实际权限不一致。
现象八:Excel 导出打开是乱码,或者导入时提示文件解析失败。
导出乱码一般是响应头没设对。用 EasyExcel 写的时候必须指定Content-Type和Content-Disposition,并且前端请求要带responseType: 'blob',否则返回的二进制会被 axios 当字符串处理。
@GetMapping("/export") public void export(HttpServletResponse response) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); // 文件名要 URL 编码,否则中文名在部分浏览器下会截断 String fileName = URLEncoder.encode("学籍名单", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); EasyExcel.write(response.getOutputStream(), StudentExportVO.class) .sheet("学籍") .doWrite(studentService.listForExport()); }导入失败则多是模板列顺序和实体字段顺序不一致,或者表头行数没对齐。建议导出和导入共用同一个 VO 类,用@ExcelProperty注解固定列名,让两边天然一致。
6. 二次开发:把毕设级代码改成能见人的样子
这套工程跑通之后,如果只是交作业,到上一章就够了;但如果你想拿它当作品集,或者想在此基础上加一两个能讲出东西的功能,有三处改造性价比最高。
第一处是权限从「角色硬编码」改成「角色—菜单」动态配置。现在的代码里角色判断基本是if (role.equals("admin"))这种写法,加一个角色就要改一遍代码。改造方式是建sys_role、sys_menu、sys_role_menu三张表,登录时按角色查菜单 ID 列表返回给前端,前端用返回的路由标识过滤菜单,后端在拦截器里校验接口级权限。这个改动大概两三天,但答辩时能讲清楚「RBAC 模型」和「前后端双重校验」,含金量比多加十个增删改查页面都高。
第二处是给列表查询加缓存。学籍数据读多写少,院系、专业、班级这类字典表的数据几乎不变,每次列表查询都联表其实浪费。可以引入 Spring Cache 加 Redis,给字典查询加@Cacheable,更新时@CacheEvict清掉。改动量很小,但能讲清楚缓存穿透和缓存一致性这两个面试常问的点。
| 改造项 | 改动范围 | 难度 | 能讲清楚的考点 |
|---|---|---|---|
| RBAC 动态权限 | 新增 3 表 + 拦截器改造 | 中 | 权限模型、前后端校验 |
| 字典数据缓存 | 加缓存注解 + Redis 配置 | 低 | 缓存一致性、穿透 |
| 异动审批流 | 异动表加状态 + 审批接口 | 中 | 状态机、事务边界 |
| 操作日志切面 | 新增 AOP 切面 + 日志表 | 低 | AOP、异步落库 |
第三处是把异动从「提交即生效」改成「提交—审批—生效」三段式。现在代码里auditStatus直接写死 1,把它改成 0 待审,再加一个审批接口,审批通过时才真正改学生主表状态。这一改,整个业务从单表操作变成了有状态流转的流程,事务边界和并发问题都会浮现出来,正好是练手的地方。
不管改哪一处,验证方法都一样:先改数据库脚本,再改实体和 Mapper,然后跑一遍完整的业务闭环——新增学生、查列表、转专业、查异动记录、导出 Excel,五步全过才算改完。我曾经图快,改完接口只测了 List 没测 Export,结果导出时字段全空,因为 VO 上漏了注解,白白多花一个晚上。
从那以后我每次拿到一份毕设源码,都强制先把数据库脚本单独跑一遍、把登录链路单独测一遍,再动业务代码。希望帮到你。
本文还有配套的精品资源,点击获取