
说实话这几年我手里过了一遍又一遍的全栈管理系统项目里“健美操评分系统”这类赛事评分加信息管理的组合是最容易被低估、又最有代表性的。看起来只是个打分界面加成绩表实际上它把赛事管理、队伍报名、多裁判打分、去极值合分、排名展示这些环节全串在了一起技术栈又恰好是国内中小型Web项目最常见的SpringBootVueMyBatisMySQL组合。如果你手头正好有一份这样的项目源码想弄懂它是怎么设计的、怎么跑起来的、怎么改造成自己的东西那这篇就是给你写的。这套系统能做的事情很直接比赛前管理员建赛事、录队伍、分配裁判比赛过程中裁判登录后给每支队伍逐项打分比赛结束后系统自动去掉最高最低分、按规则加权汇总生成排名榜单。适合看的读者也很明确一是正在做毕设想找项目练手的计算机专业学生二是刚学完SSM和Vue想搞懂一个完整全栈项目如何协同工作的新手三是接到类似“评分系统”“赛事管理系统”需求需要快速套模板的开发者。1. 项目整体设计与思路拆解1.1 题目背后的真实需求很多人拿到这类源码第一反应是问“健美操评分和别的评分有什么区别”我的答案是业务规则不同但系统骨架高度相似。评分系统最大的难点从来不是写个打分页面而是把人类的规则翻译成数据库字段和计算逻辑。健美操比赛的评分通常分几个维度常见的是艺术分、完成分、难度分每个维度满分可能都是10分最后总分按权重合并。而每支队伍可能面对5个甚至7个裁判裁判打分有主观性所以规则里通常有一条硬性要求去掉一个最高分、去掉一个最低分剩下的裁判分数取平均作为该维度得分最后再按权重合成总分。这个“去极值合分”的动作如果靠人工算一场比赛几十支队伍、上百个打分记录非常容易出错系统要解决的核心痛点就在这里。围绕这个核心系统还需要回答一连串管理问题参赛队伍信息从哪里录入裁判账号由谁分配同一裁判能不能给同一支队伍重复打分成绩发布之后教练在哪里看排名管理员能不能对打错的分进行修正把这些需求理清之后功能模块基本就出来了。1.2 技术选型为什么是这四件套SpringBootVueMyBatisMySQL这个组合可以说是当下国内中小型管理系统最“稳”的一套方案稳的意思是学习成本低、资料多、招人容易、踩坑方案全网都有。拿后端来说把SpringBoot和传统SSM放在一起比较就能看出来SpringBoot把SpringMVC、Tomcat、Jackson等组件的配置自动完成了你不需要写一堆XML配置文件动辄几十个依赖的版本冲突问题也被Spring Boot的依赖管理统一缓解。做评分系统这种业务不算复杂的项目启动类一写Controller、Service、Mapper三层一搭开发效率非常高。前端选Vue而不是React核心原因是Vue的模板语法对“从后端拿数据、把数据渲染到表格里”这件事更直观。评分管理系统的页面大多是表单、表格、下拉框这类中后台界面Vue全家桶加上Element UI一类的组件库基本开箱即用。而React的上手曲线稍微陡一些对新手不够友好。MyBatis和JPA之争也很有意思。JPA确实开发快但对SQL的控制力弱遇到多表关联、复杂查询时经常需要在注解里拼JPQL。评分系统里有一类典型需求叫“按赛事查每支队伍的有效均分”这种SQL用MyBatis写在XML里一目了然后期优化也知道去哪改。MySQL就更不用说了开源免费、生态庞大5.7和8.0版本在中小型系统里性能都绰绰有余。整套选型思路其实就是在保证可维护性的前提下选资料最丰富、最不容易卡住人的技术栈。对比维度SpringBootMyBatisMySQLSSMSpringMVCPython FlaskSQLAlchemy配置复杂度低自动配置为主高XML配置多低中文资料量极大大中等团队招人难度容易一般一般适合场景中小管理类系统老系统维护原型/快速迭代1.3 系统模块设计与角色边界这套系统里有三类角色比较典型管理员、裁判、普通浏览者角色直接决定菜单和权限。管理员负责的事最多创建赛事、设置赛事状态未开始、进行中、已结束、维护参赛队伍信息、创建裁判账号并绑定到赛事、查看最终成绩、对异常分数进行修正。裁判的职责非常收敛就是“选队伍、打分、提交”系统要确保他只能打一次而且只能打自己负责的赛事。普通用户或者访客更简单按赛事查看实时排名和成绩单不需要登录也能看。模块设计上我是强烈建议按“赛事维度”组织所有功能因为评分系统所有数据都依附于某一场赛事。如果一开始把表结构设计成没有赛事外键的松散结构后面做“历史赛事对比”“导出某届比赛成绩单”时就会到处补代码。想让代码干净核心原则是用户表管账号和角色赛事表管比赛生命周期队伍表挂在赛事下面评分表同时关联队伍和裁判排名不落表而是通过SQL实时算。2. 核心业务与数据库设计2.1 评分规则如何落到字段设计数据库之前先要把评分规则完全量化。我在这里用一个示例规则做说明真实项目里的具体权重和去极值方式一定以主办方规则为准但落库思路是一样的。假设每项满分10分三位裁判分别打出如下原始分9.2、9.5、9.0去掉最高9.5和最低9.0后剩下的9.2就是该维度得分。如果裁判人数更多比如7个人去掉一个最高和一个最低剩余5人取平均。这个规则不只用于某一个维度艺术分、完成分、难度分都要分别这样处理后再乘上各自的权重系数得到总分。落到代码里这个逻辑应该放在Service层而不是Controller层因为Controller只负责接收请求真正的业务规则要集中在Service中方便复用和测试。而这个逻辑最忌讳写在前端JavaScript里因为前端任何人都可以篡改分数计算最终成绩必须以服务端计算为准。这个原则在实际项目中踩过太多次记住了能帮你省很多事。2.2 数据库表结构设计细节针对这套系统五张核心表基本够用用户表、赛事表、参赛队伍表、裁判分配表、评分表另外可以加一张成绩汇总表用于报表导出或者直接用视图解决。下面是一张表设计概览表名关键字段设计要点sys_userid, username, password, role, real_name密码用BCrypt加密role区分admin/judgeeventid, name, status, start_date, end_date, locationstatus用0未开始/1进行中/2已结束teamid, event_id, team_name, coach, contactevent_id建立普通索引队伍属于赛事event_judgeid, event_id, judge_id多对多关系表一个裁判可参加多个赛事scoreid, event_id, team_id, judge_id, artistry, completion, difficulty, create_time三个维度分字段加上唯一索引防重复打分评分表是整个系统的关键它的唯一索引设计尤为重要。通常裁判给一支队伍只允许提交一次分数那么唯一索引可以建立在(team_id, judge_id)上如果比赛是多人分组、多轮次就把event_id加进索引。这样即便后端漏写了判重逻辑数据库这一层也能兜底。需要注意score表里的维度字段尽量用decimal类型比如decimal(3,1)既支持9.5这样的小数又不会出现9.55这种超出规则精度的数据。创建时间和修改时间建议由数据库自动维护default值设为CURRENT_TIMESTAMP避免每写一条SQL都要手动填时间。2.3 前后端交互的数据结构约定前后端分离开发最怕各写各的尤其是接口返回格式不统一前端每次都要判断一堆乱七八糟的结构。这套系统里统一接口返回格式是第一个要定的约定。我习惯定义Result类里面三个字段code、message、data。成功时code为200业务异常时code为500未登录或权限不足时code为401或403。前端axios拦截器拿到响应后只判断code不是200就全局弹出错误提示这样所有页面都有统一反馈。分页参数也建议做个规范后端接收pageNum和pageSize两个参数返回结构固定为PageResult包含total、list、pageNum、pageSize字段。前端表格组件直接绑定这两个字段即可。时间和金额是两类最容易出格式问题的字段时间统一传时间戳或者yyyy-MM-dd HH:mm:ss格式的字符串防止不同浏览器对时间解析不一致。3. 实操过程与核心环节实现3.1 环境要求与启动顺序先把项目跑起来比什么都重要。这套系统典型的环境要求是JDK 8以上、Maven 3.6以上、Node.js 14以上、MySQL 5.7或8.0Vue项目如果用的ViteNode版本建议16以上。启动顺序我建议按“数据库→后端→前端”走。第一步创建数据库把项目里带的SQL脚本导入通常命名为aerobic_score.sql或类似的文件名。导入前注意MySQL的字符集要设置为utf8mb4不然遇到生僻汉字或特殊符号会乱码。第二步打开后端的application.yml文件把数据库地址、用户名、密码改成自己本机的这一步漏改是初学者报错最多的地方。第三步在后端目录执行mvn spring-boot:run看到Tomcat started on port 8080就说明后端起来了。第四步打开前端目录依次执行npm install和npm run serve等编译完成提示Local访问地址即可。还有一点要注意前后端端口要分开后端默认8080前端Vue默认8081或者5173别用一个端口。如果改了后端端口前端的接口请求地址也要对应改。3.2 后端核心代码解析后端目录结构是标准的三层分包controller、service、mapper、entity加上config。Controller层的代码非常薄只做参数接收和结果封装业务逻辑全部下沉到Service。先看统一返回体public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }这一段代码虽然简单却是整个前后端协作的基石。所有Controller方法最后都返回Result类型前端逻辑就能统一处理。Service层的评分计算是核心中的核心里面有一个去掉最高最低分的实现Override public Double calcEffectiveScore(ListDouble rawScores) { if (rawScores null || rawScores.size() 3) { throw new BusinessException(有效裁判人数不足无法计算); } ListDouble sorted rawScores.stream().sorted().collect(Collectors.toList()); sorted.remove(0); sorted.remove(sorted.size() - 1); double avg sorted.stream().mapToDouble(Double::doubleValue).average().orElse(0); return BigDecimal.valueOf(avg).setScale(2, RoundingMode.HALF_UP).doubleValue(); }这个方法有几个容易忽略的细节。第一是必须先排序再移除首尾元素如果不排序直接remove(0)和remove(size-1)一个数组越界一个移除错了元素。第二是判空要放在最前面否则空列表直接抛异常。第三是保留两位小数用RoundingMode.HALF_UP进行四舍五入避免浮点数精度问题。Mapper层用MyBatis的XML写SQL评分表插入建议写成MySQL的INSERT ON DUPLICATE KEY UPDATE形式和数据库唯一索引配合后可以实现“重复打分自动覆盖更新”insert idinsertOrUpdate parameterTypeScore INSERT INTO score(event_id, team_id, judge_id, artistry, completion, difficulty) VALUES(#{eventId}, #{teamId}, #{judgeId}, #{artistry}, #{completion}, #{difficulty}) ON DUPLICATE KEY UPDATE artistry VALUES(artistry), completion VALUES(completion), difficulty VALUES(difficulty) /insert注意这个写法依赖(team_id, judge_id)的唯一索引没有索引这个功能不生效。有了这段代码裁判重复提交时不用先查一遍再决定插入还是更新数据库自动处理性能和代码简洁度都提升不少。3.3 前端核心代码解析Vue项目的目录结构一般是api、router、views、components、utils这几个。api目录用来统一管理所有请求router目录定义路由和菜单权限views目录按页面模块放视图组件。axios封装这一步特别重要我用一个request.js文件处理公共逻辑import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization token } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error { ElMessage.error(网络请求失败) return Promise.reject(error) } ) export default request这里的baseURL写成/api不是后端完整地址是因为开发环境用了Vite的proxy代理生产环境用Nginx转发。这种写法让前端代码里不出现具体IP和端口换环境只需要改代理配置。打分页面的表单绑定很直接每支队伍一行三个维度分数用数字输入框el-table :datateamList el-table-column propteamName label参赛队伍 / el-table-column label艺术分 template #default{ row } el-input-number v-modelrow.artistry :min0 :max10 :step0.1 / /template /el-table-column el-table-column label完成分 template #default{ row } el-input-number v-modelrow.completion :min0 :max10 :step0.1 / /template /el-table-column /el-tablev-model绑定行对象字段用户改分数时数据即时同步点提交后把整个表格数组一次性传给后端。前端也可以做一个实时预览计算方便裁判提交前心里有数calcPreview(scores) { const list [...scores].sort((a, b) a - b) list.shift() list.pop() const total list.reduce((sum, n) sum n, 0) return (total / list.length).toFixed(2) }这个预览只是给用户看的后端会重新计算最终成绩前端的展示不需要成为权威结果。3.4 评分计算逻辑的权重合成维度分数算完后总分合成是另一个容易出坑的地方。假设规则是总分等于艺术分乘0.2加上完成分乘0.5加上难度分乘0.3那么合成代码要放在事务里统一计算。Transactional(rollbackFor Exception.class) public void publishEventScores(Long eventId) { ListTeam teams teamMapper.selectByEventId(eventId); for (Team team : teams) { ScoreAggregate agg scoreMapper.getEffectiveScores(eventId, team.getId()); double total BigDecimal.valueOf(agg.getArtistry()) .multiply(BigDecimal.valueOf(0.2)) .add(BigDecimal.valueOf(agg.getCompletion()).multiply(BigDecimal.valueOf(0.5))) .add(BigDecimal.valueOf(agg.getDifficulty()).multiply(BigDecimal.valueOf(0.3))) .setScale(2, RoundingMode.HALF_UP) .doubleValue(); teamMapper.updateScore(team.getId(), total); } }加Transactional的意义在于如果计算到第三支队伍时报错前两支队伍的成绩不应该部分更新否则赛事成绩会处于一个“一半新一半旧”的脏状态。类似的场景还有管理员修改某位裁判的分数后该队伍总分必须同步重算不能只改评分表不改排名数据。这里的getEffectiveScores对应的SQL是评分系统里最核心的一条它的逻辑是“按队伍分组每个维度去掉最高最低再取平均”。MyBatis里可以写成select idgetEffectiveScores resultTypeScoreAggregate SELECT AVG(artistry) AS artistry, AVG(completion) AS completion, AVG(difficulty) AS difficulty FROM ( SELECT artistry, completion, difficulty FROM score WHERE event_id #{eventId} AND team_id #{teamId} ORDER BY artistry DESC LIMIT 99999 ) /select上面这个只是简化示例真正去掉最高最低要写窗口函数或子查询分组过滤MySQL 8.0支持窗口函数后可以用ROW_NUMBER()实现。实际项目里建议先查全部明细再在Java里过滤虽然多一次数据库交互但代码更清晰可控评分记录量级下性能完全没有压力。4. 常见问题与排查技巧实录4.1 数据库连接与驱动版本评分系统接到新机器上第一个卡点基本都在数据库连接。如果是MySQL 8.0驱动类名要写成com.mysql.cj.jdbc.Driver而不是旧版com.mysql.jdbc.Driver两个驱动在8.x里都能用但会出现警告。时区问题紧随其后报错The server time zone value is unrecognized是最典型的情况需要在连接URL后面加参数url: jdbc:mysql://localhost:3306/aerobic?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrueallowPublicKeyRetrievaltrue很多新手不知道这个不加在MySQL 8.0下会报Public Key Retrieval is not allowed尤其用Navicat连不上时更容易在这里翻车。严格来说生产环境不建议一直开着allowPublicKeyRetrieval但本地开发测试开起来最省事。4.2 MyBatis映射与Mapper绑定MyBatis最常见的报错是Invalid bound statement意思是接口方法找不到对应的SQL语句。原因基本有三种XML文件没有放在和Mapper接口同包同名目录下XML里的namespace写错配置里没有加mapper-locations指向XML目录。三个问题逐个排除基本都能解决。数据库字段下划线和Java实体驼峰不一致也是高频问题。数据库字段arts_score实体属性artsScore如果MyBatis配置没打开驼峰映射查询结果全是null。在application.yml里加一行配置即可mybatis: configuration: map-underscore-to-camel-case: true这行配置加上后arts_score自动映射到artsScore省去写resultMap的工作量。4.3 Vue跨域与接口404前端访问后端接口报“Access to XMLHttpRequest has been blocked by CORS policy”多半是跨域配置不到位。开发阶段最简单的方式是配置Vue项目的代理。Vite项目在vite.config.js里写server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }后端也可以加一个CORS配置类作为双保险Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(Registry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } }注意allowCredentials(true)和allowedOriginPatterns()要配合使用如果允许携带Cookie就不能简单写allowedOrigins()这是很多报错 SEC7128 的来源。如果前端页面能打开但接口全404先检查后端Controller的RequestMapping路径和前端baseURL路径有没有重复拼写比如后端是/api/score前端baseURL又写了/api最后请求变成/api/api/score这是很容易犯的低级错误。4.4 端口冲突与打包含前端后端端口8080被占用时的解决方式很简单先查端口再换端口。Windows命令netstat -ano | findstr 8080可以找出占用进程的PIDLinux用lsof -i:8080。确定占用进程不是必要服务后杀掉或者在后端配置里换端口。但换端口只是第一步前端页面里的代理target也要同步换否则页面能开但还是请求不通。这点经常被忽略常常排查半天发现是前后端连的端口不是同一个。上线部署时如果想用一个SpringBoot包解决所有问题可以前端先执行npm run build把dist目录里的静态文件复制到后端的src/main/resources/static下然后重新打包。这种方式确实简单但注意Vue路由如果用的history模式刷新页面会出现404解决方法是改用hash模式或者在SpringBoot里配置forward到index.html。个人项目图省事就用hash模式功能和体验差别不大。4.5 成绩合算的隐藏坑评分系统最深的坑在合算逻辑里常见的有这么几类。第一类是同一裁判重复打分导致数据翻倍因为早期没加唯一索引测试时多点了几次提交按钮平均分被拉低。遇到这种情况先看score表里有没有(team_id, judge_id)重复记录有就清掉然后补上唯一索引。第二类是去极值的边界情况。如果只有两个裁判打了一条记录去掉最高最低后数组为空除零直接报错。所以Service里判空条件要写rawScores.size() 3就直接抛业务异常提示“至少需要3名裁判打分”。第三类是加权平均的错误写法。有人在SQL里直接AVG(artistry completion difficulty)这个写法会把三个维度混在一起求平均而不是先各自求平均再加权最终结果完全不对。正确做法一定是先按维度分别计算有效均分再乘对应的权重系数。第四类是精度问题。总分计算时如果用double反复乘加容易出现8.550000000000001这种结果。稳妥做法是用BigDecimal进行乘法中间结果保留4位小数最后结果保留2位。这和金额计算的思路完全一致但很多人写业务代码时不会把金融系统的精度意识带到评分系统里。我再补一个排查合算问题的实用技巧准备一组已知预期结果的手工数据比如给三个裁判固定打9.0、9.5、9.2预期有效均分9.2加权总分按规则算好然后写一个单元测试来验证。评分规则改一次单元测试跑一次比任何人工复核都靠谱。这个习惯看着麻烦但在排名发布后发现集体算错的那一瞬间你会庆幸当时写了这个测试。写在后面的一点个人体会每年看这类项目源码我最深的感受是评分系统的成败不在界面好不好看而在业务边界和处理规则的严谨程度。数据库唯一索引、Service层事务、BigDecimal精度、统一返回格式这些看似基础的点位任何一个没做好上线后都会变成事故。我把这些细节一点点拆出来写在这里就是希望你能跳开“我要有个能跑的项目”这个层面去想清楚每一行代码背后的约束条件。如果你想把这个项目变成自己的作品建议按这个顺序动手先改包名和后端项目名去掉原作者的痕迹再登录账号走一遍管理员建赛事、裁判打分、查看排名的全流程故意制造几个边界情况看看系统能不能扛住然后增加数据导出功能用EasyExcel把成绩单导出成Excel最后把排行榜页面加上ECharts图表展示各队伍分数趋势。做完这四步这个项目的代码就真正长在你脑子里了答辩或者面试时随便一个问题都能答出所以然来。