ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue前后端分离评奖评优管理系统开发实战

SpringBoot+Vue前后端分离评奖评优管理系统开发实战 每年评奖评优季学校里最头疼的就是各类奖学金、荣誉称号的申报汇总。我见过太多用Excel来回传文件的场景班长收一份、辅导员改一版、学院再整合一次经常出现版本冲突学生交的材料格式五花八门评审环节的流转记录更是查无对证。这种状态持续了几年之后我决定不再等了直接基于SpringBootVueMyBatisMySQL这套技术栈从零做起一个前后端分离的评奖评优管理系统。源码完整、有配套部署教程学生端提交申请、管理端发布奖项和审核、评审过程留痕、公示结果导出这些都能在系统里闭环跑通。这篇文章不是空谈架构而是把我做这个项目的完整过程掰开揉碎数据库表怎么设计、JWT登录认证怎么处理、Vue的token全链路怎么走、从本地到服务器怎么部署、联调时踩过哪些坑全部讲清楚。适合三类人看正在做毕业设计的学生、想系统上手前后端分离项目开发的初学者、以及学校里确实需要这类信息化工具的负责老师。不管你基础如何只要跟着文章里每一章走这套系统都能跑起来。1. 评奖评优的业务痛点和前后端分离选型逻辑1.1 旧模式下的评奖评优乱象评奖评优这件事看似是按条件筛人、按名额定人的简单流程实际落地时牵扯的环节非常多。以优秀学生奖学金为例通常时间线是学校发布通知、学院收到名额、辅导员通知班级、学生准备材料、班级评议、辅导员审核、学院汇总、学校终审、名单公示。每个环节都涉及大量的信息登记、材料核验和意见流转。传统手工作业的问题很典型。第一是数据重复填写学生在申请表上填一遍个人信息到了班级汇总表再填一遍学院名单又填一遍学号姓名专业这些字段反复录入一旦中间某次抄错后面核账就是灾难。第二是材料分散无法追踪一个学生的成绩单、获奖证书、社会实践证明往往散落在不同邮件、不同微信聊天记录里评审时很难快速查证。第三是评审过程不透明谁在哪个环节批的、批了什么意见全凭经办人记忆出了问题说不清楚。这些痛点的根子在于缺少一个统一的数据入口和流转载体。业务上需要一个系统让学生只录一次信息让评审链路上所有人都看到同一份数据让每一次审核操作都留下痕迹。这是我做这个项目的业务出发点。1.2 为什么选前后端分离而不是传统单体页面技术选型阶段我认真比较过两条路线一是SpringBoot模版引擎渲染服务端页面二是前后端彻底分离。最终选择后者的理由很实际。前后端分离最直接的好处是并行开发效率高。前端同学拿着接口文档做页面后端同学专注于接口和业务逻辑互不阻塞。虽然单人开发时这种并行优势不明显但项目边界清晰了前端代码只关心渲染和数据交互后端代码只关心接口和数据处理每一边的复杂度都降了一个量级。另一个原因是前端生态的成熟。Vue的组件化开发、Element UI的现成组件做表单、表格、弹窗这类管理型页面几乎不用从零写样式效率比JSP塞标签库高太多了。而且前端脱离开后端服务之后页面部分完全可以独立部署到Nginx或CDN上后续如果要做小程序端或者App端后端接口是可以直接复用的不需要推倒重来。1.3 技术栈选型为什么是SpringBootVueMyBatisMySQL技术栈的每一项我都基于项目实际需求做过对比。后端框架里SpringBoot相比传统SSM省去了大量XML配置内嵌Tomcat让应用一个java -jar就能启动对单体管理系统的开发体验非常友好。Spring生态的整合能力也强Spring Security、Spring Validation这些后面都能直接接进来。持久层我用MyBatis而不是JPA核心原因是这类管理系统有大量多条件组合查询和统计报表需求。MyBatis的SQL完全自己掌控动态SQL标签可以很自然地拼出按状态奖项学号模糊搜索这种条件性能调优时可以直接改SQL上索引心里有底。JPA虽然开发速度快但复杂查询的SQL生成不可控排查问题还要先搞明白它生成的SQL长什么样在这个场景下反而是负担。前端选Vue是综合了学习成本、社区资料和生态。Vue的响应式机制上手快Element UI把表格、表单、日期选择器、上传组件都备齐了非常适合快速搭建后台管理界面。数据库选MySQL则是稳妥之选开源免费、部署简单、资料丰富学校场景的数据量完全不会成为瓶颈。2. 核心表的拆解设计与关键SQL实战2.1 需求驱动的表结构规划评奖评优系统的数据模型我按业务实体拆成了五张核心表用户表、学生表、评奖项目表、申请记录表、评审记录表外加一张公示信息表。用户表承载登录账号和角色信息学生表承载学生的基本信息两者通过用户ID关联。评奖项目表描述评什么奖包括奖项名称、评选学年、名额、申请开始结束时间。申请记录表描述谁申请了什么奖存状态、申请材料路径、申请时间。评审记录表存历次评审意见和状态变更。这个设计的核心思路是申请数据是不断流转的必须有独立的表记录每一次状态变化。很多入门项目会用一个字段存状态把评审意见堆在一列里这会导致两个问题——历史意见丢失、无法还原流转过程。评审记录表的存在让整条审核链路上每个环节的操作都有据可查。2.2 五张表的建表SQL与字段设计说明下面给出几张核心表的建表SQL字段注释直接写在DDL里方便后面对照。CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录用户名, password VARCHAR(100) NOT NULL COMMENT BCrypt加密后的密码, role VARCHAR(20) NOT NULL COMMENT 角色: STUDENT/COUNSELOR/ADMIN, student_id BIGINT DEFAULT NULL COMMENT 关联的学生ID学生角色非空, status TINYINT DEFAULT 1 COMMENT 账号状态: 0禁用 1正常, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT系统用户表;CREATE TABLE student ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(50) NOT NULL COMMENT 姓名, class_name VARCHAR(50) COMMENT 班级, major VARCHAR(64) COMMENT 专业, grade VARCHAR(10) COMMENT 年级, phone VARCHAR(20) COMMENT 手机号, email VARCHAR(64) COMMENT 邮箱 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生信息表;CREATE TABLE award_project ( id BIGINT AUTO_INCREMENT PRIMARY KEY, award_name VARCHAR(100) NOT NULL COMMENT 奖项名称, academic_year VARCHAR(20) NOT NULL COMMENT 评选学年如2024-2025, total_quota INT NOT NULL COMMENT 总名额, remaining_quota INT NOT NULL COMMENT 剩余名额, apply_start DATETIME NOT NULL COMMENT 申请开始时间, apply_end DATETIME NOT NULL COMMENT 申请截止时间, status TINYINT DEFAULT 0 COMMENT 0草稿 1已发布 2已截止 3已结束, description TEXT COMMENT 评选条件说明, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评奖项目表;CREATE TABLE award_application ( id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id BIGINT NOT NULL COMMENT 评奖项目ID, student_id BIGINT NOT NULL COMMENT 申请学生ID, materials_path VARCHAR(255) COMMENT 证明材料存储路径多个用逗号分隔, reason TEXT COMMENT 申请理由, status TINYINT DEFAULT 0 COMMENT 0待初审 1通过 2退回 3已获奖, review_comment VARCHAR(255) COMMENT 最近一条评审意见, submit_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_project (project_id), KEY idx_student (student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT申请记录表;CREATE TABLE award_review ( id BIGINT AUTO_INCREMENT PRIMARY KEY, application_id BIGINT NOT NULL COMMENT 申请记录ID, reviewer_id BIGINT NOT NULL COMMENT 评审人用户ID, old_status TINYINT NOT NULL COMMENT 评审前状态, new_status TINYINT NOT NULL COMMENT 评审后状态, comment VARCHAR(255) COMMENT 评审意见, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评审记录表;这里有个设计细节值得说申请记录表的review_comment字段存的是最新一条评审意见方便列表页直接展示但完整的历史意见都在award_review表里。这种读时冗余、写时全量的做法在中小型系统里很实用避免了每次查列表都要去连评审记录表的开销。2.3 状态机设计与时间的边界处理状态是这个系统的灵魂。我前期设计时将申请状态定义为整型常量0待初审、1通过、2退回、3已获奖。后来评审链路上发现必须区分待班级初审和待学院审核于是拆成了更加细粒度的状态。实际编码时我建议把状态常量定义在枚举类里避免魔法数字散落各处。时间边界是另一个容易出错的地方。评奖项目表里有apply_start和apply_end两个时间字段学生申请接口必须在当前时间介于两者之间时才能提交。这个判断如果放到应用层要考虑服务器时区我建议直接用数据库的NOW()和字段比较让数据库统一处理时间基准。下面的SQL就是典型的判活写法SELECT * FROM award_project WHERE id #{projectId} AND status 1 AND NOW() BETWEEN apply_start AND apply_end这种写法既锁定了项目必须处于已发布状态又完成了时间窗口判断一次查询搞定避免了先查出来再在Java里比来比去的繁琐逻辑和潜在误差。3. SpringBootMyBatis后端从JWT认证到动态SQL3.1 后端项目结构与分层后端工程我按Maven标准结构组织包名用com.example.award。Controller层只做参数接收和结果包装Service层处理业务逻辑Mapper层直接与数据库交互。学生申请奖项的流程就是一个典型例子Controller收到申请请求后Service先校验项目状态和申请时间再检查是否重复申请最后插入申请记录装配返回结果。PostMapping(/awards/{projectId}/apply) public Result apply(PathVariable Long projectId, RequestBody ApplyRequest req) { Long studentId UserContext.getCurrentUserId(); applicationService.submit(studentId, projectId, req); return Result.success(); }Service里几个校验缺一不可项目是否存在、是否在申请窗口内、学生是否已经申请过同一个奖项。这些校验任何一个漏掉都会在后续评审环节埋雷。重复申请的判断用一条count查询就能挡住但要注意加唯一索引兜底防止并发请求下数据库层面出现脏数据。3.2 JWT登录认证的实现与拦截方案登录认证我用的JWT依赖选用jjwt库。用户在登录接口提交用户名密码后端校验通过后生成token返回给前端。token里放userId和role两个核心声明过期时间设置为24小时。为了支持前后端分离部署认证信息不依赖Session所有状态都由前端在请求头里携带。JwtInterceptor实现了HandlerInterceptor接口在preHandle里从Authorization头取出token并校验无效就返回401。校验通过之后把userId存入ThreadLocal方便Service层随时获取当前操作人。这个模式在管理系统中非常常用相当于给每个请求都打上了当前是谁在操作的标记。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (!StringUtils.hasText(token) || !JwtUtil.verify(token)) { response.setStatus(401); return false; } JwtPayload payload JwtUtil.parse(token); UserContext.set(payload.getUserId(), payload.getRole()); return true; } }对于学生和管理员接口的差异化控制我选择在拦截器里做粗粒度校验配合HandlerInterceptor判断请求路径前缀比如/api/admin/**路径强制要求ADMIN角色。细粒度的数据权限则在Service里校验比如学生只能查询自己的申请记录参数里的studentId必须等于当前登录人的ID。3.3 MyBatis动态SQL处理多条件筛选评审后台的申请列表是典型的多条件查询场景按奖项、按状态、按学生姓名或学号模糊搜索。完全为每种组合写独立SQL不现实用MyBatis的动态SQL就非常顺手。select idselectApplicationPage resultTypecom.example.award.entity.Application SELECT a.*, s.student_no, s.name AS student_name, p.award_name FROM award_application a LEFT JOIN student s ON a.student_id s.id LEFT JOIN award_project p ON a.project_id p.id where if testprojectId ! null AND a.project_id #{projectId} /if if teststatus ! null AND a.status #{status} /if if testkeyword ! null and keyword ! AND (s.name LIKE CONCAT(%, #{keyword}, %) OR s.student_no LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY a.submit_time DESC /select这里有几个细节很容易踩坑。LIKE后面的参数拼接MyBatis里直接用% #{keyword} %是没问题的但要注意SQL注入防护不能用字符串拼接的方式直接拼keyword进去。另外ONLY_FULL_GROUP_BY模式下涉及GROUP BY的查询要小心SELECT列的选择问题。多条件查询的性能受数据库索引影响非常大。我在award_application表上建了project_id和student_id的联合索引查询评审列表时会先按project_id过滤索引能有效命中。如果数据量达到几十万建议再加复合索引(project_id, status)把状态过滤也走索引效果会更明显。4. Vue前端的页面结构与token全链路处理4.1 前端工程初始化和依赖安装前端用Vue CLI创建项目选择Vue 3同时安装vue-router、axios、pinia状态管理和Element Plus。Element Plus组件库对管理后台场景的覆盖非常全面表格、表单、弹窗、上传、消息提示都是现成的几乎只需要关注业务逻辑。vue create award-frontend cd award-frontend npm install vue-router4 axios pinia element-plusNode版本是个容易踩坑的点。我当时本机安装了较新的Node版本导致node-sass编译失败后来换成sass就解决了。给新手的建议是直接用LTS版本的Node配合npm源设置国内镜像装依赖能省下不少时间。项目创建完先把Element Plus完整引入跑通后续做按需引入优化体积避免一开始就卡在构建配置上。4.2 axios拦截器与路由守卫的token处理前后端分离的token处理核心就是两条请求发出前把token带上收到401时做统一处理。我把token存到localStorage里登录成功后写入前端所有axios请求通过请求拦截器统一在header里附加Authorization。axios.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) axios.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } )路由守卫负责页面级的访问控制。在路由配置里给需要登录的页面标记requiresAuth属性给管理员页面标记requiresAdmin属性。全局前置守卫里判断没有token且目标页需要登录就重定向到登录页有token但角色不是管理员且访问了管理员页面就重定向到首页。router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.meta.requiresAuth !token) { next(/login) } else if (to.meta.requiresAdmin role ! ADMIN) { next(/) } else { next() } })这里有个小技巧token失效时响应拦截器弹出统一提示然后把用户踢回登录页。但要注意避免在登录页本身上也触发这个逻辑否则会形成死循环。我当时遇到过一次——token过期后页面跳到登录页登录页发出的某个匿名请求也返回401又触发了一次跳转。解决方案就是拦截器里判断一下当前是否已经在/login或者对白名单请求不做401处理。4.3 学生端和管理端的核心页面实现页面结构上我按角色分了两条线。学生端主要包含评分项目列表、申请表单、我的申请三个页面。项目列表用el-card展示奖项卡片每张卡片显示奖项名称、名额数、申请截止时间状态为可申请的卡片有去申请按钮。申请表单用el-form承载包含申请理由的textarea和证明材料上传组件el-upload设置action指向后端的文件上传接口携带token上传后拿到文件路径回填到表单里。我的申请页面用el-table展示历次申请记录和状态标签。管理端是重头页面。奖项管理页负责发布、编辑、启停评奖项目分配名额设置申请时间窗口。评审管理页是核心操作页左侧按奖项筛选右侧表格列出待审核的申请点击审核弹窗里显示学生信息和申请材料填写通过/退回意见后提交。公示管理页则负责一键生成获奖名单发布到公示区支持导出Excel。// 评审提交的核心逻辑 async function submitReview(row, decision) { const res await updateReview(row.id, { newStatus: decision, comment: reviewComment.value }) if (res.code 200) { ElMessage.success(decision 1 ? 已通过 : 已退回) loadList() } }前端这些页面看似简单真正的难点在与后端的数据格式对齐。比如Java里的Long类型传到前端会变成Number超过JavaScript安全整数范围时精度会丢失导致某些ID比较出错。我处理的办法是后端在返回JSON时把ID转成String类型或者让前端用字符串方式处理ID字段保证每次点击操作都能定位到正确的记录。5. 从本地到服务器的完整部署流程5.1 面向服务器的环境准备部署这个系统需要的软件环境不复杂但每一步都有细节需要注意。服务器以CentOS为例需要提前装好JDK 17、MySQL 8.0、Nginx和Node环境。JDK版本在SpringBoot 3.x下建议还是17以上如果项目基于SpringBoot 2.xJDK 8就够用版本选择不能混着来。MySQL安装完以后第一件事是改root密码第二件事是设置字符集。建库时一定用utf8mb4否则中文和emoji符号会出现乱码。我实际部署时专门给系统建了一个独立账号只授予业务库的权限避免直接用root账号跑应用带来的安全风险。5.2 后端Maven打包与云端启动后端打包前先检查application.yml里的数据库连接、文件存储路径、日志路径等配置改成服务器实际环境。数据库的初始化脚本可以直接用本地的SQL文件在服务器上source导入但要注意把测试数据清一清避免把本地测试记录带到生产环境里。mvn clean package -DskipTests java -jar award-server.jar --spring.profiles.activeprod后端启动后用curl或浏览器直接访问应用的健康检查接口确认服务已经起来了。这一步卡住通常有两个原因一是MySQL端口3306没在安全组开放二是application.yml里数据库地址填错。我的排查思路是先在服务器上执行mysql命令确认数据库能连再检查java进程日志里报的异常信息按日志定位问题。生产环境运行建议用nohup或者systemd来守护进程。我习惯写一个systemd服务文件配置Restartalways这样即使进程异常退出系统也能自动拉起来不至于等用户反馈了才发现服务挂了。5.3 前端打包与Nginx部署方案前端打包前要改一个关键配置Vue项目的publicPath。如果站点部署在域名根路径下publicPath可以保持/但如果是部署在子路径下比如http://ip:8081/award/,就要把publicPath改成/award/否则JS和CSS资源路径全部404。这一步是很多人部署后白屏的头号原因。npm run build # 构建产物在 dist 目录dist目录拷贝到服务器的指定位置之后用Nginx托管静态资源同时把/api开头的请求反向代理到后端的8080端口。Nginx的配置如下server { listen 80; server_name your-domain.com; root /opt/award/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } location /uploads/ { alias /opt/award/uploads/; } }访问根路径时前端路由接管页面跳转。使用history模式的路由刷新或直接访问某个子路径时Nginx找不到对应文件try_files会把请求打到index.html上由前端路由自行解析。这是SPA部署的标准配置不理解这条规则的部署者会在刷新404这个问题上卡一天。部署完成后在全流程验证一遍登录、浏览奖项、学生提交申请、管理端审核、公示结果。我把这套验证脚本固定下来每次发版或迁移服务器都按这个顺序跑一遍可以快速发现环境级别的配置问题。6. 开发联调中的高频问题与最终处理方案6.1 跨域问题开发代理与生产环境的差异前后端分离必然遇到跨域。开发阶段我在Vue的vue.config.js里配置了devServer代理把/api开头的请求全部转发到localhost:8080这样浏览器没有跨域问题调试效率最高。生产环境用Nginx反向代理同样规避了跨域因为前后端域名相同。// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }如果你非要通过后端添加CORS头来解决跨域也不是不行但要注意指定允许的来源和请求头。切忌使用allowCredentials(true)加allowOrigin(*)的组合这会导致浏览器直接拒绝响应。我的建议是开发走代理生产走Nginx后端不做跨域配置这套方案最干净。6.2 MyBatis特殊字符比较与日期动态SQL的坑写过MyBatis查询的人应该都遇到过特殊字符问题。筛选时间为申请截止时间大于当前时间的SQL在XML里直接写大于号没问题但小于号必须转义if testapplyEnd ! null AND apply_end lt; NOW() /if小于号在XML解析阶段会被当作标签开头必须用lt;或者把整段SQL包进![CDATA[ ]]里。我在学生申请页的判断和评审列表的时间筛选里都踩过这个坑尤其是应用截止时间的比较逻辑写错一次会导致所有项目都显示为不可申请,排查时还以为是权限或时间格式的问题。日期动态SQL另一个坑是时区问题。服务器默认时区、MySQL时区、应用连接的时区配置不一致时NOW()返回的时间可能与本地观察的时间相差8小时。我的处理方式是统一在JDBC连接串上写serverTimezoneAsia/Shanghai让数据库和应用按同一时区工作。6.3 打包后的资源404与前端的版本兼容问题前端打包后白屏或资源404是常见问题。出现这类问题不要慌按三个步骤排查第一浏览器开发者工具里看Network面板的JS请求返回什么状态码如果是404多半是publicPath配置不对第二看dist目录的index.html里引用的路径前缀确认资源实际位置与请求路径相符第三如果是history模式下刷新404检查Nginx的try_files配置是否生效。另一个常犯的错误是前端组件库版本和Vue版本不匹配。Element Plus要求Vue 3Element UI则是Vue 2的组件库装混会导致组件无法渲染且控制台报错。这类问题在项目初期就要锁定版本号package.json里用精确版本而不是^号模糊匹配避免安装依赖时自动升级到不兼容版本。6.4 并发申请场景下的数据一致性处理学生集中在截止时间前提交申请是常见场景此时系统面临并发写入的考验。最典型的问题是名额超发——多个请求同时读到剩余名额为1同时通过校验并插入申请记录。数据库层面只靠应用层的判断挡不住并发。我采用的方案是在申请记录表的project_id和student_id上加了唯一索引确保同一个学生对同一个奖项只能有一条生效申请。然后再用事务和行锁处理名额扣减SQL如下UPDATE award_project SET remaining_quota remaining_quota - 1 WHERE id #{projectId} AND remaining_quota 0这条语句利用数据库行锁和条件更新保证并发下名额不会扣成负数。如果更新影响行数为0说明名额已满业务层立刻抛出异常回滚整个事务。这个思路比先查再更安全得多也是我做这个项目过程中处理数据并发最值得分享的一个经验。说到这儿我把这套评奖评优管理系统从业务设计到技术落地、再到部署上线的完整链路都过了一遍。你拿到源码之后先按第5章的流程把环境搭起来、把系统跑通然后重点读第2章的建表SQL和第3章的接口实现把核心流程的代码缕清楚。后续想扩展的话可以往三个方向走对接学校统一身份认证做单点登录、加上消息通知模块让状态流转实时触达、把评审环节改成匿名评分和自动汇总。我自己在这次开发里最大的体感是一个系统能不能用得好七成在数据模型和状态机设计代码反而是水到渠成的事。希望这份经验能帮你少踩几个坑。
返回列表