news 2026/10/19 7:15:29

基于Spring Boot+Vue的毕业论文选题管理系统设计与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Spring Boot+Vue的毕业论文选题管理系统设计与实现

每年一到毕业季,导师群和教务群就会被选题表、改题申请、重复课题的交锋反复轰炸。学生不知道哪些题目还能选,导师不清楚哪个学生已经占坑,教务管理员更是要对着Excel表格手动核对上限人数——这种情况我曾经完整经历过一遍,所以当我自己动手做毕业论文选题管理系统时,核心诉求就一条:把“选题”这个原本靠人肉协调的流程,变成一套学生、导师、管理员三方都能实时看到最新状态的闭环系统。系统用Spring Boot + Vue前后端分离架构,后端负责业务逻辑与数据持久化,前端负责交互与状态展示,最后部署起来也很轻量,属于典型的Java全栈毕设项目,同时也具备直接改造成其他场景(实习分配、课程设计选题)的能力。

这套系统到底解决什么问题,又适合谁来参考?简单说,如果你是计算机相关专业的学生,正在纠结毕设题目怎么选,或者想找一个兼顾技术栈完整度、业务逻辑清晰、工作量适中的项目来练手,那这个选题管理系统非常对口。它覆盖了用户认证授权、核心业务流转、文件上传、分页查询、路由权限控制这些真实项目里的高频需求,做完以后你对Spring Boot和Vue的理解会从“会写接口”提升到“能独立设计一套业务系统”的层次。下面我把整个设计和实现过程拆开细讲,从技术选型讲到数据库建表,从前端路由守卫讲到后端事务处理,所有踩过的坑都会一并交代清楚。

1. 项目定位与技术选型:为什么是Spring Boot + Vue

1.1 前后端分离是这类系统的最优解

毕业论文选题管理系统的用户角色天然有三类:管理员、教师、学生,三者的操作场景和终端环境差异很大。管理员通常在办公室的PC上处理审核,教师可能在不同操作系统之间切换,学生更多在宿舍或移动端访问。如果还用传统的单体JSP方案,前端页面和后端逻辑耦合在一起,每次改个按钮样式都要重新编译整个Web应用,多人协作时冲突不断,部署也只能打成一个臃肿的WAR包。

选用Spring Boot + Vue做前后端分离之后,前后端只需要通过JSON格式的接口通信。后端专注业务逻辑、数据校验、权限控制和安全防护,前端专注页面渲染、交互反馈和状态管理。两个工程可以独立开发、独立测试、独立部署,前端打包成静态资源后既可以交给Nginx托管,也可以直接放进Spring Boot的static目录下实现单应用部署,非常灵活。

这里我还想多说一句为什么后端选Spring Boot而不是SSH或者SSM。Spring Boot最核心的价值在于自动化配置:内嵌Tomcat,省去外部容器安装;大量Starter封装,引入一个依赖就能获得对应能力;配合YAML配置文件,开发时几乎不需要XML。对于毕设这种时间紧凑的项目来说,把精力花在写业务上远比折腾环境配置更有价值。而Vue这边,组件化开发、响应式数据绑定、Vue Router和Vuex/Pinia配套完善,写列表页、表单页的效率比原生JavaScript或者jQuery高出太多,生态也是国内最成熟的。

1.2 技术栈全貌:从前端到后端的完整清单

这套系统我最终确定的完整技术栈如下:

层次技术选型版本建议
后端框架Spring Boot2.7.x 或 3.x
持久层框架MyBatis-Plus3.5.x(与Spring Boot 3兼容需注意)
权限认证JWT + Spring Security 或拦截器根据项目复杂度二选一
数据库MySQL5.7 或 8.0
前端框架VueVue 2.7 或 Vue 3.2
前端构建Vite 或 Vue CLI视Vue版本而定
UI组件库Element UI / Element PlusVue2配Element UI,Vue3配Element Plus
状态管理Vuex 或 PiniaVue2用Vuex,Vue3用Pinia
文件存储本地磁盘 / MinIO局部功能可能用到

