简介面向高校计算机专业学生与毕设需求基于Java开发的实时评分系统源码专为赛事打分场景设计针对传统手写评分耗时且易错的问题实现评委手机扫码实时打分、大屏同步展示结果适合作为毕业设计项目与Java Web开发学习参考。压缩包共61个文件约138KB以48个Java源码文件为主涵盖SpringBoot、MyBatis等框架核心逻辑附带SQL数据库脚本、XML配置、YAML配置文件及Markdown项目说明文档便于快速还原环境并理解整体设计。已有355人学习下载。资料包含完整前后端交互实现、计分规则模块、大屏展示部分配合MySQL与Redis缓存读者可据此掌握从数据库设计到接口开发、实时展示的完整流程也可作为毕设答辩的项目演示基础整体代码结构清晰利于二次开发、功能扩展与模块复用。1. 实时评分系统到底在解决什么问题每次看到“实时评分系统”这个标题我第一反应不是代码怎么写而是想起第一次做这类项目时的翻车现场评委在台上打完分后台迟迟不刷新大屏上的名次还是十分钟前的旧数据主持人只能想办法把节奏拖慢一点。这就是典型的“假实时”——数据确实存进了数据库但展示端根本没有感知所有人都在等一个手动刷新的动作。这个基于 Java 开发的实时评分系统本质上解决的就是两个问题第一多个评委打分的并发写入不能乱、不能丢第二打分一旦提交排行和均分要能在秒级内推送到大屏或观众端。无论是毕设答辩评分、校园歌手比赛、企业年会节目评选还是课堂互动打分这套逻辑都是通用的。它不是什么炫技项目核心就是“写入可靠 推送及时 排行正确”三件事。说句实在话这个项目对新手最友好的地方在于它既有基础 CRUD 的成就感又有 WebSocket 推送的“高级感”还能把数据库事务、并发控制、前端实时渲染这些面试常考的点串起来。我会从技术选型、数据库设计、后端打分逻辑、前端实时显示以及部署踩坑这几个维度把这个项目从头掰开讲一遍。2. 技术选型与项目结构为什么这套组合最不容易翻车2.1 从零开始选型Spring Boot WebSocket MySQL 是最稳的起点实时评分系统的核心链路是“评委打分 → 服务端落库 → 广播给大屏端”。针对这条链路最常见的成熟方案是 Spring Boot 做后端、WebSocket 做实时推送、MySQL 存储最终结果、前端用原生 WebSocket API 或简单的 JS 库接收消息。这套组合对毕设级别甚至小型商用场景都完全够用。为什么不推荐更重的方案比如用消息队列 Kafka 或 Redis 做实时流计算对小规模评分场景属于杀鸡用牛刀部署复杂度直线上升。评分系统的特点是并发量通常不大几十个评委同时提交、数据一致性要求高最终排名不能出错、实时性要求是秒级而非毫秒级。用 Spring Boot 自带的 WebSocket MySQL 事务就足够覆盖 99% 的需求而且源码结构清晰写进项目说明文档里也好讲。技术选型上我建议锁定三个版本约束JDK 8 或 11Spring Boot 2.xMySQL 5.7 或 8.0。这三个版本兼容性最好网上能查到的资料也最多遇到问题不容易卡住。如果你用的 JDK 17注意 Spring Boot 2.x 的部分旧依赖会有兼容性警告最好直接用 Spring Boot 2.7 版本。2.2 包结构怎么摆让答辩时讲架构不心虚很多毕设项目的源码看着头疼就是因为所有类都堆在几个包里没有分层意识。实时评分系统我建议至少拆出下面几个包这个结构既是行业惯例答辩讲起来也条理清晰com.example.ratingsystem ├── controller # 接收 HTTP 请求返回 JSON 或页面 ├── service # 业务逻辑层评分计算、校验、事务控制 ├── mapper # MyBatis 或 MyBatis-Plus 的数据访问接口 ├── entity # 数据库实体类 ├── dto # 前后端交互的数据对象 ├── config # WebSocket 配置、跨域配置 ├── websocket # WebSocket 服务端与消息处理 └── utils # 工具类三层结构Controller → Service → Mapper是这类业务系统最常用的落地方式。特别注意的一点WebSocket 的 ServerEndpoint 不是 Spring 管理的 Bean如果要在端点里注入 Service需要用静态方法或者通过 SpringContextUtil 获取这是个新手很容易踩的坑。2.3 构建项目的最小骨架从 Maven 依赖到启动类这个项目的启动类和控制层结构非常简单我直接给你看最小可用的骨架代码。先用 Maven 建立一个 Spring Boot 项目核心依赖只需要三个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency虽然只写了三个依赖但在实际操作中还需要一个持久层框架。我用 MyBatis-Plus 居多因为它的 BaseMapper 能省掉大量单表 CRUD 的 XML 代码对赶进度的项目特别友好加上它即可。启动类不需要特殊处理标准写法就够了SpringBootApplication public class RatingSystemApplication { public static void main(String[] args) { SpringApplication.run(RatingSystemApplication.class, args); } }这段代码的作用是启动内嵌的 Tomcat 容器并初始化 Spring 上下文。关键参数其实在配置文件里下面这段 application.yml 是必须仔细检查的server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/rating_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto websocket: path: /ws/score重点说下这些配置的含义URL 里的 serverTimezone 参数必须加否则高版本 MySQL 驱动会报时区错误useSSLfalse 是本地开发必须的map-underscore-to-camel-case 把数据库的下划线字段自动映射到 Java 的驼峰属性例如 create_time 对应 createTime这个设计能让实体类干净很多。路径 /ws/score 是前端连接 WebSocket 的路径前后端约定好就行。3. SQL 数据库设计评分系统的地基必须一次打对3.1 三张核心表评委、选手/节目、评分记录实时评分系统的表结构不复杂但表之间的关系设计直接决定了后面代码怎么写。我见过不少项目只建了两张表——选手表和评分表评委信息写死在代码里结果答辩时被问“评委数据怎么管理”就卡住了。正确做法是至少三张表评委表、参赛者表或者叫选手表、评分表。评委表的字段相对简单核心是 id、name、status 这三个字段。status 用来标记评委是否启用了账号如果评委只是现场临时打分也可以用 token 机制替代但在毕设项目里用简单的用户名密码登录就够了。参赛者表需要设计好展示信息字段id、name、category 用于区分组别、sort_order 用于控制上场顺序如果有图片材料还可以加一个 avatar 字段。评分表是核心中的核心它的设计决定了排行榜怎么算、能不能审计、会不会出错。我会把这条 SQL 给你看然后逐字段解释为什么这样设计CREATE TABLE score_record ( id bigint(20) NOT NULL AUTO_INCREMENT, contestant_id bigint(20) NOT NULL COMMENT 参赛者ID, judge_id bigint(20) NOT NULL COMMENT 评委ID, score decimal(4,1) NOT NULL COMMENT 评分如9.5, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 提交时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_contestant_judge (contestant_id,judge_id), KEY idx_contestant (contestant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评分记录表;这条建表语句里有三个设计决策值得反复说。第一score 字段用 decimal(4,1) 而不是 double因为浮点数计算精度会出问题比如 9.5 存进去变成 9.499999而 decimal 是精确类型算平均分时不会被浮点误差干扰。第二UNIQUE KEY 加在 (contestant_id, judge_id) 上保证同一个评委对同一个选手只能打一次分防止重复提交导致的数据错误。第三create_time 和 update_time 是标配评分审计时能追溯是哪个时刻提交的也能看出评委是不是集中在最后几分钟才打分、是否存在刷分嫌疑——这个细节写进项目说明文档里面试官会觉得你想过真实业务。3.2 初始化数据脚本别在界面上一个个点着录数据很多人做毕设的时候有个坏习惯启动项目后在页面上手动录入测试数据录完忘记导出成 SQL 文件。等到交源码时数据库里什么都没有评委拿到你的代码后根本跑不起来。正确的做法是准备两个 SQL 文件一个 schema 建表一个 data 初始化。初始化数据时记得处理两个细节。第一个是评委初始密码必须存成 MD5 或 BCrypt 加密值不能明文第二个是参赛者要覆盖各种边界情况比如有一个选手没有成绩、有一个选手分数很低这样才能测试排行榜的正确性。下面给出一个最小化的初始化数据示例INSERT INTO judge (id, username, password, real_name, status) VALUES (1, judge01, MD5(123456), 评委一, 1), (2, judge02, MD5(123456), 评委二, 1), (3, judge03, MD5(123456), 评委三, 1); INSERT INTO contestant (id, name, category, sort_order) VALUES (1, 选手A, 歌唱组, 1), (2, 选手B, 歌唱组, 2), (3, 选手C, 舞蹈组, 1);初始化数据的目的是什么第一是让项目启动后立刻能看到效果评分排行不是空的状态第二是让你调试排序、去最高最低分这些逻辑时有确定性的输入。比如三个选手的数据在手你可以在心里先算好最终排名再对比页面上显示的结果确认逻辑没写错。我不建议在初始化数据时插入评分记录。让评委账号在项目运行后真正通过打分界面提交数据这样能同时验证 WebSocket 广播和排行榜计算两条链路是不是都正常。3.3 配置数据库连接本地环境最容易卡住的三个问题数据库连接配置看起来只是填个 URL但实际坑非常多。常遇到的情况有三个端口没对上、时区报错、权限不足。时区问题在数据库连接串里已经加了 serverTimezone一般能解决。但如果还是报错多半是 MySQL 服务没启动或者驱动版本和数据库版本不匹配。权限问题通常是这样你用的是 root 账号但 MySQL 版本较高默认可能启用了 caching_sha2_password 认证插件而 MySQL Connector/J 5.1.x 不支持这个插件。解决方式有两个一是把驱动版本升到 8.0.x推荐二是在 MySQL 里执行 ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY password; 改认证方式。我建议直接用新版驱动兼容性和性能都更好不用在兼容层折腾。4. 后端实时评分逻辑并发写入与排行计算的实现4.1 打分配置规则去掉最高最低分是硬需求不是可选项实时评分系统往往不只是算平均分。最常见的业务规则是去掉一个最高分、去掉一个最低分再算剩余分数的平均值因为这样能防止个别评委给极端分数影响总排名。虽然这个规则很常见但实现时如果不加小心会造成算出来的平均分是错的。如果你在 Controller 里写一大段业务逻辑直接在接口层做去最高最低求平均的操作会带来两个问题。第一是 Controller 变得臃肿第二是如果后面改成“去掉两个最高分”要改动的地方分散在多个方法里。正确做法是把这套规则收敛到 Service 层的一个方法里统一处理。我把核心逻辑写出来public ScoreResult calculateFinalScore(Long contestantId) { ListBigDecimal scores scoreMapper.findScoresByContestant(contestantId); if (scores.isEmpty()) { return ScoreResult.empty(contestantId); } ListBigDecimal sorted scores.stream() .sorted() .collect(Collectors.toList()); if (sorted.size() 3) { sorted.remove(0); // 去掉最低分 sorted.remove(sorted.size() - 1); // 去掉最高分 } BigDecimal sum BigDecimal.ZERO; for (BigDecimal s : sorted) { sum sum.add(s); } BigDecimal avg sum.divide(BigDecimal.valueOf(sorted.size()), 2, RoundingMode.HALF_UP); return new ScoreResult(contestantId, avg, sorted.size()); }这里一个关键点是先排序再移除而不是直接操作原始列表不然你不知道哪个分数是最高、哪个是最低。另一个容易被忽略的细节是当评分人数只有 1 或 2 个时就不该去极值直接取均值就行这个逻辑我用 if (sorted.size() 3) 做了保护。用 BigDecimal 做除法时必须指定保留小数位数和舍入规则否则会抛 ArithmeticException这个错误信息并不直观很容易让人卡半天。4.2 WebSocket 实时推送从后端把最新分数推到每个浏览器评分系统同步到前端的方式有很多种最传统的是前端轮询接口比如每 3 秒请求一次排行榜。但轮询有两个问题实时性会打折扣最多延迟 3 秒而且大屏端频繁刷接口对服务器有一定压力。WebSocket 是长连接服务端一旦有新评分入库可以立刻推送到所有连接中的前端实现真正意义上的实时更新。Spring Boot 实现 WebSocket 有两种主流方式一种是基于原生 ServerEndpoint 注解另一种是基于 Spring 的 WebSocketHandler。我在实时评分项目里用原生注解方式居多因为代码简单直接每来一个连接就创建一个端点实例消息收发都在实例里。下面是服务端推送的核心实现Component ServerEndpoint(/ws/score) public class ScoreWebSocketEndpoint { private static CopyOnWriteArraySetScoreWebSocketEndpoint clients new CopyOnWriteArraySet(); private Session session; OnOpen public void onOpen(Session session) { this.session session; clients.add(this); System.out.println(新连接接入当前在线人数 clients.size()); } OnClose public void onClose() { clients.remove(this); System.out.println(连接断开当前在线人数 clients.size()); } OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } public static void broadcast(String message) { for (ScoreWebSocketEndpoint client : clients) { try { synchronized (client.session) { client.session.getBasicRemote().sendText(message); } } catch (IOException e) { e.printStackTrace(); } } } }这个实现里有两个细节必须注意。第一个是用了 CopyOnWriteArraySet 来存客户端会话集合因为 WebSocket 的广播是并发操作多个评委同时打分时可能在多个线程里同时调用 broadcast普通 ArrayList 会出现并发修改异常。第二个是广播时给 session 加了 synchronized 锁虽然单个 session 的并发发消息概率极低但加了锁能防止 Tomcat 底层对同一连接并发写带来的异常。ServerEndpoint 类在 Spring Boot 里有一个众所周知的坑它由 WebSocket 容器管理不是 Spring 的 Bean所以不能直接在端点类里 Autowired 注入 Service。常规做法是写一个工具类去获取 Spring 上下文中的 Bean或者在 onOpen 时把处理逻辑委托给外部 Spring Bean。代码实现很直接写一个 SpringContextUtil 静态方法即可。4.3 打分提交接口事务、校验、广播三个动作一个都不能少评分接口是这个系统最核心的对外入口它的逻辑可以拆成三块先校验数据合法性再写入数据库开启事务最后广播新榜单给所有客户端。这三块顺序不能乱尤其是广播必须放在事务提交之后否则前端收到的数据如果你事务回滚了就会拿到脏数据。接口代码用伪代码方式展示整体逻辑PostMapping(/api/score/submit) public Result submit(RequestBody ScoreSubmitDTO dto) { // 参数校验分数范围、评委是否存在、选手是否存在 ValidateUtil.checkScore(dto.getScore()); // 0~10 分小数点后一位 // 业务层执行校验唯一键 插入/更新评分 ratingService.submitScore(dto); // 计算最新排行并广播 ListRankItemDTO ranking ratingService.getRanking(); ScoreWebSocketEndpoint.broadcast(JSON.toJSONString(ranking)); return Result.success(); }注意一个取舍问题是“每个评委只能打一次分”还是“打了还能改”两种需求的实现方式不同。前者是插入依赖 score_record 表上的唯一键重复打分直接报错给提示“您已打分”后者是 upsert 逻辑如果 (contestant_id, judge_id) 已存在就更新分数。毕设项目通常用“不可修改”的方式更好讲也能引出唯一索引这个知识点。业务层 submitScore 的具体实现里要标注事务边界Transactional(rollbackFor Exception.class) public void submitScore(ScoreSubmitDTO dto) { ScoreRecord record new ScoreRecord(); record.setContestantId(dto.getContestantId()); record.setJudgeId(dto.getJudgeId()); record.setScore(dto.getScore()); // insert: 若唯一键冲突则抛 DuplicateKeyException scoreMapper.insert(record); // 注意此时事务尚未提交 }Transactional 保证 insert 和后续的排行榜读取读到的数据一致。如果没有事务包裹极端情况下广播出去的排行榜还没包含刚插入的分数前端就会在短暂时间内看到旧排行这是“假实时”的典型病例。4.4 排行榜计算SQL 聚合 vs Java 内存计算怎么选实时评分系统在展示排行榜时有两条路一条是每次广播前在 Java 代码里把所有选手的分数据加载到内存按选手分组后计算平均分再排序另一条是写一条 SQL 语句用 GROUP BY 加函数完成聚合。两条路各有适用场景我一般推荐小数据量用 SQL 聚合因为代码量最少而且性能足够。SQL 计算排行榜是直接让数据库做分组聚合省了 Java 层的数据搬运SELECT c.id AS contestant_id, c.name AS contestant_name, COUNT(s.id) AS score_count, ROUND(AVG(s.score), 2) AS avg_score FROM contestant c LEFT JOIN score_record s ON c.id s.contestant_id GROUP BY c.id, c.name ORDER BY avg_score DESC, c.sort_order ASC;这条 SQL 的细节是 LEFT JOIN不是 INNER JOIN。用 INNER JOIN 的话没有人打分的选手会直接消失这样排行榜上缺了人大屏显示不完整。LEFT JOIN 能让未评分的选手也出现在榜单上平均分显示 NULL。当然你在 Java 端展示时可以把 NULL 平均分显示成“暂无评分”。当评分数据量特别大例如一场比赛有几百个选手和几十个评委SQL 每次实时计算排行榜对数据库会有一定压力。此时可以换成 Java 内存计算加本地缓存但我必须提醒一句毕设项目完全没必要上升到这个复杂度。SQL 聚合方案在百万级评分记录下也有良好表现加个索引就够支撑了不要在项目里过早优化。排序规则上很多项目直接用 avg_score 降序遇到同分时没有二级排序于是同名次的先后顺序成了乱的。我加的 c.sort_order ASC 就是解决这个问题当平均分一样比较选手的上场顺序让排序结果稳定可复现。5. 前端展示与联调把后端分数变成大屏上跳动的数字5.1 前端页面用什么技术原生 WebSocket 就够了不需要上框架实时评分系统的前端有两个角色视角评委端打分和管理端/大屏端看排行。评委端要的是一个简洁的表单选择选手、拖动或输入分数、点提交。大屏端要的是一个自动更新的排行榜。两个页面都不复杂不要让技术栈成为负担。做毕设项目时如果还没学过 Vue 或 React直接用原生 HTML JavaScript CSS 就足够。WebSocket 在浏览器端有原生 API不需要引入任何第三方库代码量非常小const ws new WebSocket(ws://localhost:8080/ws/score); ws.onmessage function(event) { const ranking JSON.parse(event.data); renderRanking(ranking); }; ws.onopen function() { console.log(WebSocket 连接已建立); }; ws.onclose function() { console.log(连接断开尝试重连); // 生产环境需要重连机制毕设可以手动刷新页面 };这段代码的核心点在 onmessage 回调里拿到的是服务端推送的排行榜 JSON 字符串直接传给 renderRanking 函数去渲染表格。你的前端的核心逻辑就两个一个发起 WebSocket 连接另一个根据消息更新 DOM。这里要特意提醒一个细节WebSocket 的 URL 里路径要跟后端 ServerEndpoint(/ws/score) 的路径一致端口也要一致。如果你把页面放在 8080 端口接口也在 8080那 URL 可以直接用相对路径避免在页面上写死 IP 和端口导致换环境就失效。5.2 评委打分页与 WebSocket 的协作注意手动刷新引发的重复提交评委端页面的核心交互就是一个表单提交。我需要提醒的是提交按钮在前端要有防重复点击逻辑否则连点两下就会发两次 HTTP 请求如果是“每个人只能打一次分”的模式后端会因为唯一键冲突给第二条请求报错。前端防护代码很简单用 once 标记来锁住按钮let submitted false; function submitScore(contestantId, score) { if (submitted) { alert(您已完成评分不可重复提交); return; } submitted true; fetch(/api/score/submit, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({contestantId: contestantId, score: score}) }) .then(res res.json()) .then(data { if (data.code 200) { // 评分成功WebSocket 会自动刷新大屏 } else { submitted false; // 失败则允许重新提交 alert(data.message); } }); }注意这段代码里的时序submittedtrue 放在 fetch 之前而不是 then 之后是为了防止用户在请求还在路上的时候再点一次。失败时把 submitted 恢复为 false这样网络异常时用户还能重试。这个“前端防重复后端唯一键兜底”的双保险思路是实际项目中很重要的一层防护思路。5.3 跨域与端口联调开发时最容易翻车的三个问题前后端联调时最常遇到的三个坑我一个个说清楚。第一个是跨域问题。如果你直接用浏览器打开 HTML 文件file:// 协议而不是通过 Nginx 或 Spring Boot 静态资源访问页面地址是比如 file:///D:/rating/index.html调用 localhost:8080 的接口时浏览器会拦截跨域请求。最简单的解法是把你的静态页面放进 Spring Boot 的 static 目录里或者用 VSCode 的 Live Server 插件启动一个本地服务。如果必须跨域后端需要加全局跨域配置代码我建议放进来Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:5500) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true); } }第二个坑是 WebSocket 的跨域。HTTP 接口的跨域配置解决不了 WebSocket 连接被拒绝的问题因为 WebSocket 握手遵循的是另一套跨域规则。如果你的前端不在后端同源下ServerEndpoint 需要在握手阶段处理 Origin 校验或者直接在主配置里允许。最简单有效的方式还是在开发阶段保持同源。第三个坑是端口占用。8080 被其他进程占用时启动报错信息是“Port already in use”。排查方式是 netstat -ano 查端口对应的 PID结束掉冲突进程或改 server.port。第一次遇到这个报错时可能会以为项目代码写错了先查端口再查代码能省很多时间。5.4 大屏端的历史榜单要不要加一个“回放”功能大屏端除了显示实时的总排名比赛现场流程里还有两个常用页面当前选手的成绩展示刚打完分立即显示、历史排名回放存入数据库后可以按时间点查看变化轨迹。回放功能听起来复杂但在该项目底子上加其实很简单score_record 表里每行都有 create_time排行榜是历史数据的实时聚合。你要做一个“某时间点的榜单”就是在 SQL 里加一个 WHERE create_time 目标时间其余聚合逻辑完全相同。这样评委、观众可以在比赛结束后复盘“第三名是哪一轮追上来的”。这个功能虽然不是刚需但放进项目说明文档里是一个很好的加分亮点包括代码注释里都能体现你有完整业务思维。6. 实时评分系统避坑指南现象、原因、解决6.1 WebSocket 连不上控制台一直报 404现象前端 new WebSocket 连接后浏览器控制台显示 404连接失败onerror 被触发。原因最常见的是 WebSocket 路径与后端配置不一致。Spring Boot 的 ServerEndpoint(/ws/score) 注册的是应用上下文根路径下的 /ws/score如果你的 server.servlet.context-path 设置了 /rating那实际 WebSocket 路径就变成了 /rating/ws/score。另一个常见原因是启动类上没有加 EnableWebSocket 注解用原生 ServerEndpoint 时Spring Boot 需要额外启用 WebSocket 支持。解决先检查 context-path 配置再检查启动类是否加了 EnableWebSocket。如果你用的是原生注解方式还需要确认 pom 里引入了 spring-boot-starter-websocket并且没有把 server.servlet.context-path 设置为一个意想不到的值。建议用控制台日志里的实际路径去对齐前端连接地址。6.2 广播的数据所有人看到的都一样但评委端不该收到排名现象大屏端和评委端都连了同一个 WebSocket 地址大屏上的榜单更新了但评委自己正在操作的页面上也弹出榜单刷新框体验很怪。原因广播逻辑没有区分会话角色。所有连接到 /ws/score 的端都进入了同一个广播列表发送的消息一模一样。解决合理的架构是评委端走 HTTP 打分大屏端和管理端走 WebSocket 收榜。如果确实需要评委端也连 WebSocket就在握手时通过 query string 传 role 参数服务端根据 role 分组存储会话广播时只发给 rolescreen 的会话。代码改法是在 OnOpen 里解析 query 参数创建两个 ConcurrentHashMap 分别存两组会话集合。6.3 排行榜平均分不对decimal 类型和舍入规则出了问题现象三名评委打分 9.5、9.5、9.0页面显示平均分 9.33但自己用计算器算是 9.33 四舍五入到两位应该也对。换成 9.5、9.6、9.4页面显示 9.50而期望值是 9.50 但后面又跳成 9.49数据变得不可靠。原因这是浮点数精度问题导致的。但如果分数用 decimal 存储一般不会出现真正出问题的地方是 Java 端除法用了 double 类型或 BigDecimal.divide 没指定舍入模式导致异常。float 和 double 在二进制表示下无法精确表示 9.5、9.6 这类十进制小数运算会产生微小误差。解决数据库层一律用 decimal(4,1)Java 层用 BigDecimal 存储和参与运算除法时指定 MathContext 或 RoundingMode.HALF_UP。这条规则非常关键任何涉及到钱的或者分数的系统永远不要用 double 做精确运算。我在每个查询排行榜的 SQL 里用 ROUND(AVG(s.score), 2)对应的 Java 接收时也转成 BigDecimal 展示。6.4 排行榜刷新慢广播风暴和数据量增长超出预期现象评分提交后大屏上的排行榜要好几秒才能刷新看着不是“实时”的样子。原因可能有两个。第一广播是同步执行的broadcast 方法在循环里逐个 session 发送网络慢或某个前端断线了服务端会卡在 I/O 上第二每次广播都实时查了一遍排行榜全量数据如果 SQL 是 LEFT JOIN 全表聚合数据量大了之后单次查询耗时增多。解决第一个问题是对每个 session 的发送用异步方式或线程池单个 session 发送失败时跳过不要阻塞后续广播。第二个问题是排行榜 SQL 加索引score_record 表的 (contestant_id, score) 联合索引会有明显效果。如果数据量上万条可以给榜单做 5 秒级缓存让数据库查询频率控制在可接受范围内但要注意缓存失效时机至少要比大屏刷新早一拍。毕设场景里做好这两步就够了。6.5 重启项目后 WebSocket 大量断开没有心跳机制现象项目运行一段时间后前端的 WebSocket 连接静默断开页面不再更新但控制台没有任何报错。原因大屏端长时间没有消息交互中间的网络设备或 Tomcat 本身空闲超时会主动杀掉连接客户端又没有心跳检测无法感知连接已死。解决最简单的方案是前端定时发送 ping 消息保持活跃后端收到后自动回 pong。更稳妥的方案是在前端 onclose 事件里加上自动重连逻辑重连间隔 3 秒、最多重连 5 次避免死循环。这段前端重连代码在实战里非常实用想接大屏展示的项目一般都会用到let retryCount 0; function connectWebSocket() { const ws new WebSocket(ws://localhost:8080/ws/score); ws.onmessage ...; ws.onclose function() { if (retryCount 5) { retryCount; setTimeout(connectWebSocket, 3000); } }; ws.onopen function() { retryCount 0; }; }6.6 项目说明文档写什么别只贴代码截图这个标题里“项目说明文档”和源码、SQL 数据库是并重的交付物但很多人的文档只是把代码粘贴一遍截图堆了一堆该讲清楚的内容反而没有。我有一次收源码时看到文档只有启动步骤、代码结构截图和几张效果图评委根本不知道你的系统有哪些设计决策。项目说明文档至少要包含以下内容需求分析角色定义、用例场景、数据库设计说明三张表的关系、为什么唯一键要这样加、核心接口列表打分、排行榜、登录、WebSocket 消息协议格式、部署运行步骤JDK 版本、MySQL 启动、导入 SQL、修改配置、启动应用以及你做的关键设计决策的记录。这些内容在答辩或面试时就是你的思维过程比代码本身更能体现出你的工程能力。后面写文档时按这个结构来写基本不会跑偏。7. 让系统更像“作品”的进阶技巧三个低成本高感知的改动第一个技巧是给排行榜加“实时变化标记”。让前端在收到 WebSocket 推送时对比上一次排名的数据如果某位选手排名上升就在名字旁边加一个上升箭头下降则用下降箭头。实现思路很简单前端保存上一次榜单快照在 renderRanking 里比对名次差。这个视觉细节会让大屏看起来真的有“实时感”而不是一个数字在刷新。改动只需要 20 行 JS但对答辩演示的冲击力非常明显。第二个技巧是评委端加入“暂存草稿”功能。评委在打分时可以填了分数不提交下次点开还在这样现场操作时不会因为手误清空。做法可以把草稿保存在 LocalStorage 里按 contestantId 做 key 存储。这种细节会让你的系统显得更贴近真实业务评委有很多次打分会来回改而不是一次成型。第三个技巧是给项目加上一个最简单的“评分状态开关”。比赛开始前排行榜大屏不公开只有管理员能看到比赛结束后再开放排行榜。实现方式是在配置表里加一个 status 字段管理端接口可以修改拉榜单时根据当前状态返回不同结果。这个功能在说明文档里可以写成一个“权限控制”用例展示的不只是 CRUD而是业务权限理解。最后说一个我自己的习惯每次改完一个像 WebSocket 推送这样的异步逻辑我都会打开两个浏览器窗口一个当评委端一个当大屏端亲手点一遍完整流程而不是只看接口返回成功就以为完工了。同一个 session 发送消息并发的原理、事务提交和广播的先后顺序这些只有在手动操作时才能真正注意到。这个动作帮我避免了无数次现场演示时才暴露的翻车问题希望你也能养成这个习惯希望帮到你。本文还有配套的精品资源点击获取