有一段时间我把“Java书法比赛评分系统”当作了毕业设计的主攻方向。起因是帮学校整理一场师生书法比赛的评分现场我看到几位评委老师守着一张手工打印的评分表反复核分一个作品的得分算了三遍三个结果现场气氛非常尴尬。书法比赛本质上是主观评价评委审美差异本来就大如果计分环节还出错不仅比赛结果站不住脚连评委之间也容易起矛盾。所以我把这个题目拆成了两个目标一是复刻一套从赛事创建、作品登记、评委打分、成绩统计到结果公示的完整线上流程二是保证计分逻辑足够严谨让去掉最高分、最低分、按权重汇总这些规则透明且可复核。这才是我对“评分系统”这四个字的理解它绝不只是CRUD而是一套完整的业务闭环。这篇文章会按我从0到1的实现思路展开把表结构、评分算法、权限设计、踩坑记录都整理出来供打算做同类毕业设计或想在学校里真正落地一个比赛平台的读者参考。1. 书法比赛评审的痛点从纸质登记到全流程线上化1.1 线下评审的三大痛点恰好就是系统的需求来源我之前亲眼看过一次完整的线下书法比赛评审。流程是这样的评委老师每人拿一摞纸质评分表依次给每件作品打分写上“笔法”“结构”“章法”“内容”几个小分再加一个总分。打完分之后学生志愿者把这些表收齐用Excel手工录入再去掉每个作品的最高分和最低分算平均分最后排名。听起来好像不难但实际执行时问题非常大。第一个痛点是数据录入环节的二次误差评委手写的分数尤其是8和6、9和7这种字形相近的数字录入时很容易看错。第二个痛点是评分尺度不一致有些评委打分整体偏高平均给到8.5有些评委则从严平均只有6.5。如果系统只做简单平均那些被严格评委打的低分就会严重拉低某个作品的排名这对参赛者不公平。第三个痛点是结果难以回溯纸质评分表装进文件袋之后基本就没人再翻了一旦有选手对成绩提出疑问管理员无法立刻给出“哪个评委、在哪个评分项、打了多少分”的明细。这三条痛点放到一起其实就是一套“书法比赛评分系统”最原始的需求清单。开发的时候不需要去想要不要做AI识别书法质量、要不要搞区块链存证先把评审流程本身跑顺、跑稳、跑透明才是毕设和实际项目都能过关的核心。1.2 系统要覆盖的五个核心环节我把整个项目按业务节点拆成了五段赛事管理、作品管理、评委打分、成绩统计、结果发布。前面两个很好理解赛事管理就是创建比赛、配置比赛时间段和规则作品管理就是登记参赛选手的姓名、编号、作品名称、作品图片。真正拉开工作量差距的是后面三个。评委打分这个环节不能做成简单的“给个总分”就完了。我在调研时发现不同书法比赛对评分项的叫法不完全一样有的分四项有的分五项权重也不同。所以我把评分项设计成了可配置的。成绩统计是系统的核心必须支持“去掉最高分和最低分再平均”和“按评分项权重加权汇总”两种模式。结果发布则要同时支持在线公示和导出Excel成绩单导出这一步看起来不起眼但实际坑很多后面单独讲。我在做技术选型时后端用了Spring Boot MyBatis-Plus数据库用了MySQL前端用Vue Element UI前后端分离。选这套组合的考虑很简单Spring Boot生态成熟、资料多毕设过程中遇到问题能快速搜到答案MyBatis-Plus能省掉大量单表CRUD的XML配置Vue Element UI做后台管理界面上手快表格、表单、弹窗全是现成组件。如果你对Spring Boot不熟换成SSH或者纯Servlet JSP也一样能实现只是效率会低一些。2. 表结构设计把评审规则翻译成可配置的数据2.1 六张核心表少了哪张都会补得很难受数据建模这一步会直接决定后期开发的顺畅程度。我在第一版设计时删删改改最终稳定下来的是六张核心表。表名用途关键字段user系统用户统一存管理员、评委username, password, role, real_namecompetition赛事name, status, start_time, end_time, rule_descwork参赛作品competition_id, contestant_no, contestant_name, work_title, image_pathscore_item评分项配置competition_id, item_name, weight, max_score, sort_orderjudge_assign评委与赛事关联competition_id, judge_idscore_record评分明细work_id, judge_id, score_item_id, score, comment这里有一个很多人一开始会忽略的设计为什么要把评分项单独拆一张表而不是在赛事表里放几个字段因为一场比赛可能有多个评分项而且不同比赛的评分项数量不一样如果直接在competition表里设计item1_score、item2_score这种字段以后想加一个评分项就必须改表结构非常僵硬。拆成score_item表之后评分项就变成了“数据”而不是“结构”管理员在后台可以动态增删。score_record表是所有业务逻辑的落脚点。我刻意把“作品、评委、评分项、分数”作为一条独立记录保存而不是把一场评分打包成一个JSON塞进某个字段。这样做的好处是后期查询特别灵活——想知道某个评委给所有作品的均分、某个作品的单项对比、某几场赛事之间的评分差异都可以直接通过SQL聚合而不需要在程序里解析JSON。2.2 权重和满分先定好后续计算才不会乱score_item表里有三个字段需要特别说明weight、max_score、sort_order。weight是权重百分比比如书法基本功占30%、结构与章法占30%、内容完整性占20%、整体气韵占20%。max_score是每个评分项的满分常见的是10分制或者100分制我做的项目统一用的100分制每个评分项最高给到100分最后按权重加权。sort_order是为了保证评分项在页面上的展示顺序和打印顺序一致。这里有一个容易忽略的后端校验逻辑同一场赛事的所有评分项权重之和必须等于100。这个校验不能只在前端做后端接收配置的时候也必须做因为管理员可能绕过前端直接调接口也可能在新增评分项时忘记调整其他项的权重。我在接口层加了Transactional事务配置信息一次性提交权重加起来不等于100就直接拒绝。另外max_score也不建议做成硬编码。虽然当前项目里所有比赛都用100分制但留着这个字段以后如果校方要办一场“满分10分制”的硬笔比赛后台配置一下就能适配不用改代码。这也是我总结的一个经验能做成配置的尽量不要写死在代码里特别是这种多套赛事共用的系统。2.3 唯一约束是防止数据错乱的第一道防线score_record表里我建了一个组合唯一索引uk_work_judge_item(work_id, judge_id, score_item_id)。这个索引的用途是保证“同一个评委对同一个作品的同一个评分项只能有一条分数记录”。为什么要加这个因为评分的提交场景比较复杂。评委可能分两次进入页面第一次保存了草稿没提交第二次又录入了一遍也可能是前端网络抖动用户点了两次提交按钮请求被发了两遍。如果没有唯一索引数据库里就会出现重复的分数记录虽然排名计算时可以用聚合去重但“重复提交”本身就是一个业务逻辑漏洞应该从源头拦截。加了唯一索引之后配合应用程序里的防重复提交逻辑就能做到双保险。数据库唯一索引负责最终兜底程序逻辑负责在接口层就给出友好提示“您已经给该作品的此评分项提交过分数如需修改请走修改接口。”这个设计在多评委同时并用一台电脑的场景下作用特别明显因为两个人操作的是同一个浏览器很容易出现误触提交。3. 核心评分算法去掉最高最低分背后的数学与边界问题3.1 为什么不能直接用SQL的平均值函数很多人写评分系统时最直观的想法就是把所有评委的分数查出来用AVG()算一个平均分然后排个序就完事了。但如果你真正组织过一场书法比赛就会明白这是行不通的。书法评分是强主观评分评委之间尺度差异极大。有的评委偏好工整风格有的评委偏好个性鲜明的作品如果直接把所有原始分做平均一个给9分的评委和一个给6分的评委他们俩的分值几乎是“相互抵消”的但这两个极端分数本身不应该是同等权重的。国际体育竞技里常见的做法是去掉一个最高分、去掉一个最低分再取中间分数的平均值这个思路用在书法比赛上非常合适。去掉极值之后剩下评分的平均分能显著降低个别人为偏差对整个排名的影响。比如一件作品有5个评委打分9.5、9.0、8.8、8.5、6.5。如果直接平均结果是8.46去掉最高9.5和最低6.5之后中间三位的平均是8.77。显然后者更接近这件作品的真实水平。3.2 去极值的边界条件评委少于4人时该怎么办去掉最高和最低分的前提是评委人数足够多。如果一场比赛只有3个评委再去掉两个极端分数就只剩一个分数了这个单个分数反而更容易被评委的个人偏好绑架。所以我在实现时加了一条规则评委人数大于等于5时启用去极值算法评委人数小于5时直接取全部平均分。这里还涉及一个严谨性问题同一个作品的一轮打分有的评委提交了有的评委还没提交。如果拿“当前已提交人数”来判断是否启用去极值那么随着更多评委提交同一个作品的得分算法配置可能会变结果不稳定。我把判断基准定为“该赛事分配的评委总数”而不是“已提交的评委数量”这样可以保证同一件作品在一场赛事中使用一致的算法。核心实现思路是这样的先查出这件作品的所有评委总分按分数排序去掉第一个和最后一个剩下部分求平均。如果去掉之后剩余数量小于2就只能兜底用全量平均。代码大致如下public BigDecimal calcFinalScoreForWork(Long workId) { ListBigDecimal totalScores scoreRecordMapper.selectTotalScoreList(workId); if (totalScores.isEmpty()) { return BigDecimal.ZERO; } if (totalScores.size() 5) { Collections.sort(totalScores); totalScores totalScores.subList(1, totalScores.size() - 1); } BigDecimal sum BigDecimal.ZERO; for (BigDecimal score : totalScores) { sum sum.add(score); } return sum.divide(BigDecimal.valueOf(totalScores.size()), 2, RoundingMode.HALF_UP); }这段逻辑看起来简单但有一个前提每个评委对一件作品只能有一个总分。所以我在查询前会先用GROUP BY work_id, judge_id对每个评分项加权汇总出总分再去极值。如果查询不到总分记录就返回0或者标记为“待评分”由前端处理展示状态。3.3 分项加权先把每个评分项算成均值再汇总去极值做完后成绩统计还有第二层逻辑——分项加权。每一件作品的最终得分不是“所有评委总分的平均”而是“每个评分项在各评委间计算均值再把各个评分项的均值按权重加权”。我举个例子某赛事评分项为“基本功40%”和“艺术性60%”。作品A在基本功上得到评委分90、92、88、91去掉极值后均值90.67在艺术性上得到评委分80、85、82、83去掉极值后均值82.5。那么最终得分就是90.67×0.4 82.5×0.6 85.768四舍五入保留两位得到85.77。这个逻辑看起来只是小学四年级的数学题但在代码里实现时最麻烦的是如何组织SQL和Java的分工。我一开始尝试用一条SQL把分项均值算出来后来发现涉及去极值、多人分组、评分配置表关联SQL写出来又长又难调试。最终我采用了一个更清晰的方案先按评分项分组查询出“作品、评分项、该评分项所有评委分数列表”在Java内存里去极值、算单项均值最后再遍历评分项列表做加权汇总。代码结构更清晰也方便单元测试单独验证每一步。这里要特别注意BigDecimal的使用时机。凡是涉及分数累加、均值计算的运算一律使用BigDecimal不要用double或float。比如90.67 82.5这种计算用浮点类型会出现类似173.17000000000002的精度误差虽然不影响排名但展示给管理人员看的时候非常不专业。3.4 名次排序同分并列的跳号处理所有作品算完最终分之后下一步就是排名。排名不是简单的按分数倒序排一下就行还要处理并列问题。比赛中经常出现同分情况。我采用的规则是“标准竞赛名次”如果第2名和第3名成绩相同那么两名选手都记第2名下一名选手的名次直接跳到第4名。这个规则在Excel公式里很常见但用代码实现时如果只是简单遍历for (int i 0; i list.size(); i)很容易把名次写错。我的做法是维护一个“上一个名次”和“上一个分数”的游标。遍历排序好的作品列表时当前作品分数与上一个作品分数相同就沿用上一个名次不同时名次为“当前下标1”。这样能自动跳过并列占用的名次。下面这段逻辑我建议直接照抄int currentRank 0; BigDecimal lastScore null; for (int i 0; i finalScoreList.size(); i) { if (i 0) { currentRank 1; } else if (finalScoreList.get(i).getScore().compareTo(lastScore) ! 0) { currentRank i 1; } // 设置currentRank到作品对象 lastScore finalScoreList.get(i).getScore(); }这里要注意compareTo不能换成equals因为BigDecimal的equals方法会同时比较精度90.0和90.00会被判定为不相等而compareTo只比较数值更适合这种业务场景。4. 三种身份的权限闭环管理员、评委、选手如何在一套系统里协作4.1 权限的本质是“谁能看到什么、谁能操作什么”设计权限的时候我先定义清楚了三种角色边界。管理员负责创建赛事、维护作品信息、给评委分配任务、查看并导出最终成绩。评委只能看到分配给自己评分任务的作品只能录入自己维度的分数不能看到其他评委的打分明细。选手或观众则只能查看成绩公示页面不能进入后台。从技术实现上讲我用Spring Boot的拦截器HandlerInterceptor做了三级URL权限控制。登录接口和公开的赛事公告接口放行其他接口全部走拦截器校验。拦截器从Session里取出登录用户和角色然后判断当前请求路径是否匹配该角色的权限前缀。比如/api/judge/**开头的接口如果当前用户角色不是ROLE_JUDGE直接返回403。这种做法比上网搜一套完整Spring Security配置要快得多。因为毕设系统的用户量小、角色少用Spring Security虽然更“正规”但配置量大学起来也费时间。如果你时间充裕建议用拦截器搭好基础再在答辩时说明“系统预留了接入Spring Security的扩展空间”这个回答在答辩场合是很加分的。4.2 赛事状态机后端状态校验比前端隐藏按钮更可靠系统里赛事状态不是谁都能随便改的我设计了四个状态0代表草稿1代表报名中2代表评审中3代表已结束。赛事状态决定了用户能执行什么操作。比如赛事状态为“评审中”时管理员不能再新增作品赛事状态为“已结束”时评委不能再提交分数。最开始的版本我只是在前端根据状态隐藏按钮后来发现根本拦不住。原因很简单前端隐藏按钮只是界面层面的约束如果有人直接调用后端接口照样能绕过限制提交数据。所以我在后端设计了CompetitionStateChecker这个工具类每个写操作接口在执行前都会校验当前赛事状态状态不对就直接抛业务异常。这个状态机的设计还给后期做时长控制带来了好处。比如设置赛事end_time后定时任务会扫描超时未结束的赛事自动把状态从“评审中”推进到“已结束”结束后评分数据锁定评委页面变成只读避免比赛结束后还有人在改分数。4.3 评委之间的数据隔离怎么实现这里说的数据隔离不是复杂的多租户架构而是最简单的“评委不能看别人分数”。实现办法有一个原则在SQL层面把评委身份作为查询条件传进去而不是查出全部数据后再用Java做内存过滤。比如评委打分列表页我需要展示分配给当前评委的所有作品和已有的评分记录。查询语句只需要带上WHERE judge_id ?把当前登录评委的ID传进去。这样做既保证了数据隔离又减轻了内存负担。如果在内存里过滤一旦作品量增大列表接口会越跑越慢而且稍不留神就会把别人的分数漏到页面上。评委提交分数时也要重新校验judge_id是不是当前登录用户不能直接信任前端传过来的评委ID。因为前端请求参数是可以篡改的攻击者完全可以伪造一个judge_id1的请求。我在ScoreController里统一用SecurityUtils.getCurrentUserId()获取当前登录用户业务逻辑里永远不读取前端传的评委ID。5. 实测阶段踩过的五个坑逐个排查与修复5.1 评委同时打分互相覆盖分数越算越乱系统第一个版本上线试运行时我只单机测试什么问题都没有。结果现场有两位评委坐在不同电脑上同时给同一件作品的同一评分项提交分数其中一个评委的分数莫名其妙丢掉了。排查过程是这样的我第一反应是数据库连接池的问题看了半天没问题。后来打开数据库表数据发现同一(work_id, judge_id, score_item_id)组合出现了多条记录才意识到提交接口没有做幂等控制。两个评委同时发起请求时各自的插入操作互不知晓最后造成了重复数据和覆盖。修复办法分两层数据库层增加唯一索引应用层提交接口在事务内先查后插查到已有记录就转为更新操作。再加上前面提到的BigDecimal分数替换严谨了很多。这个坑让我意识到并发问题不是大系统才需要关心的事哪怕是几十个评委轮流打分的小系统也架不住有人手快双击按钮。5.2 去极值算法启用边界导致分数全丢这是我测试过程中最尴尬的一个Bug。当时我把去极值逻辑判断条件写成了“只要评委人数大于等于3就去掉极值”结果有个赛事只分配了3位评委2号作品刚够3个人打了分去掉最高和最低之后只剩下中间一位评委的分数算出来的中位数把整体分数拉偏还有一件作品甚至因为3个评委全是同分去掉极值后列表直接空了最终成绩变成0。修复方式是明确规则启用去极值条件为“评委总数大于等于5”否则全部平均。同时增加健壮性判断去掉极值后如果剩余分数列表数量小于1直接返回0分并标记异常让管理员手动复核。这种边界条件在单元测试里没有覆盖到是因为我当时的测试数据统一用了5位评委没想过只有3位评委的场景。测试一定要覆盖极端人数比如评委1人、2人、3人、4人、5人。5.3 四舍五入与精度串扰成绩单出现90.0和90.00并存成绩列表页有一段时间出现一个奇怪现象两件作品明明总分都是90一件显示90.0另一件显示90.00。虽然排名不受影响但导出到Excel时数据格式不统一非常难看。排查后发现是数据类型混用导致。数据库表里score字段我用的是DECIMAL(5,2)但Java实体类里有的地方用了Double有的地方用了BigDecimal导致MyBatis映射后精度展示不一致。统一所有分数实体类和DTO中的类型为BigDecimal后问题立刻消失。这里顺便说个细节数据库表设计里所有分数列都用DECIMAL(5,2)不要用FLOAT或DOUBLE。MySQL的FLOAT和DOUBLE是近似值存储哪怕你在应用层用的BigDecimal只要数据库字段类型不对查询结果仍然可能带上误差。5.4 导出Excel中文列名和文件名乱码成绩公示功能做得差不多了我自测导出Excel时发现文件下载后中文列名全部变成乱码。排查后知道这是Excel导出时编码问题解决需要两个步骤。第一步导出文件名不能直接中文拼接需要对文件名做URL编码否则下载时会出现%E4%B8%AD...这类乱码或者直接被浏览器拦截。第二步Excel工作簿中要设置单元格字体为“宋体”并且设置列的自动宽度。用Apache POI导出时CellStyle里需要显式设置字体否则默认字体对中文支持不好。我后来换成了EasyExcel导出的API更友好编码问题也不存在了。如果你还在用HSSFWorkbook手动写样式可以参考这个经验创建单元格样式时一定加上Font font workbook.createFont(); font.setFontName(宋体); style.setFont(font);5.5 删除作品时外键约束告警整条业务流程断开管理员在后台删除一个误录入的参赛作品时系统直接抛出了外键约束异常。原因很简单score_record表里有该作品的外键得先删评分明细再删作品本身。我当时的处理是在WorkService里新增deleteWorkWithRecord事务方法先删score_record再删work整体包在一个Transactional里。同时增加了逻辑删除字段deleted默认值0删除时改为1这样就算误删也能恢复。物理删除只留给最高权限的管理员使用避免误操作造成数据永久丢失。6. 从毕设到实际落地的扩展点这几个方向可以继续深挖6.1 大屏实时展示评审进度我做完基础版之后校方提出了一个很实际的需求比赛评审过程中能不能在大屏幕上实时展示当前作品的得分和排名这个需求在项目里做成了“评审监控屏”。评委每提交一次分数后台收到数据后通过WebSocket推送排名变化给大屏端大屏每5秒渲染一次最新榜单。如果不想引入WebSocket也可以用轮询大屏页面每隔10秒调用一次排名接口。虽然实时性稍微差一点但实现成本很低。从毕设角度讲这种“排名数据实时变化”的场景特别适合在答辩演示时用来展示系统的价值比单纯截图列表页有说服力得多。6.2 换技术栈复用时哪些设计可以原样照搬标题里提到这个项目还可以作为Java、Python、PHP、小程序APP、C#、爬虫大数据、单片机等方向的基础我做完这套之后也认真想过换栈的迁移成本。其实真正需要重写的部分只有页面和底层ORM核心的评分算法、去极值规则、状态机、权限模型、表结构设计这些是跟编程语言无关的完全可以原样照搬。举个例子小程序端要做评委打分后端接口在Java里定义成POST /api/judge/submitScore在Python Flask版本里就变成app.route(/api/judge/submitScore, methods[POST])前端小程序只需要对接这个接口协议评分算法照抄Python实现就行。也就是说先把这个系统的业务逻辑和数据模型吃透比纠结用哪门语言更重要这也是这套毕设为什么能衍生出多语言版本的根本原因。6.3 部署环境的坑从本机到服务器端口和路径都容易翻车最后提醒一个容易被忽视的部署问题。本机运行时前端Vue项目通过localhost:8080访问后端一切正常。部署到服务器后前端请求路径如果还是localhost就直接指向服务器本机当然访问不到后端接口。所以前端代码里的接口地址要抽成环境变量上线时配置为服务器IP或域名。另一个常见问题是前端打包后的静态资源路径。Vue项目默认base路径可能是根目录如果部署在Tomcat的/score-system子路径下刷新页面就会404。解决方式是在vue.config.js里配置publicPath: /score-system/。这些部署细节理论上不影响功能但如果你答辩时现场演示因为路径问题打不开页面那体验就很糟糕了。个人实测下来的体会是这套评分系统真正复杂的地方不在于CRUD而在于那些看上去很简单、实际有很多边角的计分规则和状态控制。把去极值边界、同分跳号、评委隔离、并发防重这几块打磨干净后整个项目基本就能承担一场中等规模书法比赛的线上评分工作了。如果你正在做同类题目我建议优先级顺序是先跑通完整业务链路再加花哨功能。核心链路稳了再谈大屏展示、小程序联动就都不算难事。