这里补充一下JWT和Spring Security的选择问题。我做过几个类似的毕设,结论是:如果选题管理系统只做角色权限拦截和登录鉴权,没必要引入完整的Spring Security,因为它的过滤器链很重,学习成本高,配置一不小心就出问题。我自己采用的是“JWT + HandlerInterceptor”方案,也就是写一个拦截器,在请求进入Controller之前校验Token,识别用户身份和角色,然后放行或拦截。这个方案轻量、直观、容易在答辩时讲清楚。如果你的系统后续要对接OAuth2、第三方登录或者精细到方法级的权限注解,再考虑引入Spring Security。

前端这边,路由守卫加上简单的角色判断就能实现页面级权限控制,再加上按钮级权限的判断条件,基本够用。状态管理用Vuex或Pinia存用户信息、Token和基础数据,避免每个页面都重复请求接口。

1.3 快速搭建工程骨架

后端工程我建议直接用Spring Initializr生成基础结构,选择Spring Web、MySQL Driver、Lombok这几个起步依赖,后续再手动加入MyBatis-Plus的依赖。前端工程用Vite创建Vue项目,安装Vue Router、状态管理库、Axios和Element UI组件库。

一个值得注意的坑是Spring Boot版本和MyBatis-Plus的兼容性。Spring Boot 3.x基于Jakarta EE,很多旧版MyBatis-Plus会启动报错,务必使用MyBatis-Plus 3.5.3以上的版本,并确认驱动坐标改为com.mysql:mysql-connector-j。如果你不想折腾这些兼容问题,直接用Spring Boot 2.7.x搭配MyBatis-Plus 3.5.1,这个组合非常稳,我实测过程中一次过。

前端创建好之后,我会先把 axios 封装成独立的request工具模块,设置baseURL指向后端服务,添加请求拦截器(自动附带Token)和响应拦截器(统一处理业务码和401未登录状态)。这是整个前端工程的地基,务必在最开始就做好,不然后期每个接口都要重复写Token逻辑,改起来很痛苦。

2. 核心业务模块与数据库设计

2.1 业务流程拆解:选题到底走哪几步

我在做设计之前,先把自己代入到真实的选题场景里梳理了一遍完整流程。毕业论文选题通常不是“学生挑一个题目”这么简单,背后涉及命题、审题、选课、改选、分配指导教师等多个环节。我最后归纳出一套相对完整的业务闭环:

  • 管理员维护基础数据,包括学院、专业、班级、教师账号、学生账号。
  • 教师提交论文题目,填写选题类型、研究方向、简介、可选人数上限。
  • 管理员审核教师提交的题目,审核通过后进入选题池。
  • 学生在选题池中浏览题目,根据自己的兴趣和专业方向选择一个题目。
  • 如果学生选了题目,需要等待教师确认;教师可以选择接受或拒绝。
  • 学生也可以撤销选题、重新选择,但需要遵循系统的次数限制和状态约束。
  • 管理员可以查看整体选题进度,随时导出统计报表。

当然,这只是我设计的一种流程模型。实际校历可能略有不同,有的学校允许学生自定义题目再报给教师审核,这属于反向流程;有的学校把教师确认环节放到最后,学生先选先得。设计数据库时一定要把状态机抽出来,让业务流程的可配置性更强。

2.2 数据表设计:五张核心表的结构与关系

根据上面的业务拆解,我设计了如下核心表。实际建表时还会加上一些辅助表,比如班级表、专业表、操作日志表,这里列出最关键的几张:

表名用途核心字段
sys_user用户表(管理员、教师、学生统一存储)user_id, username, password, real_name, role, teacher_id, student_no, college, major
topic选题表topic_id, title, category, description, requirements, teacher_id, max_student, selected_count, status, create_time
topic_selection选题关系表id, topic_id, student_id, teacher_id, status, apply_time, confirm_time, remark
announcement公告表(可选功能)id, title, content, publisher, publish_time
operation_log操作日志(可选功能)id, user_id, action, detail, create_time

sys_user表把三类用户统一存到一张表里,通过role字段区分,比给每种角色单独建表更简单。这里有个细节技巧:学生需要关联学号、专业、班级等信息,教师需要关联工号、职称、所属院系,所以我在用户表里同时预留了teacher_id和student_no字段,用角色字段来决定哪个字段生效。这种单表设计虽然有一些冗余空字段,但对权限控制和数据查询来说是最方便的。

topic表里的status字段是这个系统的核心状态机,我定义为以下几种状态码:0表示待审核,1表示已通过并在选题池中,2表示已拒绝,3表示已招满,4表示已下架。前端根据这个状态的变更实时刷新页面显示,后端则用状态机保证状态转移的合法性。

