
聊到“Java Web 学生评奖评优管理系统”这应该是很多计算机专业同学毕业设计、课程设计里的“常客”。但你如果翻过网上那些老项目大多还停留在 JSP Servlet、SpringMVC JQuery 那个年代前后端不分离代码耦合严重数据库脚本动不动还是 MySQL 5.x 的老语法。今天分享的这套源码技术栈直接拉到了当前就业市场的主流配置SpringBoot2 Vue3 MyBatis-Plus MySQL8.0并且自带完整文档。不论你是准备做毕设还是想系统练一遍前后端分离项目的完整落地流程这套东西都很有参考价值。我会从架构思路、核心模块拆解、关键代码实现、环境搭建一直讲到实际部署中容易踩的坑全程都用开发者的视角来聊尽量让你看完就能对着源码理清思路。1. 项目整体设计与技术选型思路1.1 为什么是这套技术组合很多同学选技术栈容易犯一个毛病跟风追新。看见 SpringBoot3 出来了就直接上结果发现 JDK17 的环境没装、老教程全对不上、第三方依赖还有兼容坑折腾两天还没开始写业务代码。这套项目把技术栈定在 SpringBoot2 Vue3 MyBatis-Plus MySQL8.0是非常务实的选择我把每个环节的选型逻辑拆开说。SpringBoot2 对应的是 JDK8 生态。JDK8 在高校机房、企业存量系统里依然是绝对主力资料多、踩坑分享丰富遇到问题百度一下基本都有答案。SpringBoot3 强制要求 JDK17虽然性能更好但对毕设和接手老项目来说反而增加环境负担。SpringBoot2 的自动配置机制、Starter 依赖管理已经非常成熟开发体验和最终效果足够好。Vue3 是前端的主流版本组合式 API 让代码复用和逻辑组织比 Vue2 清爽太多。配合 Vite 构建工具开发环境秒级热更新比 Webpack 时代动辄几十秒的编译等待舒服得多。不过要说明一下这套源码如果用的是 Vue CLI 创建工程也没关系核心是 Vue3 的语法和组件化思路构建工具不影响你理解代码。MyBatis-Plus 对单表 CRUD 的简化是革命性的内置的 BaseMapper 直接提供了 insert、delete、update、selectById 这一堆通用方法绝大多数数据访问不用写 SQL。它的 LambdaQueryWrapper 用起来非常顺手比手拼 XML 里的动态 SQL 直观得多也比 JPA 更容易控制 SQL 行为适合这种业务规则比较清晰的管理系统。MySQL8.0 是当前数据库的事实标准。相比 5.7默认字符集已经是 utf8mb4对中文和 emoji 的支持零配置搞定窗口函数、公用表表达式CTE这些新特性虽然在这个项目里用不到但是学习价值是在的你以后做数据分析类功能时随时能用上。1.2 学生评奖评优系统的需求定位与模块规划评奖评优这个业务场景核心矛盾在于“提交-审核-公示”这条链路的状态管理和角色权限控制。整个系统按角色拆可以分成三类用户每类用户看到的界面和能做的操作完全不同。学生端的主要诉求是查看可申报的奖项、填写申请材料、上传佐证附件、查看自己申请的审核进度。这里的关键点是“申请记录的快照”概念——学生提交申请的那一瞬间系统要保存他当时的成绩排名、综合测评分数、获奖经历等字段而不是实时去关联学生表。原因很好懂学生的成绩单可能会被教务修正如果评审时读的是实时数据学生提交的材料和评审看到的材料对不上是要出纠纷的。好的设计是申请记录里直接冗余存储这些快照字段。教师端或者辅导员端核心功能是查看名下学生的申请列表、对申请材料进行初审、填写推荐意见。对于校级奖项可能还有二级学院到学校的两级审批那就要设计审批流转的功能。管理员端的功能最重包括学生信息管理、奖项配置奖项名称、等级、名额、申请起止时间、评审分配指定某奖项由哪些老师评审、结果公示管理公示起止时间、公示状态、以及最终的获奖名单归档。除了业务模块还有个容易被忽略但必考的点统一认证与登录拦截。站点内所有接口除了登录其余都要校验身份和角色权限这块一般用 JWT 或者 Spring Security 来做本项目里用拦截器加自定义注解的方式实现轻量且够用。2. 数据库设计与核心业务模型2.1 核心表结构设计与字段规划数据库设计是这类管理系统的地基表建不好后面写代码全是打补丁。评奖评优系统的核心表我按业务域拆成四组。第一组是用户与权限域。用户表设计上要注意学生和教师不应该各建一套账号体系而是建一张统一的用户表通过 role 字段区分角色再通过 profile 表或直接在用户表冗余学号、工号、姓名、所属学院。这样登录逻辑只需要查一张表不用走多态关联代码会简单很多。密码字段用 BCrypt 加密存储不要用 MD5MD5 在如今算力下等于明文。第二组是基础数据域主要是学生信息和奖项信息。学生信息表包含学号、姓名、性别、专业、班级、年级、政治面貌、综合测评成绩、学分绩点、排名等。奖项信息表包含奖项名称、奖项等级国家级、省级、校级、院级、评奖年度、名额数量、申请开始时间、申请截止时间、是否允许重复申请等。设计时要想清楚有些字段是会变化的比如综合测评成绩它到底是存在学生表里实时更新还是在学生提交申请时做成快照我倾向于后者实操中这点非常关键。第三组是业务流转域包括申请记录表、审核记录表、公示记录表。申请记录表是最复杂的字段包括关联的学生ID、关联的奖项ID、申请状态草稿、已提交、审核中、通过、驳回、各项申报材料的内容或链接、辅导员初审意见、学院审核意见、学校审核意见、驳回原因等。审核记录表记录每一步操作的操作人、操作时间、操作类型、意见内容方便追溯这条流水设计很多学生容易漏掉但对于评奖评优这种敏感业务来说几乎必考。第四组是公示与归档域。公示表记录每个批次的公示开始时间、结束时间、公示范围内的奖项和名单公示期内学生可以发起异议。公示结束后数据归档生成最终的获奖名单表和证书编号。2.2 业务状态流转与权限控制分析评奖评优的核心业务逻辑就是一条申请记录的“状态机”。我用状态值来串起整个流程DRAFT(草稿) - SUBMITTED(已提交) - COLLEGE_REVIEWING(学院审核中) - SCHOOL_REVIEWING(学校审核中) - APPROVED(通过) \- REJECTED(驳回)状态流转的触发点对应不同的角色学生提交申请触发草稿到已提交辅导员初审触发已提交到学院审核中或驳回学校评审触发学院审核中到学校审核中或驳回最终评定通过后进入公示流程。这个流转过程必须做“防跳转”校验也就是不能出现学生直接提交一个 APPROVED 状态的申请。实现时可以在 Service 层写一个状态流转校验方法每个更新操作前先查当前状态跟期望状态比对不一致就抛业务异常。权限控制这块我没有用 Spring Security 那套重型的过滤器链而是采用拦截器 自定义注解的方式。登录接口颁发 JWT Token前端每次请求在 Header 里携带 Token后端拦截器统一解析校验。针对需要特定角色的接口用 RequireRole(ADMIN) 这样的注解标记拦截器里校验通过才放行。好处是轻量、直观整个权限代码加起来不到两百行适合中小型管理系统。如果以后要接入更复杂的权限模型再替换成 Spring Security 也来得及。3. 后端核心技术实现与代码拆解3.1 MyBatis-Plus 通用 CRUD 与条件构造器应用这个项目的后端数据访问层几乎全部依赖 MyBatis-Plus 的通用能力。你只需要让 Mapper 接口继承 BaseMapper 就能直接调用 insert、deleteById、updateById、selectById、selectList 等现成方法完全不用写一行 SQL。我举一个实际的例子比如学生提交申请时要查询某个奖项是否在申请时间窗口内、是否还有剩余名额。用 LambdaQueryWrapper 写起来是这样的// 查询指定ID的奖项并校验申请时间窗口和名额 Award award awardMapper.selectOne(new LambdaQueryWrapperAward() .eq(Award::getId, awardId) .ge(Award::getEndTime, LocalDateTime.now()) .le(Award::getStartTime, LocalDateTime.now())); if (award null) { throw new BusinessException(该奖项不在申请时间范围内); } // 统计该奖项已提交的申请数量 Long appliedCount applicationMapper.selectCount(new LambdaQueryWrapperApplication() .eq(Application::getAwardId, awardId) .in(Application::getStatus, Arrays.asList(SUBMITTED, COLLEGE_REVIEWING, SCHOOL_REVIEWING, APPROVED))); if (appliedCount award.getQuota()) { throw new BusinessException(该奖项申请名额已满); }LambdaQueryWrapper 最大的好处是类型安全字段名写错了编译期直接报错不像 XML 里写错列名要等到运行期才炸。另外注意 eq、ge、le 这些方法的调用顺序没有强制要求但为了代码可读性建议保持条件字段从左到右、从主到次的顺序写。分页查询也是管理系统的硬需求。MyBatis-Plus 提供了分页插件配置好之后只需调用 selectPage 方法PageApplicationVO page new Page(pageNum, pageSize); LambdaQueryWrapperApplication wrapper new LambdaQueryWrapperApplication() .eq(Application::getStudentId, currentUser.getStudentId()) .orderByDesc(Application::getCreateTime); IPageApplicationVO result applicationMapper.selectPageWithAwardName(page, wrapper);注意这里如果你要联表查询返回 VO 对象需要在 Mapper 里写自定义方法。MyBatis-Plus 的 BaseMapper 只处理单表涉及多表关联的业务查询还是要自己写 SQL这就体现出 MyBatis 系比 JPA 灵活的地方了。selectPageWithAwardName 背后的 XML 里就是一个 left join 奖项表把业务字段和奖项名称查出来封装成 VO 返回。3.2 Service 层业务逻辑设计与事务处理Service 层是系统的“大脑”所有业务规则都在这里落地。我用 Interface Impl 的方式定义 Service这是 MyBatis-Plus 官方推荐的做法服务接口继承 IService 实现类继承 ServiceImplM, T这样既能获得 MyBatis-Plus 内置的通用服务方法又能扩展自己的业务方法。以“学生提交申请”这个场景为例核心 Service 方法长这样Transactional(rollbackFor Exception.class) public Long submitApplication(ApplicationSubmitDTO dto) { // 1. 校验学生身份和奖项信息 Student student studentService.getById(dto.getStudentId()); Award award awardService.getById(dto.getAwardId()); validateSubmitPermission(student, award); // 2. 校验是否重复申请 Long count applicationService.count(new LambdaQueryWrapperApplication() .eq(Application::getStudentId, dto.getStudentId()) .eq(Application::getAwardId, dto.getAwardId()) .ne(Application::getStatus, REJECTED)); if (count 0) { throw new BusinessException(不能重复申请同一奖项); } // 3. 创建申请记录并保存快照 Application application new Application(); BeanUtils.copyProperties(dto, application); application.setStatus(SUBMITTED); application.setCreateTime(LocalDateTime.now()); application.setUpdateTime(LocalDateTime.now()); applicationService.save(application); // 4. 记录操作日志埋点 operateLogService.record(SUBMIT_APPLICATION, application.getId(), currentUserId(), 学生提交评奖申请); return application.getId(); }这里我看到过很多初级同学犯的错误写了一大堆业务逻辑但忘记加 Transactional 注解。比如保存申请记录之后还要扣减名额、记录日志只要中途任何一个操作抛异常前面对数据库的写入就变成脏数据了。加 rollbackFor Exception.class 的意思是所有异常都触发回滚包括 RuntimeException这应该成为你写任何涉及多表变更的 Service 方法的默认习惯。3.3 统一返回体、异常处理与登录鉴权实现前后端分离项目里接口返回格式必须是统一的 JSON 结构否则前端处理起来很痛苦。我的统一返回体定义如下{ code: 200, message: success, data: { } }code 为 200 表示成功其他为业务错误码message 给前端弹提示用data 放业务数据。对应 Java 类是一个泛型类 Result 含静态方法 success(data)、error(code, message)。全局异常处理器是整个项目非常关键的一个类。我用 RestControllerAdvice ExceptionHandler 捕获所有异常分类处理业务异常 BusinessException返回前端友好的提示信息不打印堆栈避免刷日志参数校验异常 BindException/MethodArgumentNotValidException把校验失败信息拼装返回兜底 Exception打印完整堆栈返回“系统繁忙”的通用提示这样接口层代码就非常干净不用每个方法都 try-catch业务代码只管抛异常。登录鉴权我的实现方案是 JWT 拦截器。用户登录成功后生成 Token 返回前端Token 里携带用户ID和角色信息。拦截器里解析 Token 并放入 ThreadLocal 上下文后续 Service 层通过上下文获取当前用户避免在 Controller 层方法签名里反复传 userId。自定义注解 RequireRole 配合拦截器做角色控制public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod (HandlerMethod) handler; RequireRole requireRole handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole null) { return true; } // 从Header取Token并校验角色 String token request.getHeader(Authorization); LoginUser loginUser JwtUtil.parseToken(token); if (loginUser null) { throw new BusinessException(登录状态已过期请重新登录); } String role loginUser.getRole(); String requiredRole requireRole.value(); if (!role.equals(requiredRole)) { throw new BusinessException(无权限访问); } UserContext.set(loginUser); } return true; } }注意拦截器里做权限判断后要在 finally 或拦截器 afterCompletion 里清理 ThreadLocal否则线程池复用会导致用户信息串号这是一个很经典且隐蔽的 bug排查起来极其痛苦。4. 前端 Vue3 工程化实践与页面实现4.1 Vue3 组合式 API 与选项式 API 的选择这套系统前端用的 Vue3这里我想好好聊一下 Composition API 和 Options API 怎么选因为这是 Vue3 学习路上的第一个岔路口。我先给结论新项目直接用组合式 API不要留恋选项式 API。选项式 API 的代码组织方式是按“选项”划分的你把一个组件的数据放在 data 里、方法放在 methods 里、生命周期钩子放在 mounted 里。组件小的时候看着整齐等业务逻辑一多比如一个页面要同时处理表格查询、弹窗表单、文件上传、状态切换“数据、方法、侦听器”这一套撑下来几百行找代码的时候得在 data 和 methods 区域来回翻维护成本很高。组合式 API 的思路是按“业务逻辑”来组织代码。同一个业务功能的状态和方法放在一起比如“查询申请列表”相关的 loading、list、queryParams、fetchList 方法全写在一块修改时只看这一段就够。代码复用上也更优雅自定义一个 useApplicationList 的 composable 函数多个页面都能引用。我拿这套系统里的“学生申请列表”页面来对比说明。选项式 API 的写法大致是export default { data() { return { loading: false, list: [], queryParams: { page: 1, size: 10 } } }, methods: { async fetchList() { this.loading true const res await getApplicationList(this.queryParams) this.list res.data.records this.loading false } }, mounted() { this.fetchList() } }换成组合式 APIscript setup import { ref, onMounted } from vue import { getApplicationList } from /api/application const loading ref(false) const list ref([]) const queryParams reactive({ page: 1, size: 10 }) const fetchList async () { loading.value true try { const res await getApplicationList(queryParams) list.value res.data.records } finally { loading.value false } } onMounted(fetchList) /script