
每年一到毕业季导师群和教务群就会被选题表、改题申请、重复课题的交锋反复轰炸。学生不知道哪些题目还能选导师不清楚哪个学生已经占坑教务管理员更是要对着Excel表格手动核对上限人数——这种情况我曾经完整经历过一遍所以当我自己动手做毕业论文选题管理系统时核心诉求就一条把“选题”这个原本靠人肉协调的流程变成一套学生、导师、管理员三方都能实时看到最新状态的闭环系统。系统用Spring Boot Vue前后端分离架构后端负责业务逻辑与数据持久化前端负责交互与状态展示最后部署起来也很轻量属于典型的Java全栈毕设项目同时也具备直接改造成其他场景实习分配、课程设计选题的能力。这套系统到底解决什么问题又适合谁来参考简单说如果你是计算机相关专业的学生正在纠结毕设题目怎么选或者想找一个兼顾技术栈完整度、业务逻辑清晰、工作量适中的项目来练手那这个选题管理系统非常对口。它覆盖了用户认证授权、核心业务流转、文件上传、分页查询、路由权限控制这些真实项目里的高频需求做完以后你对Spring Boot和Vue的理解会从“会写接口”提升到“能独立设计一套业务系统”的层次。下面我把整个设计和实现过程拆开细讲从技术选型讲到数据库建表从前端路由守卫讲到后端事务处理所有踩过的坑都会一并交代清楚。1. 项目定位与技术选型为什么是Spring Boot Vue1.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 UIVue3配Element Plus状态管理Vuex 或 PiniaVue2用VuexVue3用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, majortopic选题表topic_id, title, category, description, requirements, teacher_id, max_student, selected_count, status, create_timetopic_selection选题关系表id, topic_id, student_id, teacher_id, status, apply_time, confirm_time, remarkannouncement公告表可选功能id, title, content, publisher, publish_timeoperation_log操作日志可选功能id, user_id, action, detail, create_timesys_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 statustopic_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 ThreadLocalLoginUser 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) { LambdaQueryWrapperSysUser 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 LambdaQueryWrapperTopicSelection() .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) { PageTopicVo topicPage new Page(page, size); LambdaQueryWrapperTopic 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); IPageTopic 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.jsAPI层我会严格按模块拆分每个方法调用后端一个接口不要直接在页面组件里写axios请求这样便于统一管理URL和参数。比如选题相关的APIimport 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中一定要配置serverTimezoneAsia/Shanghai否则按照UTC存储时间会比北京时间少8小时。如果要通过Jackson返回给前端并格式化我直接在application.yml里配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8如果使用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但每解决一个能力就实打实长一分。最后留个小技巧项目不要一开始就追求大而全先把学生选题、教师审核这条主链路跑通加入文件上传和统计功能作为加分项再打磨异常处理和交互细节按这个优先级推进你就能在有限的时间里有完整且稳的项目交付。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。