topic_selection表是整个系统的“桥梁”,它记录了每一次选题申请。这里我做了个设计取舍,同时存了topic_id和teacher_id,这样查询学生当前选中的题目时可以少关联一次用户表,性能更好。status字段表示这条申请记录的状态,比如0待确认、1已通过、2已拒绝、3已退选。为了控制学生同时选题数量,我在代码里加了逻辑校验,每个学生只能有一条状态为0或1的申请记录。

2.3 数据库设计的几个关键原则

第一,用户密码千万不要明文存储。我使用的是BCrypt加密,登录时用BCrypt校验密码是否匹配。这个操作成本很低,答辩时也是一个加分亮点:密码加密存储、防止脱库风险。前端传密码时还可以加一层MD5或SHA256散列,后端只保存散列结果,安全设计上就更完整了。

第二,所有外键逻辑关联即可,不建议数据库层面强制外键约束。这个跟很多教材上推荐的做法相反,但在实际项目里,外键约束会对插入、删除造成额外开销,并且一旦业务逻辑出现顺序问题会给排查增添困难。我在设计时保证了应用层事务的一致性,而不是依赖数据库的级联约束。

第三,时间字段统一用create_time、update_time这种命名,MyBatis-Plus配置自动填充,减少手动维护时间戳的重复工作。这个后面会细讲如何实现。

第四,考虑数据量增长。选题相关操作最频繁的是topic_selection表的写入和查询,因此我给这个表的核心查询字段建立了联合索引,比如student_id + status,topic_id + status,查询速度会快很多。毕设阶段可能看不出区别,但答辩时讲到这个细节,技术上会更加分。

2.4 顺带说明:角色权限的数据库设计

权限模型是选题系统绕不开的点。这个项目角色不多,我使用最经典的RBAC(基于角色的访问控制)简化模型,不必做得太复杂,用户表加角色字段、前端路由守卫判断角色编码即可。

  • 管理员:拥有全部菜单权限,可以管理所有模块。
  • 教师:可以题目管理、学生确认、个人信息维护。
  • 学生:只能浏览选题池、发起选题、查看自己的选题记录。

如果你的前端想实现动态路由,还有一个做法是把登录用户可访问的菜单通过接口下发,然后使用Router.addRoute动态注册。这个方案比前端写死路由更灵活,也更能体现对Vue Router的理解。我在系统管理模块里就是通过后端返回菜单树、前端动态注册的方式实现的,后面第三部分会重点讲解。

3. 后端核心实现:从JWT登录到选题状态流转

3.1 用拦截器+JWT实现无状态登录鉴权

先说登录流程。用户输入账号密码,后端接收后用用户名查表,用BCrypt校验密码,校验通过则生成Token返回给前端。Token的载荷中包含了用户ID、角色、过期时间,我用jjwt库来生成和解析Token。

public class JwtUtil { private static final String SECRET = "your-secret-key-change-in-production"; private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000; public static String generateToken(Long userId, String role, String username) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }

Token生成之后返回给前端保存到localStorage,在axios请求拦截器中统一加到Authorization头。后端写一个LoginInterceptor,继承HandlerInterceptor,在preHandle方法里解析Token,校验合法性,然后从Token中提取用户ID和角色存到ThreadLocal或者请求对象中,方便后续业务代码获取当前登录人信息。

核心代码如下:

public class LoginInterceptor implements HandlerInterceptor { private static final ThreadLocal<LoginUser> USER_HOLDER = new ThreadLocal<>(); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token) || !token.startsWith("Bearer ")) { writeUnauthorized(response); return false; } try { Claims claims = JwtUtil.parseToken(token.substring(7)); LoginUser loginUser = new LoginUser(); loginUser.setUserId(claims.get("userId", Long.class)); loginUser.setRole(claims.get("role", String.class)); loginUser.setUsername(claims.getSubject()); USER_HOLDER.set(loginUser); return true; } catch (JwtException | IllegalArgumentException e) { writeUnauthorized(response); return false; } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { USER_HOLDER.remove(); } public static LoginUser getCurrentUser() { return USER_HOLDER.get(); } }

这里有个被很多人踩过的坑:一定要在afterCompletion里调用ThreadLocal.remove(),否则由于Tomcat的线程池复用,下一次请求会读到上一个用户的信息,造成用户串号。这个Bug我在开发时遇到过,排查了很久才发现是ThreadLocal没有清理导致的。

