
很多音游店赛组织者都经历过这样的场景一场舞萌比赛三四十位选手每一轮歌曲不同、难度不同达成率和结算分数全靠在纸质表格上手记最后算总积分、排名次的时候要反复核对一个数抄错影响一整条分数链。本文就从“纱露朵娃娃”这个有点玩梗的项目代号说起完整拆解一套舞萌比赛积分管理系统的开发过程。项目后端采用 Spring Boot MyBatis-Plus数据库用 MySQL前端使用 Vue CDN 原生 HTML整体结构适合作为中等规模实战项目参考。读完你可以掌握一套完整的成绩录入、积分换算、排行榜展示实现方案也能直接把它改造为门店赛、积分联赛或社团赛的管理工具。需要先说明一点本文内容只做赛后成绩录入和管理不涉及机台数据破解、外挂辅助或任何违规抓取。所有比赛数据都来自人工登记或主办方结算表这一点在开发中也要格外注意边界。1. 背景与项目定位1.1 舞萌比赛管理中的真实痛点舞萌maimai是世嘉推出的街机音乐游戏玩家根据屏幕上的箭头提示通过在机台两侧的按键和触摸屏区域完成击打。国内许多机厅会定期举办门店赛或区域积分赛赛事流程通常是选手报名、分组、多轮歌曲比拼、按达成率折算积分最后累积积分排名。比赛过程中最大的压力其实在裁判和记分员身上。每名选手打完一轮后需要记录歌曲名、难度、达成率、结算分数还要判断是否 Clear。如果赛事采用“积分制”主办方还要按达成率区间给选手换算积分再根据歌曲难度进行加权。一旦选手较多手工表格就很容易出现漏记、错记、上传缓慢的问题。这个问题用一套 Web 系统就能解决。选手信息维护在系统里成绩只需填达成率和分数积分计算交给后端服务排名自动生成。这样主办方在比赛间隙就能看到当前排名也方便赛后在玩家群里分享结果。1.2 “纱露朵娃娃”项目定位看到“纱露朵娃娃”这个名字可能有读者会好奇它到底是什么。它不是真实玩家也不是什么官方吉祥物只是机厅里一只常年摆在机台上的布偶娃娃。玩家开玩笑说它是全场唯一一个不用动手、不需要搓键、却每天看着所有人打舞萌比赛的选手。本文借用这个梗作为项目代号原因也很简单一个赛事管理工具本来就不需要“亲自上场”它只需要安静地记录每一场成绩像那只娃娃一样坐在角落里看着比赛发生。项目代号不需要严肃但功能设计一定要认真。这套系统的定位不是大型赛事平台而是一个轻量、可自托管的小工具。它不需要用户注册不需要 OAuth不需要复杂的权限体系满足单机厅、单管理员的小规模赛事场景即可。如果后续要接入多门店、多赛事、多角色权限可以在现有结构上继续扩展。1.3 功能清单第一版系统包含以下核心模块模块功能说明选手管理新增选手、列表查询、按昵称搜索比赛管理创建比赛、查询比赛列表成绩录入录入选手某场比赛的成绩包括歌曲、难度、达成率、分数积分计算根据达成率区间与歌曲难度自动计算本场积分排行榜按比赛查看当前选手累计积分排名数据导出按比赛导出积分表方便赛后存档从业务规则来看本系统使用一种简化的评分方式先按达成率区间得到基础积分再乘上歌曲难度系数。实际赛事规则可能更复杂但核心计算逻辑可以替换不影响整体架构。2. 环境准备与数据库设计2.1 开发环境与版本约定下面以 Spring Boot 2.7.x JDK 8/11 MySQL 8.x MyBatis-Plus 3.5.x 为例进行讲解。如果你使用的是 Spring Boot 3.x需要注意javax包名已经迁移到jakarta部分配置项也发生了变化。完整环境清单如下JDK1.8 或 11构建工具Maven 3.6开发框架Spring Boot 2.7.x持久层MyBatis-Plus 3.5.x数据库MySQL 5.7 或 8.x数据库连接池Spring Boot 默认的 HikariCP前端Vue 2 CDN Axios CDN单页 HTMLIDEIntelliJ IDEA 或 VS Code版本号不一定使用最新版关键是 Spring Boot、MyBatis-Plus、MySQL 驱动三者之间的兼容性。建议先建立一个最小工程确认依赖能正常下载后再往下开发。2.2 数据库设计数据库名建议为maimai_score字符集使用utf8mb4可以用来存储玩家昵称中的特殊字符和 emoji。下面先看三张核心表的字段设计。第一张表是选手表记录参赛选手基本信息。字段类型说明idBIGINT主键自增nicknameVARCHAR(64)玩家昵称real_nameVARCHAR(64)真实姓名或备注可空phoneVARCHAR(20)联系电话可空player_levelVARCHAR(20)玩家评级如 SS、S 等可空create_timeDATETIME创建时间第二张表是比赛表记录一场比赛的基本信息和状态。字段类型说明idBIGINT主键自增event_nameVARCHAR(128)比赛名称event_dateDATE比赛日期event_typeVARCHAR(20)比赛类型如单赛、积分赛statusTINYINT0 未开始1 进行中2 已结束create_timeDATETIME创建时间第三张表是成绩记录表记录选手每一轮的具体成绩。字段类型说明idBIGINT主键自增match_idBIGINT比赛 IDplayer_idBIGINT选手 IDsong_nameVARCHAR(128)歌曲名song_levelINT歌曲难度等级achievement_rateDECIMAL(5,2)达成率例如 98.50song_scoreINT结算分数basic_pointsINT基础积分total_pointsDECIMAL(8,2)加权后总积分create_timeDATETIME录入时间字段basic_points和total_points可以冗余存储这样排行榜查询时不需要每次都重新计算积分。如果赛事规则调整重新跑一遍历史成绩即可。2.3 建表 SQL下面是完整的建表脚本可以直接在 MySQL 中执行。CREATE DATABASE IF NOT EXISTS maimai_score DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE maimai_score; CREATE TABLE player ( id BIGINT AUTO_INCREMENT PRIMARY KEY, nickname VARCHAR(64) NOT NULL COMMENT 玩家昵称, real_name VARCHAR(64) DEFAULT NULL COMMENT 真实姓名或备注, phone VARCHAR(20) DEFAULT NULL COMMENT 联系电话, player_level VARCHAR(20) DEFAULT NULL COMMENT 玩家评级, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, UNIQUE KEY uk_nickname (nickname) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选手表; CREATE TABLE match_event ( id BIGINT AUTO_INCREMENT PRIMARY KEY, event_name VARCHAR(128) NOT NULL COMMENT 比赛名称, event_date DATE NOT NULL COMMENT 比赛日期, event_type VARCHAR(20) DEFAULT SINGLE COMMENT 比赛类型, status TINYINT NOT NULL DEFAULT 1 COMMENT 0未开始 1进行中 2已结束, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT比赛表; CREATE TABLE match_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, match_id BIGINT NOT NULL COMMENT 比赛ID, player_id BIGINT NOT NULL COMMENT 选手ID, song_name VARCHAR(128) NOT NULL COMMENT 歌曲名, song_level INT NOT NULL COMMENT 歌曲难度等级, achievement_rate DECIMAL(5,2) NOT NULL COMMENT 达成率, song_score INT NOT NULL COMMENT 结算分数, basic_points INT NOT NULL DEFAULT 0 COMMENT 基础积分, total_points DECIMAL(8,2) NOT NULL DEFAULT 0 COMMENT 加权后总积分, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 录入时间, KEY idx_match_id (match_id), KEY idx_player_id (player_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT比赛成绩记录表;player表的昵称字段建议加唯一索引防止同一个人被重复录入。真实赛事中同名情况比较常见如果担心误判也可以去掉唯一索引改为录入时前端搜索确认。3. 后端接口开发3.1 创建 Spring Boot 工程与依赖使用 IDEA 的 Spring Initializr 创建工程Group 可以填com.exampleArtifact 填maimai-score。生成后修改pom.xml加入 MyBatis-Plus 和 MySQL 驱动依赖。?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdmaimai-score/artifactId version1.0.0-SNAPSHOT/version namemaimai-score/name description舞萌比赛积分管理系统/description properties java.version1.8/java.version mybatis-plus.version3.5.3/mybatis-plus.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version${mybatis-plus.version}/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project注意事项Spring Boot 2.7.x 默认使用mysql-connector-java如果升级到 Spring Boot 3.x驱动坐标会变化使用com.mysql:mysql-connector-j。MyBatis-Plus 3.5.x 对较高版本 Spring Boot 的兼容性需要确认遇到报错优先调整版本号。3.2 配置文件在src/main/resources/application.yml中配置数据源和 MyBatis-Plus。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/maimai_score?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: root hikari: maximum-pool-size: 10 minimum-idle: 2 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: automap-underscore-to-camel-case会自动把数据库字段achievement_rate映射为 Java 属性achievementRate减少手写 ResultMap 的麻烦。log-impl配置会在控制台输出 SQL便于调试生产环境可以移除。3.3 实体类与 Mapper在com.example.maimai.entity包下创建三个实体类。这里以MatchRecord为例其他两个类结构类似。package com.example.maimai.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.math.BigDecimal; import java.time.LocalDateTime; TableName(match_record) public class MatchRecord { TableId(type IdType.AUTO) private Long id; private Long matchId; private Long playerId; private String songName; private Integer songLevel; private BigDecimal achievementRate; private Integer songScore; private Integer basicPoints; private BigDecimal totalPoints; private LocalDateTime createTime; // getter / setter 省略 }在 Java 8 环境下LocalDateTime与 MySQL 的DATETIME可以正常映射。注意实体类属性命名要遵循驼峰规则否则需要额外配置。Mapper 接口非常简单继承BaseMapper即可获得基础的增删改查能力。代码如下package com.example.maimai.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.maimai.entity.MatchRecord; import org.apache.ibatis.annotations.Mapper; Mapper public interface MatchRecordMapper extends BaseMapperMatchRecord { }PlayerMapper和MatchEventMapper写法相同只是泛型参数不同。MyBatis-Plus 的BaseMapper已经包含insert、selectById、selectList、updateById等常用方法小规模项目几乎不需要写 XML SQL。3.4 积分计算服务积分计算是这套系统的业务核心。这里使用一个简化规则达成率低于 70%0 分达成率 70%含到 80%1 分达成率 80%含到 90%2 分达成率 90%含到 94%3 分达成率 94%含到 97%4 分达成率 97%含以上5 分基础积分算完后再乘上歌曲难度等级系数。这里以歌曲等级 10 级作为基准等级每高 1 级系数增加 0.05上限为 1.3。也就是说一首 13 级歌曲拿到 5 分基础积分后最终积分是 5 × 1.15。package com.example.maimai.service; import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.math.RoundingMode; Service public class ScoreCalculator { public int calculateBasicPoints(BigDecimal achievementRate) { if (achievementRate null) { return 0; } double rate achievementRate.doubleValue(); if (rate 70) { return 0; } else if (rate 80) { return 1; } else if (rate 90) { return 2; } else if (rate 94) { return 3; } else if (rate 97) { return 4; } else { return 5; } } public BigDecimal calculateTotalPoints(int basicPoints, int songLevel) { double coefficient 1.0 Math.max(0, songLevel - 10) * 0.05; if (coefficient 1.3) { coefficient 1.3; } BigDecimal ratePoint BigDecimal.valueOf(basicPoints * coefficient); return ratePoint.setScale(2, RoundingMode.HALF_UP); } }这种设计的好处是业务规则和控制器分离。如果赛事主办方调整了积分规则只需要修改ScoreCalculator接口层不用动。3.5 业务层与控制器设计一个成绩录入的 DTO接收前端提交的数据。package com.example.maimai.dto; import javax.validation.constraints.DecimalMax; import javax.validation.constraints.DecimalMin; import javax.validation.constraints.NotNull; import java.math.BigDecimal; public class RecordSaveDTO { NotNull(message 比赛ID不能为空) private Long matchId; NotNull(message 选手ID不能为空) private Long playerId; NotNull(message 歌曲名不能为空) private String songName; NotNull(message 歌曲等级不能为空) private Integer songLevel; NotNull(message 达成率不能为空) DecimalMin(value 0.00, message 达成率不能小于0) DecimalMax(value 101.00, message 达成率不能大于101) private BigDecimal achievementRate; NotNull(message 结算分数不能为空) private Integer songScore; // getter / setter 省略 }业务层MatchRecordService负责保存成绩并在保存前调用积分计算服务package com.example.maimai.service; import com.example.maimai.dto.RecordSaveDTO; import com.example.maimai.entity.MatchRecord; import com.example.maimai.mapper.MatchRecordMapper; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.math.BigDecimal; import java.time.LocalDateTime; Service public class MatchRecordService { Resource private MatchRecordMapper matchRecordMapper; Resource private ScoreCalculator scoreCalculator; public MatchRecord saveRecord(RecordSaveDTO dto) { MatchRecord record new MatchRecord(); BeanUtils.copyProperties(dto, record); int basicPoints scoreCalculator.calculateBasicPoints(dto.getAchievementRate()); BigDecimal totalPoints scoreCalculator.calculateTotalPoints(basicPoints, dto.getSongLevel()); record.setBasicPoints(basicPoints); record.setTotalPoints(totalPoints); record.setCreateTime(LocalDateTime.now()); matchRecordMapper.insert(record); return record; } }控制器层提供三个核心接口新增选手、创建比赛、录入成绩并查询排行榜。package com.example.maimai.controller; import com.example.maimai.dto.RecordSaveDTO; import com.example.maimai.entity.MatchEvent; import com.example.maimai.entity.MatchRecord; import com.example.maimai.entity.Player; import com.example.maimai.mapper.MatchEventMapper; import com.example.maimai.mapper.MatchRecordMapper; import com.example.maimai.mapper.PlayerMapper; import com.example.maimai.service.MatchRecordService; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import java.util.List; import java.util.Map; RestController RequestMapping(/api) public class MatchController { Resource private PlayerMapper playerMapper; Resource private MatchEventMapper matchEventMapper; Resource private MatchRecordMapper matchRecordMapper; Resource private MatchRecordService matchRecordService; PostMapping(/player) public Player createPlayer(RequestBody Player player) { playerMapper.insert(player); return player; } GetMapping(/player) public ListPlayer listPlayer(RequestParam(required false) String keyword) { if (keyword null || keyword.trim().isEmpty()) { return playerMapper.selectList(null); } return playerMapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapperPlayer() .like(nickname, keyword)); } PostMapping(/match) public MatchEvent createMatch(RequestBody MatchEvent matchEvent) { matchEventMapper.insert(matchEvent); return matchEvent; } PostMapping(/record) public MatchRecord addRecord(RequestBody RecordSaveDTO dto) { return matchRecordService.saveRecord(dto); } }上面的接口没有对参数做复杂校验也不包含统一异常处理。为了节省篇幅示例刻意保持精简。实际项目中建议使用Valid注解统一校验并用RestControllerAdvice捕获异常。搭配一个排行榜查询接口。这里不直接写 SQL而是先把某场比赛的所有记录查出来在内存中聚合。因为单场比赛的数据量通常只有几百条性能没有问题。GetMapping(/record/ranking) public ListMapString, Object ranking(RequestParam Long matchId) { ListMatchRecord records matchRecordMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.QueryWrapperMatchRecord() .eq(match_id, matchId) .orderByDesc(total_points) ); return records.stream().map(record - { MapString, Object row new java.util.HashMap(); row.put(recordId, record.getId()); row.put(songName, record.getSongName()); row.put(songLevel, record.getSongLevel()); row.put(achievementRate, record.getAchievementRate()); row.put(songScore, record.getSongScore()); row.put(totalPoints, record.getTotalPoints()); return row; }).collect(java.util.stream.Collectors.toList()); }严格来说这个接口返回的是“单轮成绩按分数排序”不是跨歌曲累计成绩。如果要按选手聚合累计积分需要按playerId分组求和。可以在 Service 中增加一个aggregateByPlayer方法public ListMapString, Object aggregateRanking(Long matchId) { ListMatchRecord records matchRecordMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.QueryWrapperMatchRecord() .eq(match_id, matchId) ); MapLong, BigDecimal playerTotalMap new java.util.HashMap(); MapLong, Integer playerCountMap new java.util.HashMap(); for (MatchRecord record : records) { playerTotalMap.merge(record.getPlayerId(), record.getTotalPoints(), BigDecimal::add); playerCountMap.merge(record.getPlayerId(), 1, Integer::sum); } return playerTotalMap.entrySet().stream() .sorted(Map.Entry.Long, BigDecimalcomparingByValue().reversed()) .map(entry - { Long playerId entry.getKey(); MapString, Object row new java.util.HashMap(); row.put(playerId, playerId); row.put(totalPoints, entry.getValue()); row.put(roundCount, playerCountMap.getOrDefault(playerId, 0)); return row; }) .collect(java.util.stream.Collectors.toList()); }这种方式不适合海量数据但作为几十人规模的比赛积分榜完全够用。如果后续要扩展到上千人可以改成 SQLGROUP BY聚合或者在积分表里增加累计字段。4. 前端页面开发4.1 页面整体架构前端不搞复杂工程直接把页面放在src/main/resources/static/index.html后端启动后访问http://localhost:8080/即可打开。页面使用 Vue 2 CDN 和 Axios CDN省去 npm 构建步骤。页面整体分为三个区域选手管理区新增选手、查询选手列表。比赛管理区创建比赛、选择当前比赛。成绩录入区选择选手、填写歌曲和达成率提交后展示排行榜。考虑到示例页面较长这里只展示关键代码结构引入 CDN 资源、创建 Vue 实例、定义初始数据和方法。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title舞萌比赛积分管理/title style body { font-family: Microsoft YaHei, Arial, sans-serif; margin: 30px auto; max-width: 1000px; background: #f7f9fc; } .card { background: #fff; border-radius: 8px; padding: 16px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); } .input { margin: 6px 8px 6px 0; padding: 8px 10px; border: 1px solid #d9d9d9; border-radius: 4px; } .btn { padding: 8px 14px; margin-right: 6px; border-radius: 4px; border: none; cursor: pointer; } .btn-primary { background: #3b82f6; color: #fff; } table { width: 100%; border-collapse: collapse; margin-top: 12px; } th, td { border: 1px solid #e5e7eb; padding: 8px; text-align: center; font-size: 14px; } th { background: #f3f4f6; } /style /head body div idapp !-- 页面内容通过 Vue 组件渲染 -- /div script srchttps://cdn.jsdelivr.net/npm/vue2.7.16/dist/vue.min.js/script script srchttps://cdn.jsdelivr.net/npm/axios1.6.8/dist/axios.min.js/script script new Vue({ el: #app, data: { player: { nickname: , realName: , phone: }, playerList: [], match: { eventName: , eventDate: , eventType: SINGLE }, matchList: [], currentMatchId: null, record: { playerId: null, songName: , songLevel: 10, achievementRate: 0, songScore: 0 }, rankingList: [] }, methods: { loadPlayers() { axios.get(/api/player).then(res { this.playerList res.data; }); }, loadMatches() { axios.get(/api/match).then(res { this.matchList res.data; if (this.matchList.length 0 !this.currentMatchId) { this.currentMatchId this.matchList[0].id; this.loadRanking(); } }); }, createPlayer() { axios.post(/api/player, this.player).then(() { alert(选手添加成功); this.player.nickname ; this.player.realName ; this.player.phone ; this.loadPlayers(); }); }, createMatch() { axios.post(/api/match, this.match).then(() { alert(比赛创建成功); this.loadMatches(); }); }, addRecord() { if (!this.currentMatchId || !this.record.playerId) { alert(请先选择比赛和选手); return; } const data { matchId: this.currentMatchId, playerId: this.record.playerId, songName: this.record.songName, songLevel: this.record.songLevel, achievementRate: this.record.achievementRate, songScore: this.record.songScore }; axios.post(/api/record, data).then(() { alert(成绩录入成功); this.record.songName ; this.record.songLevel 10; this.record.achievementRate 0; this.record.songScore 0; this.loadRanking(); }); }, loadRanking() { axios.get(/api/record/ranking, { params: { matchId: this.currentMatchId } }).then(res { this.rankingList res.data; }); } }, mounted() { this.loadPlayers(); this.loadMatches(); } }); /script /body /html这里需要补一个/api/match的 GET 接口直接返回matchEventMapper.selectList(null)即可。前端启动后选手列表、比赛列表都会自动加载。4.2 选手信息管理页面在#app内部渲染一个选手管理卡片。新增选手表单包含昵称、姓名、手机号三个输入框下面辅助一个简单的搜索框。div classcard h3选手管理/h3 input classinput placeholder选手昵称 v-modelplayer.nickname / input classinput placeholder真实姓名可选 v-modelplayer.realName / input classinput placeholder手机号可选 v-modelplayer.phone / button classbtn btn-primary clickcreatePlayer添加选手/button input classinput placeholder搜索选手昵称 v-modelsearchNickname inputsearchPlayer / table tr thID/th th昵称/th th姓名/th th手机号/th /tr tr v-foritem in playerList :keyitem.id td{{ item.id }}/td td{{ item.nickname }}/td td{{ item.realName || - }}/td td{{ item.phone || - }}/td /tr /table /div搜索功能不需要请求后端直接在前端用filter过滤即可。如果选手量很大再改成后端分页查询。4.3 比赛成绩录入与排行榜在录入成绩前先加载比赛列表并提供一个下拉选择框。div classcard h3比赛成绩录入/h3 select v-modelcurrentMatchId changeloadRanking option v-form in matchList :valuem.id{{ m.eventName }}{{ m.eventDate }}/option /select div stylemargin-top:10px; select v-modelrecord.playerId option v-forp in playerList :valuep.id{{ p.nickname }}/option /select input classinput placeholder歌曲名 v-modelrecord.songName / input classinput typenumber placeholder歌曲等级 v-model.numberrecord.songLevel / input classinput typenumber step0.01 placeholder达成率 v-model.numberrecord.achievementRate / input classinput typenumber placeholder结算分数 v-model.numberrecord.songScore / button classbtn btn-primary clickaddRecord录入成绩/button /div table tr th歌曲/th th等级/th th达成率/th th结算分/th th加权积分/th /tr tr v-forrow in rankingList :keyrow.recordId td{{ row.songName }}/td td{{ row.songLevel }}/td td{{ row.achievementRate }}/td td{{ row.songScore }}/td td{{ row.totalPoints }}/td /tr /table /div如果使用前面的aggregateRanking接口表格展示的字段要改成playerId、totalPoints、roundCount。实际开发中可以同时提供两个接口一个是单轮成绩明细一个是选手累计排名。5. 运行与验证5.1 启动后端服务在application.yml中确认数据库账号密码无误后直接在 IDEA 中运行MaimaiScoreApplication。启动成功后控制台会打印 Spring Boot 的 Banner 和端口信息。如果使用命令行启动可以执行mvn spring-boot:run启动后访问http://localhost:8080/如果能看到前端页面说明工程基本没问题。5.2 使用 curl 验证接口不依赖页面先用 curl 验证后端接口是否正常。新增一名选手curl -X POST http://localhost:8080/api/player \ -H Content-Type: application/json \ -d {nickname:纱露朵娃娃,realName:,phone:}预期返回 JSON 中包含选手 ID 和昵称{ id: 1, nickname: 纱露朵娃娃, realName: , phone: , createTime: 2025-01-01T12:00:00 }然后创建一场比赛再录入成绩curl -X POST http://localhost:8080/api/match \ -H Content-Type: application/json \ -d {eventName:福州门店积分赛,eventDate:2025-01-05,eventType:SINGLE,status:1}curl -X POST http://localhost:8080/api/record \ -H Content-Type: application/json \ -d {matchId:1,playerId:1,songName:Grievous Lady,songLevel:14,achievementRate:98.50,songScore:1010000}录入成功后totalPoints应该由后端计算出来。按照前面的规则达成率 98.5% 对应基础积分 5 分歌曲等级 14 的系数是 1.2最终积分是 6.00。5.3 页面联调验证页面加载后先添加一名选手再创建一场比赛。接着选择比赛在成绩录入区选择刚创建的选手填写歌曲名、等级、达成率、结算分点击“录入成绩”。如果一切正常下方表格会立即出现该条成绩并显示加权积分。如果点击按钮后没有反应打开浏览器开发者工具中的 Network 面板重点检查请求状态码。如果是 404检查接口路径是否写错如果是 500查看后端控制台异常栈最常见的是数据库表没建或字段映射错误。6. 常见问题与排查思路在开发这套系统的过程中有几个高频问题值得单独拿出来讲。问题现象常见原因解决思路启动时报数据库连接失败数据库未创建、账号密码错误、驱动配置不对先执行建表 SQL再检查application.yml的连接配置接口返回 404请求路径与RequestMapping不一致或前端路径拼错检查后端控制类路径对比浏览器 Network 中的实际请求地址前端页面样式错乱CDN 资源加载失败或离线环境将 Vue 和 Axios 文件下载到本地 static 目录引入中文乱码数据库字符集不是 utf8mb4或连接 URL 缺少编码参数建库时指定 utf8mb4连接 URL 增加characterEncodingutf8达成率字段插入失败前端传入字符串映射到 BigDecimal 时类型异常前端使用v-model.number后端 DTO 使用BigDecimal排行榜顺序不对前端直接使用单轮成绩明细接口没有按选手聚合改用聚合接口或按total_points倒序排列MyBatis-Plus 方法报空指针实体类没有 setter或未添加Mapper注解检查实体类 getter/setter检查 Mapper 接口注解如果出现“Could not create connection to database server”这一类错误不要急着改代码。先确认 MySQL 服务是否启动再用数据库客户端尝试连接最后才检查 Spring Boot 配置。7. 最佳实践与工程建议7.1 数据一致性与事务控制成绩录入涉及两个动作写入match_record表同时根据积分规则计算积分。如果后续接入“选手累计积分”字段还需要更新选手总积分。这时必须用事务保证一致性。在MatchRecordService的保存方法上增加Transactional注解是一个简单有效的做法。Transactional(rollbackFor Exception.class) public MatchRecord saveRecord(RecordSaveDTO dto) { // 保存成绩并计算积分 }实际赛事中成绩录入员可能会误操作把选手的达成率填错。系统层面至少要支持“修改”和“删除”接口或者提供“成绩作废”逻辑。不能只允许新增否则后期更正成本很高。7.2 权限与安全边界这套系统默认是赛事管理员使用所以没有引入登录认证。但如果部署到公网任何人都可能访问新增、删除接口。最小可落地的方案是加一层简单的管理员密码校验例如接口请求头要求携带X-Admin-Token。在 Spring Boot 中可以使用拦截器统一校验避免在每个 Controller 里重复写判断逻辑。更完整的方案是引入 Spring Security但小规模工具项目不推荐一开始就上重型安全框架。另外一个容易被忽略的点是 SQL 注入。MyBatis-Plus 的QueryWrapper在传值时会使用占位符普通查询不会产生注入风险。但如果自行拼接 SQL必须使用#{}不要使用${}。7.3 可维护性与后续扩展项目规模不大但代码结构上仍然应该分层。目前示例只展示了 Controller、Service、Mapper 三层如果继续扩展建议增加 DTO/VO 层和统一的异常响应结构。后续可以从下面几个方向继续完善增加多轮赛制支持把每轮成绩作为单独记录并提供轮次字段。增加歌曲谱面定数管理把歌曲等级和定数维护为独立表。支持从 CSV 批量导入成绩。增加选手排名变化曲线用图表展示。将前端工程化使用 Vue 3 Vite Element Plus 重构。引入登录鉴权和操作日志记录谁在什么时间修改了成绩。增加数据备份脚本比赛结束后自动导出 SQL。这套系统最值得保留的设计是把积分计算从 Controller 中剥离出来后续赛事规则调整时只需要维护ScoreCalculator和配置表而不需要改动接口和页面。类似的小型赛事工具往往就是从一张成绩记录表开始的。先把数据结构和录入流程跑通再逐步加权限、加推送、加可视化项目的复杂度就会始终处于可控范围内。