3.2 登录接口与权限校验的完整链路

你可能有疑问,既然有Spring Security,为什么不用@PreAuthorize注解?我的回答是:对毕设这个体量的系统而言,拦截器加角色判断足够清晰。如果角色细分或者权限控制方法论变了,再升级到Spring Security也容易。

登录接口我会用三个参数:用户名、密码、角色。为什么登录还要带角色?因为这个系统用户统一存储在sys_user表里,不同角色登录后跳转的首页完全不同。带上角色可以在后端就过滤掉非法角色的登录尝试,前端也能在登录页直接区分学生登录/教师登录/管理员登录。

@PostMapping("/login") public R login(@RequestBody LoginRequest request) { LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SysUser::getUsername, request.getUsername()) .eq(SysUser::getRole, request.getRole()); SysUser user = userMapper.selectOne(wrapper); if (user == null || !BCrypt.checkpw(request.getPassword(), user.getPassword())) { return R.fail("账号或密码错误"); } if (user.getStatus() != null && user.getStatus() == 0) { return R.fail("账号已被禁用"); } String token = JwtUtil.generateToken(user.getUserId(), user.getRole(), user.getUsername()); return R.ok().data("token", token) .data("userInfo", buildUserInfo(user)); }

之后所有业务接口都经过拦截器。教师提交题目接口、学生选题接口各自有一个额外的角色校验逻辑:要么在Controller上用自定义注解标记,要么在Service里判断当前用户角色再抛业务异常。我的习惯是在拦截器里只统一校验Token是否有效,具体到每个业务方法再校验角色,这样逻辑不会耦合在一起,阅读代码时也清晰。

3.3 选题状态流转的实现细节

选题是系统的核心业务,我单独用一个Service类来管理。这个类里最复杂的方法是“学生申请选题”,涉及如下步骤:

@Transactional(rollbackFor = Exception.class) public R applyTopic(Long topicId) { LoginUser currentUser = LoginInterceptor.getCurrentUser(); if (!"student".equals(currentUser.getRole())) { return R.fail("无权限操作"); } Topic topic = topicMapper.selectById(topicId); if (topic == null || topic.getStatus() != 1) { return R.fail("选题不存在或不在选题池中"); } if (topic.getSelectedCount() >= topic.getMaxStudent()) { return R.fail("该课题已选满"); } Long currentSelection = selectionMapper.selectCount( new LambdaQueryWrapper<TopicSelection>() .eq(TopicSelection::getStudentId, currentUser.getUserId()) .in(TopicSelection::getStatus, 0, 1)); if (currentSelection > 0) { return R.fail("你已有进行中的选题申请"); } // 新增申请记录,并将topic的已选人数加1(这里可以改用乐观锁) TopicSelection selection = new TopicSelection(); selection.setTopicId(topicId); selection.setStudentId(currentUser.getUserId()); selection.setTeacherId(topic.getTeacherId()); selection.setStatus(0); selection.setApplyTime(new Date()); selectionMapper.insert(selection); topic.setSelectedCount(topic.getSelectedCount() + 1); topicMapper.updateById(topic); return R.ok(); }

这段逻辑里有一个并发问题:如果大量学生同时申请同一个题目,MySQL的行锁或乐观锁怎么处理?我在设计时用的是“先查后改”的普通写法,在毕设场景下并发量不高,够用。但如果想做得更严谨,可以在topic表增加一个version字段,更新时使用“UPDATE topic SET selected_count = selected_count + 1 WHERE topic_id = ? AND status = 1 AND selected_count < max_student”这样的CAS写法,影响行数为0则说明已满或状态异常,再回滚业务事务。这类细节教学视频里很少讲,但答辩时能讲明白,档次完全不一样。

状态流转要严格控制,比如已确认的选题不能直接退选,已拒绝的选题不能重新确认。我把状态流转的校验都收敛在Service层,禁止业务代码绕过Service直接操作Mapper。这种设计让状态机的边界非常清晰:所有状态变化必须经过同一个方法。

3.4 文件上传:公告附件与论文文档的存储方案

选题系统里很可能需要上传文件,比如教师上传课题附件资料,学生上传开题报告,管理员上传通知附件。我开发时常遇到两个选择:把文件存到本地磁盘,或者对接MinIO对象存储。

从热词里也看到了MinIO,这里明确说一下我的方案。如果只是毕设展示,本地磁盘存储最简单,即把文件路径存到数据库,文件本身放在服务器某个目录,前端访问时通过一个文件访问的Controller映射读取。

@PostMapping("/upload") public R upload(MultipartFile file) { if (file.isEmpty()) { return R.fail("文件不能为空"); } String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = UUID.randomUUID() + suffix; String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); File folder = new File(UPLOAD_DIR + datePath); if (!folder.exists()) { folder.mkdirs(); } File dest = new File(folder, fileName); file.transferTo(dest); String url = "/file/" + datePath + "/" + fileName; return R.ok().data("url", url); }

这里需要把/file/**路径配置为静态资源映射或者放行拦截器,然后文件就能通过URL直接访问了。如果项目后续需要分布式部署或者换服务器,本地磁盘方案就得改成MinIO,因为MinIO是独立服务,应用无状态化后可用性更高。热词里也看到有人把MinIO集成到Spring Boot,原理上就是引入minio依赖、配置endpoint和密钥、通过MinioClient上传下载,核心步骤并不复杂。但在选题管理系统初期我建议先把本地存储跑通,功能完整后再扩展对象存储也不迟。

3.5 分页查询与条件搜索的综合实现

选题列表是主页面,学生要能做到按题目名称、题目类型、教师姓名进行搜索,并且分页加载数据。后端接口设计如下:

@GetMapping("/topic/page") public R page(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String title, @RequestParam(required = false) String category) { Page<TopicVo> topicPage = new Page<>(page, size); LambdaQueryWrapper<Topic> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.isNotBlank(title)) { wrapper.like(Topic::getTitle, title); } if (StringUtils.isNotBlank(category)) { wrapper.eq(Topic::getCategory, category); } wrapper.eq(Topic::getStatus, 1); wrapper.orderByDesc(Topic::getCreateTime); IPage<Topic> result = topicMapper.selectPage(topicPage, wrapper); // 这里还需要转换为VO,并填充教师姓名等展示字段 }

查询接口的关键点在于不要直接返回实体对象,而是返回VO对象。把实体类和展示类分离,可以防止把无用字段返回给前端,比如用户表密码字段,还可以在前端展示时拥有更灵活的结构。我通常会写一个MapStruct转换类或者手动写一个convert方法,在返回给前端前把用户表的teacherName等字段填充好。

4. 前端工程落地:路由权限、状态管理与核心页面

4.1 Vue项目结构与API层封装

前端工程我用Vite创建Vue 3项目,目录划分如下:

src/ ├── api/ # 接口请求定义 │ ├── login.js │ ├── topic.js │ └── user.js ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由配置与守卫 ├── store/ # Pinia状态管理 ├── views/ # 页面 │ ├── admin/ │ ├── teacher/ │ └── student/ └── utils/ # 工具函数 └── request.js

API层我会严格按模块拆分,每个方法调用后端一个接口,不要直接在页面组件里写axios请求,这样便于统一管理URL和参数。比如选题相关的API:

import request from '@/utils/request' export function getTopicPage(params) { return request({ url: '/api/topic/page', method: 'get', params }) } export function applyTopic(topicId) { return request({ url: '/api/topic/apply', method: 'post', params: { topicId } }) }

axios封装里最重要的一环是响应拦截器。我统一后端返回格式为“code、message、data”,响应拦截器里当code不为200时弹出错误提示,当HTTP状态码为401时清空本地用户信息并跳转到登录页。这个逻辑一旦做好,后面写页面时就不需要重复处理错误分支,代码会清爽很多。

4.2 动态路由与按钮级权限控制

这个系统的角色不同,能访问的菜单也不同。我实现的方案是:登录成功后向后端请求当前用户的路由列表或菜单树,然后在前端通过router.addRoute动态添加路由。

// 路由守卫核心逻辑 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') // 没有登录态则跳转登录页 if (!token && to.path !== '/login') { next('/login') return } if (token && to.path === '/login') { next('/') return } if (token) { const userStore = useUserStore() if (!userStore.userInfo) { userStore.fetchUserInfo().then(() => { // 根据角色注册动态路由 const dynamicRoutes = generateRoutes(userStore.role) dynamicRoutes.forEach(route => router.addRoute(route)) next({ ...to, replace: true }) }) return } } next() })

这里有个极其容易踩的坑:addRoute是异步注册路由的,直接next()后页面可能还是找不到组件并报“No match”。我用的办法是注册完动态路由后重新导航一次,也就是next({...to, replace: true}),让路由表更新后再进入目标页面。这个细节我在开发时卡了很久,写出来给后来人避坑。

如果把前端做成了动态路由,后端就需要提供获取菜单的接口。这里我就不展开菜单表设计了,因为对于选题系统,我也可以选择更简单的前端静态路由,菜单根据角色用v-if控制显示,几乎所有逻辑都写在router配置里。两种方式各有利弊:静态路由简单但不够灵活,动态路由更适合真正的前后端分离场景。如果你的项目时间比较充裕,还是建议把动态路由做出来。

4.3 核心页面一:学生选题中心

学生端最重要的一页是选题池,我使用了Element Plus的el-table展示列表,利用el-tag展示不同状态,配合el-pagination做分页,顶部放搜索栏。

这个页面有几个细节值得注意:

  • 剩余名额不足时,禁用选题按钮。
  • 已经选择过其他题目的学生,后端接口会返回错误,前端也能根据“已有进行中的选题”局部状态来隐藏按钮。
  • 列表数据量小时直接一次加载全部数据,但设计上仍然保留了分页参数,便于数据量大时切换。

表格操作列里我放了“查看详情”弹窗、“申请选题”按钮,详情弹窗里展示课题简介、导师信息和可选人数。当用户点击申请后,弹出二次确认,调用applyTopic接口,成功后刷新列表并显示最新状态。这种交互在业务系统里非常常见,做熟之后写其他管理系统也能触类旁通。

4.4 核心页面二:教师与管理员的管理后台

教师端的关键功能是提交题目、查看谁选了自己的题目、确认或拒绝学生申请。我用了两个Tab页:一个Tab展示自己发布的题目;另一个Tab展示待确认的学生申请列表。待确认列表的接口会返回申请记录、学生信息、选题状态,教师点击“通过”按钮后调用ConfirmApi。这里我用了一个组件内弹窗表单来让学生查看课题详情,也能在教师端复用同一套详情组件,减少代码冗余。

管理员端的功能相对多一些:用户管理、题目审核、数据统计、公告管理。用户管理页是典型的CRUD页面,使用el-dialog嵌套el-form实现新增和编辑。我的经验是CRUD页面尽量做成一个通用的表格页面模板,包括搜索栏、工具栏、表格区、分页区四个区块,这样后续扩展其他管理页面时直接复制改配置即可。

管理员对教师申请题目的审核操作会直接改变topic表中的status字段,前端审核后需要刷新列表。在大列表查询中,我特别注意了重置查询条件时页码需要回到第一页,不然在第三页执行条件查询会出现“当前页没有数据”的错觉。这个用户交互的细节不处理的话,测试阶段很容易被挑出毛病。

5. 常见问题与排查技巧实录

5.1 跨域问题:明明接口通了,前端为何报错

前端工程默认运行在5173或8080端口,后端运行在8080或9090端口,两者之间产生了跨域请求。我在后端写了一个全局CORS配置类,使用WebMvcConfigurer支持跨域。

@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); } }

这个配置类在实际开发中基本能解决所有跨域问题。优先使用allowedOriginPatterns("")而不是allowedOrigins(""),因为后者在allowCredentials为true时会被浏览器拒绝。另外,如果你的项目同时配置了拦截器和CORS,注意Interceptor的preHandle可能先于CORS处理,导致预检请求带了Origin头但被鉴权拦截。我的处理方式是:在拦截器中放行OPTIONS请求,或者把CORS配置单独放到Spring Security的过滤器链最前面。这里踩过坑,所以特别提醒。

5.2 日期格式化与时区问题:插入时间和查询时间对不上

这个看起来是小问题,但几乎人人都会遇到。MySQL连接URL中一定要配置serverTimezone=Asia/Shanghai,否则按照UTC存储时间会比北京时间少8小时。如果要通过Jackson返回给前端并格式化,我直接在application.yml里配置:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

如果使用LocalDateTime类型,另外需要在Java类字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),前后端配合才能稳定显示。这个知识不算高深,但选题系统里“申请时间”“确认时间”如果显示错了,管理员统计就没法做了。

5.3 逻辑删除与唯一约束冲突

我在用户管理功能中使用了MyBatis-Plus的逻辑删除,delete字段只做标记,查询都会自动过滤。但逻辑删除会遇到一个经典问题:如果给用户表username字段建了唯一索引,逻辑删除后再次添加相同用户名的账号时会提示Duplicate entry。

解决方式有三种:唯一索引中包含delete标记字段(对逻辑删除不友好);删除时对username做特殊处理(比如拼接后缀);或者干脆不建唯一索引,改由应用层判断。我当时选择的是“应用层查询时先检查是否存在同名用户”,然后根据状态做提示。这个场景很典型,遇到时不要慌。

5.4 前端打包后部署到Spring Boot的路径问题

前后端分离后,开发时可以开两个服务联调,但部署到服务器时,我想保持“一个Spring Boot应用、一个端口”的简单方案。做法是:前端执行npm run build,把dist目录里的static和index.html复制到后端src/main/resources/static下面,或者通过Maven插件在构建时自动拷贝。Spring Boot会自动托管static目录下的静态资源,这样最省事。

这里有几个注意点:如果路由模式是history,刷新二级页面会报404,需要处理后端转发;如果配置了拦截器,需要把静态资源路径放行;如果接口路径和静态资源路径撞了,也会出问题。我当时的做法是后端所有接口统一加/api前缀,Controller的@RequestMapping写“/api/...”,这样静态资源与接口就完美隔离了。

5.5 事务失效的隐蔽坑

选题申请业务涉及“插入申请记录”和“更新题目人数”两个操作,我在方法上加了@Transactional并设了rollbackFor = Exception.class。但这里有一个致命陷阱:如果方法是被同类中的另一个方法调用的,自调用会导致事务失效。

Spring事务默认基于AOP代理实现,只有通过代理对象调用方法才会被事务增强。如果Service类中a方法调用b方法,b方法即使标注了@Transactional也不会生效。解决办法有两种:把b方法抽取到另一个Service类中注入调用;或者在当前类中注入自身代理对象。我一开始把applyTopic和releaseTopic都放在同一个TopicService中,结果测试时发现异常抛出后数据没有回滚,排查半天才发现是自调用问题。这个经验很宝贵,写在这里提醒大家。

5.6 MyBatis-Plus分页插件的配置

如果有同学也选择MyBatis-Plus,分页功能需要手动配置分页插件。Spring Boot 2.7版本下的配置代码:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

没有配置这个拦截器时,selectPage方法不会自动拼接LIMIT语句,你传的page和size参数完全无效,查出来是整个表的数据。这个问题在刚接触MyBatis-Plus时特别容易忽略,但配置完成之后分页就非常好用,xml里几乎不需要手写分页逻辑。

6. 项目部署与扩展方向

6.1 本地联调与服务器部署步骤

在本地开发阶段,我习惯用Vite的代理配置来解决开发环境跨域:

// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:9090', changeOrigin: true } } }

Vite会把/api开头的请求转发到后端地址,浏览器端看不到跨域,后端也不需要配置CORS。但到了生产环境,这种代理不再生效,我更推荐用Nginx统一转发,某个location配置如下:

server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:9090; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

这个配置里try_files机制很重要,它能保证history模式下刷新页面不会403或404。如果你直接把dist部署到服务器,后端地址、数据库地址这些环境配置建议放到不同的配置文件里,通过Spring Boot的profile机制灵活切换,比如application-dev.yml开发环境、application-prod.yml生产环境。数据源配置、文件存储路径、日志级别分别处理,上线后不用改代码就能切换环境。

6.2 从毕设到生产:值得增加的功能模块

选题系统做成这样已经是一个完整可运行的项目了,但如果想进一步提升价值,我建议从三个方向扩展。

第一个方向是消息通知模块,每当学生申请选题、教师确认结果、管理员审核题目,系统发送站内信或邮件通知相关用户。可以集成Spring Boot的MailSender接口,也可以简单做一个消息表配合前端轮询。这个功能让系统的用户体验上升一个台阶。

第二个方向是数据可视化,管理员端增加统计图表,展示各专业选题进度、各教师题目热度、各题型的分布情况。前端用ECharts渲染,后端提供聚合统计接口。这个扩展在毕设答辩时视觉冲击力很强,也是简历上很好讲的项目亮点。

第三个方向是导入导出功能。Excel导入学生账号和教师账号、导出选题记录,我在开发时用了Apache POI或EasyExcel。这里有实际经验:EasyExcel比POI更容易上手,而且内存占用低,特别适合导入大量数据。这个功能对管理员来说几乎是刚需。

6.3 压测与性能优化心得

如果你要应对比较大的并发场景,比如全校几千人同时选课,那么有必要做一些性能优化。这里分享几个零成本但有效的手段:

  • 数据库连接池用HikariCP,它在Spring Boot 2+中是默认配置,无需额外引入,但需要调整maximumPoolSize,避免连接数不足。
  • 热点数据缓存到Redis。选题池列表变化频率低、查询频率高,是完全适合缓存的场景。用户登录状态也可以改成Redis存储Token以支持主动退出和强制下线。
  • 请求参数统一校验框架,使用Spring Boot的@Validated注解和hibernate-validator,减少Service层手工if判断。
  • 接口返回中移除不必要的大字段。比如选题列表接口如果每次都返回题目简介全文,定期刷新页面会带来额外网络开销,可以改成详情接口再返回简介。

不过要注意,毕设场景一般不需要考虑太深的优化,这些属于加分的拓展项,量力而行。我的建议是先把功能完整性和代码规范性做好,因为答辩时评委老师更关心的是你理解了哪些技术点、系统设计是否合理、出问题能否快速定位,而不是压测报告里的数字有多好看。

我在做这个项目时最深的一个感受是:前后端分离不是说把页面和代码分开就完事了,真正重要的是把接口契约定义好。前后端各做一个模块,然后联调时因为某个字段命名不一致反复修改,这种情况太常见了。我的建议是先定义好统一的R响应体、统一的分页Result对象,再用接口文档工具把每个接口的入参出参固定下来,前端的Mock和开发才能顺畅。在项目里,我是用Apifox来管理和调试接口的,开发过程中感受到了明显的效率提升。

这套选题管理系统,最终不仅帮我解决了毕业设计的选题问题,也让我对Spring Boot自动配置、Vue组件通信、JWT无状态认证、事务传播机制这些核心概念有了更深入的理解。如果你正在为毕设选题发愁,不如参考这个项目独立做一遍,过程中你会遇到各种稀奇古怪的Bug,但每解决一个,能力就实打实长一分。最后留个小技巧:项目不要一开始就追求大而全,先把学生选题、教师审核这条主链路跑通,加入文件上传和统计功能作为加分项,再打磨异常处理和交互细节,按这个优先级推进,你就能在有限的时间里有完整且稳的项目交付。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 12:45:26

tailwind-patterns - SKILL

name: tailwind-patterns description: “Tailwind CSS v4 principles. CSS-first configuration, container queries, modern patterns, design token architecture.” risk: none source: community date_added: “2026-02-27” Tailwind CSS 模式&#xff08;v4 - 2025&…

作者头像 李华
网站建设 2026/10/17 11:29:34

teach - GLOSSARY-FORMAT

GLOSSARY.md 格式 GLOSSARY.md 是此教学工作区的规范语言。所有讲解、练习和学习记录都应遵循其术语。构建它本身就是学习的一部分&#xff1a;把一个概念压缩成精确定义&#xff0c;正是用户理解它的证据。 结构 # {主题} 术语表{一两句话描述此术语表涵盖的主题。}## 术语**肥…

作者头像 李华
网站建设 2026/10/16 8:17:22

swiftui-expert-skill - localization

SwiftUI 本地化参考 面向用户的文本指导&#xff1a;Text、Button、Label、导航/工具栏标题、alert 以及携带可本地化字符串的类型。关于单个 Text 上更窄的"verbatim 与本地化"决策&#xff0c;请参阅 references/text-patterns.md。 目录 SwiftUI 自动本地化字符…

作者头像 李华
网站建设 2026/10/15 20:42:17

自动化与脚本实战:语言选型到测试运维全指南

自动化与脚本&#xff0c;这两个词放在一起&#xff0c;多少有点江湖气。外行听起来像是黑客电影里的暗号&#xff0c;内行知道这就是把重复劳动交给机器的基本盘。我干了这么多年自动化相关的工作&#xff0c;从最早的按键精灵到现在的 pytest、Ansible、Playwright&#xff0…

作者头像 李华