
简介基于Java开发的田径运动会管理系统专为高校课程设计及Java信息系统入门者准备覆盖运动员报名、赛事安排、成绩记录、排名展示等核心流程。资源共八十一个文件压缩包大小约二点零四兆包含二十五份源码、四十八份编译后的类文件、数据库驱动包、SQL脚本及实验报告源码可用Eclipse直接打开数据库脚本便于快速初始化。已有两千一百六十八人学习下载。系统功能模块清晰涵盖运动员管理、赛事安排、报名系统、成绩录入与处理、信息发布和权限管理配套课设报告详细阐述设计思路、系统架构与实现过程源码注释完整体现了良好的软件工程实践。通过该项目可学习图形界面设计、数据库增删改查、分层架构搭建及角色权限控制还可参考其可扩展设计来增加新的比赛项目是一份适合课程设计、项目实战及答辩准备的完整参考。1. 田径运动会管理系统一个 Java 单体项目能装下多少“脏活累活”每年校运会报名截止前体育部的老师都会在微信群里收到一堆格式混乱的 Excel等比赛当天又要拿着纸质检录单满操场找人等所有成绩录完名次和团体总分还得用计算器一个个核对。一个 java 田径运动会管理系统最直接的价值就是把这套流程从 Excel 和纸质表搬进数据库——报名、检录、成绩录入、名次判定、团体总分汇总全部走一套在线流程数据只录一遍后面所有统计都从库里出。适合的场景很明确高校、中学、企事业单位内部运动会以及需要按项目和成绩做综合管理的比赛。对 Java 开发者来说它又是一个能把面向对象建模、Spring Boot 集成、Java 排序算法和并发处理都串起来的练手项目技术深度刚好处于“能写又能讲”的位置。2. 先建模再写代码把运动会拆成五张表和三类状态2.1 数据模型怎么定运动员、项目、报名、成绩、赛程运动会管理系统和普通 CRUD 最大的区别在于“成绩”不是一个简单的增删改查它和报名资格、项目类型、比赛轮次是绑定的。我的习惯是先跑一遍流程再做表报名阶段需要知道谁报了哪个项目检录阶段要确认运动员到了没有、在第几组第几道赛后要按项目类型计算名次和得分。所以核心表一般这么分athlete 运动员表学号、姓名、性别、班级、联系方式。event 项目表项目名称、项目类型径赛还是田赛、竞赛规则取最好成绩还是取最短时间、是否团体项目、报名人数上限。enrollment 报名表运动员 ID、项目 ID、报名状态保证一个人在一个项目里只有一条有效报名。result 成绩表报名 ID、成绩值、成绩单位、比赛轮次、检录状态、是否有效成绩。sys_user 用户表管理员、录入员、裁判等角色权限。这里最容易被新手跳过的是“成绩值”的设计。径赛最终比较的是耗时田赛比较的是距离如果直接把成绩存成字符串“12:34.56”后面排序、比较大小都会出问题。我一般会用整数类型存“最小计量单位”径赛存毫秒数田赛存厘米数展示的时候再格式化。这样排名时就是纯数字比较这也是 Java 数据类型选择上最值得跟新人强调的一点。对应实体类层次是 Athlete、Event、Enrollment、Result、SysUser关系上 Enrollment 是运动员和项目之间的多对多关联表Result 与 Enrollment 一一对应。面向对象建模时不要把 Result 直接挂在 Athlete 下因为同一个运动员在不同项目里的成绩要跟着项目走挂在报名记录下才符合业务。我当时第一次做的时候把 Result 表里塞了 athlete_id 和 event_id 两个冗余外键后来发现同一个项目同一运动员有多条无效成绩时排名查询的过滤条件会越写越乱。成绩表只留 enrollment_id 一个业务外键反而清爽。2.2 用 MyBatis-Plus 根据 Java 实体类生成创建表的 SQL 语句建表这一步很多人是手写 SQL但手写 SQL 和实体类字段经常对不上尤其是后期加了字段忘了改表结构。常见做法是开发环境启动时自动比对实体类和数据库表结构不一致就自动补列。我一般会在项目里放一个 SchemaGenerator利用 MyBatis-Plus 的 TableInfoHelper 读取实体类的表名和字段映射拼出 CREATE TABLE IF NOT EXISTS 的 SQL启动时执行。Component public class SchemaGenerator implements ApplicationRunner { private final JdbcTemplate jdbcTemplate; public SchemaGenerator(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Override public void run(ApplicationArguments args) { ListClass? entities List.of( Athlete.class, Event.class, Enrollment.class, Result.class ); for (Class? entity : entities) { TableInfo tableInfo TableInfoHelper.getTableInfo(entity); jdbcTemplate.execute(buildCreateTableSql(tableInfo)); syncColumns(tableInfo); } } private String buildCreateTableSql(TableInfo info) { StringBuilder sql new StringBuilder(CREATE TABLE IF NOT EXISTS ) .append(info.getTableName()).append( (); // TableInfoHelper 的 fieldList 不含主键字段所以手动补主键 sql.append(id BIGINT NOT NULL AUTO_INCREMENT, ); for (TableFieldInfo field : info.getFieldList()) { sql.append().append(field.getColumn()).append( ) .append(mapType(field.getPropertyType())) .append( DEFAULT NULL, ); } sql.append(PRIMARY KEY (id)) ENGINEInnoDB DEFAULT CHARSETutf8mb4); return sql.toString(); } private String mapType(Class? type) { if (type String.class) return VARCHAR(255); if (type Integer.class || type int.class) return INT; if (type Long.class || type long.class) return BIGINT; if (type LocalDateTime.class) return DATETIME; return VARCHAR(255); } private void syncColumns(TableInfo info) { String sql SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA DATABASE() AND TABLE_NAME ?; SetString exists jdbcTemplate.query(sql, (rs, i) - rs.getString(1), info.getTableName()) .stream().collect(Collectors.toSet()); for (TableFieldInfo field : info.getFieldList()) { if (!exists.contains(field.getColumn())) { jdbcTemplate.execute(ALTER TABLE info.getTableName() ADD COLUMN field.getColumn() mapType(field.getPropertyType())); } } } }这段代码的核心逻辑分三段TableInfoHelper.getTableInfo 返回 MyBatis-Plus 解析好的表信息包含表名、主键和字段列表拼 SQL 时手动补 id 主键因为 fieldList 里不包含 TableId 标注的字段syncColumns 先查 INFORMATION_SCHEMA.COLUMNS把实体里有但表里没有的列用 ALTER TABLE 补上。这样开发阶段改实体类、重启、自动改表等到要部署生产环境再用 Flyway 把正式的迁移脚本补上是一条比较顺的链路。参数说明mapType 的映射是开发用简化版生产环境建议把 String 长度拿到实体注解里配置而不是固定 255LocalDateTime 映射成 DATETIME 而不是 TIMESTAMP可以少踩时区坑每次启动都执行 CREATE TABLE IF NOT EXISTS 没有副作用但 syncColumns 要加一个开关或只让它在 dev 环境执行线上库绝不能让它自动加列。2.3 报名与成绩的状态机已报名、已检录、已完赛、成绩无效运动会流程里最容易被写成一堆 if 的就是状态。报名提交后是“已报名”检录通过是“已检录”完赛是“已完赛”犯规或弃权是“成绩无效”。把这几个状态放在 enrollment 和 result 表里用数字枚举值存储比用一个自由字符串 status 散着写要稳得多。状态转移规则就三条我直接贴在代码注释里让后来的人看已报名 - 已检录 - 已完赛已报名 - 已取消已完赛 - 成绩无效这个状态机是整个系统里最容易出 bug 的地方。比如成绩录入时如果没有校验“已检录”状态就会出现还没检录就录了成绩的记录如果“成绩无效”之后没有联动重新排名团体总分就会把无效成绩算进去。所以我在 Service 层写一个 StateMachine 工具类只暴露 check(from, to) 方法所有状态更新都先过它。public class StateMachine { private static final MapInteger, SetInteger RULES Map.of( EnrollmentStatus.REGISTERED.getCode(), Set.of(EnrollmentStatus.CHECKED_IN.getCode(), EnrollmentStatus.CANCELLED.getCode()), EnrollmentStatus.CHECKED_IN.getCode(), Set.of(EnrollmentStatus.FINISHED.getCode()), EnrollmentStatus.FINISHED.getCode(), Set.of(ResultStatus.INVALIDATED.getCode()) ); public static void check(int from, int to) { if (!RULES.getOrDefault(from, Set.of()).contains(to)) { throw new BusinessException(非法的状态变更: from - to); } } }逻辑说明状态枚举的 code 是 int存入数据库的就是这个数字页面上展示再映射成中文RULES 里定义了每个状态允许跳转到哪里getOrDefault 保证了未注册的状态不允许任何跳转。所有 Controller 在更新状态前都调用 StateMachine.check这样排名时不用到处判断成绩有效性因为无效成绩已经被状态机隔离在外了。参数说明如果以后要支持“成绩申诉后恢复有效”只需要在 FINISHED 的规则里加一个回退目标不要动业务代码。3. Spring Boot MyBatis 跑通核心链路报名、检录、成绩录入3.1 工程结构与依赖为什么选 Spring Boot MyBatis 而不是 JSP 直接写选择 Spring Boot MyBatis 而不是传统 Java Server Pages 和 Servlet 直接写不是炫技而是这套组合最适合把“接口开发”和“数据访问”分开。如果学校课程设计指定 JSP Servlet骨架换成 JSP 也一样核心业务逻辑和表结构可以原样搬只把 Controller 换成 Servlet、模板换成 JSP。但自己从零做我建议还是 Spring Boot省去大量 XML 配置也方便后续加拦截器和过滤器。pom.xml 里最小依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency逻辑说明spring-boot-starter-web 提供 MVC 和内置 Tomcatmybatis-plus-boot-starter 提供 CRUD 封装和分页插件报名查询、成绩列表这些基础操作不需要手写 XMLthymeleaf 做服务端渲染报名页和成绩页都直接用后端模板不用单独部署前端工程mysql-connector-j 是 MySQL 8 的驱动坐标注意新版驱动类名是 com.mysql.cj.jdbc.Driver。分页插件需要单独配置一个 MybatisPlusInterceptor Bean加 PaginationInnerInterceptor后面成绩列表分页要用。application.yml 里几个关键配置spring: datasource: url: jdbc:mysql://localhost:3306/sports_meet?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password thymeleaf: cache: false mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl参数说明serverTimezoneAsia/Shanghai 必须加不加的话连接 MySQL 8 会直接报时区错误map-underscore-to-camel-case 打开后 create_time 才能自动映射到 createTime 属性StdOutImpl 会在控制台打印每条 SQL开发阶段很有用上线前要换成 Slf4jImpl 或直接关掉不然日志文件会涨得飞快。3.2 报名接口唯一索引与事务边界报名接口是第一个并发密集的点。运动会报名截止前半小时可能同时有几十个人在提交。如果只做“先查再插”两个人同时提交同一运动员报同一个项目两条请求都会查到“未报名”然后都插入成功重复报名就出现了。解决方式分两层数据库加唯一索引业务层捕获 DuplicateKeyException。enrollment 表建表时加唯一索引ALTER TABLE enrollment ADD UNIQUE KEY uk_athlete_event (athlete_id, event_id);Service 层写法Override Transactional(rollbackFor Exception.class) public void enroll(Long athleteId, Long eventId) { Enrollment existing enrollmentMapper.selectOne( new LambdaQueryWrapperEnrollment() .eq(Enrollment::getAthleteId, athleteId) .eq(Enrollment::getEventId, eventId) ); if (existing ! null) { throw new BusinessException(该运动员已报名此项目); } Enrollment enrollment new Enrollment(); enrollment.setAthleteId(athleteId); enrollment.setEventId(eventId); enrollment.setStatus(EnrollmentStatus.REGISTERED.getCode()); try { enrollmentMapper.insert(enrollment); } catch (DuplicateKeyException e) { throw new BusinessException(报名重复请勿重复提交); } }逻辑说明先查再插不是用来防并发的数据库唯一索引才是最后一道闸查一遍只是为了给出友好的业务提示真正挡并发的是 uk_athlete_event。Transactional 的作用是保证插入失败时事务回滚不会留下半条数据。参数说明唯一索引的字段顺序建议把 athlete_id 放前面因为报名查询基本都是按运动员维度查如果项目有人数上限加一个 event.max_players 字段用 UPDATE event SET count count 1 WHERE event_id ? AND count max_players 这种方式做原子扣减比先查再判更稳。3.3 成绩录入与 Java 排序排名从 Stream 分组到并列名次成绩录入是裁判的操作入口。检录时裁判在终端上把运动员状态从“已报名”改成“已检录”比赛结束录入成绩保存时同时把状态推到“已完赛”。排名可以在所有成绩录完后统一算也可以保存完就刷新该项目的排名。我的做法是保存成绩后触发该项目排名刷新因为运动会现场项目是进行式的排名刷得快广播和大屏才跟得上。排名算法的核心代码如下public ListResultVO rankByEvent(Long eventId, Integer eventType) { ListResult results resultMapper.selectList( new LambdaQueryWrapperResult() .eq(Result::getEventId, eventId) .eq(Result::getValid, 1) .orderByAsc(eventType TrackEventType.RUN ? Result::getScore : Result::getScore) .orderByDesc(eventType TrackEventType.FIELD ? Result::getScore : Result::getScore) ); // 这里故意不用 sorted index因为并列名次要共享名次并跳号 ListResultVO rankList new ArrayList(); int rank 1; Long prevScore null; for (int i 0; i results.size(); i) { Result r results.get(i); if (i 0 || !r.getScore().equals(prevScore)) { rank i 1; } prevScore r.getScore(); ResultVO vo new ResultVO(); vo.setRank(rank); vo.setAthleteName(r.getAthleteName()); vo.setScore(r.getScore()); rankList.add(vo); } return rankList; }逻辑说明径赛按耗时升序田赛按距离降序所以 orderBy 方向要通过 eventType 动态切换。排名时不能用简单的 index1因为如果两个人成绩相同名次要一样下一个人要跳过空名次——这就是“并列跳号”规则。这里的思路是循环里只有成绩发生变化时才更新 rank成绩相同则沿用上一个 rank。参数说明这里要求 score 是 Long 类型用 equals 比较才有意义result 表里冗余了 athlete_name 和 event_name排名查询可以少 join 两张表数据录入时把冗余字段一起写进去查询效率高很多。如果项目多、成绩量大还可以在 result 表上加 (event_id, valid, score) 联合索引排序直接走索引避免 filesort。这里不建议用冒泡排序或者手写 Comparator 复杂链一个循环就能把名次和排序一起算完读代码的人也好理解。4. 避坑指南环境配置、并发录入、排序错乱这些坑我都踩过4.1 java 环境配置好却启动失败先看端口、JDK 版本和控制台 SQL现象跑 spring-boot:run控制台报 java.lang.OutOfMemoryError 或者端口被占用项目起不来还有一种是装了多个 JDK系统 PATH 指向的是 Java 8而项目需要 Java 17IDEA 里编译正常命令行启动就报 UnsupportedClassVersionError。原因这类问题九成出在环境变量上。多个 JDK 并存时IDEA 用自己配置的 JDK命令行用 PATH 指向的 java两者不一致另外把堆内存调得过大比如把进程堆大小调整到 8000MB在开发机上反而可能因为物理内存不足启动失败不是越大越好端口被占用则是上一次进程没杀掉尤其是 IDE 里点停止但子进程还挂着。解决命令行先跑 java -version 确认当前版本再在 IDEA 的 Project Structure 里统一 Project SDK 和模块 SDK两处必须一致。启动命令显式指定 JDK 路径/usr/local/jdk-17/bin/java -Xmx512m -Xms256m -jar sports-meet.jar端口占用用 lsof -i:8080 找到 PID 后 kill或者直接换端口server: port: 8081同时把 MyBatis-Plus 的 StdOutImpl 打开项目启动成功但页面报错时先看控制台最后一条 SQL多数问题出在表名或字段名映射不上。环境变量这种问题有时候很玄学但按这个顺序排查基本十分钟内能定位。4.2 成绩并发录入导致覆盖事务和乐观锁要一起用现象两个裁判同时录同一个运动员的成绩后保存的那条覆盖了前一条成绩单上出现明显错误。原因result 表的更新语句是 UPDATE result SET score#{score} WHERE id#{id}没有版本校验。两个人各读了一条旧数据后提交的覆盖前面的这是典型的丢失更新。解决给 result 表加 version 字段更新时带上乐观锁UPDATE result SET score ?, version version 1 WHERE id ? AND version ?int rows resultMapper.update(null, new LambdaUpdateWrapperResult() .eq(Result::getId, resultId) .eq(Result::getVersion, version) .set(Result::getScore, score) .set(Result::getVersion, version 1)); if (rows 0) { throw new BusinessException(成绩已被其他裁判修改请刷新后重试); }逻辑说明乐观锁适合成绩录入这种并发冲突概率不高的场景先让请求正常执行更新时发现版本号对不上就提示重试。如果现场录入量很大可以把 version 换成数据库行锁 SELECT ... FOR UPDATE但运动会场景里一个项目通常只有一个裁判在录入乐观锁已经够用。4.3 排名错乱字符串成绩排序和并列名次处理现象100 米成绩排出来12:34 排在 2:15 前面明显不对。原因成绩字段是 VARCHAR数据库排序按字典序比较“12”小于“2”所以 12 分多排在 2 分多前面。这是把成绩存成字符串最典型的翻车现场数据库按字典序排Java 端如果用 String 类型比较结果也一样。解决把成绩列统一改成 BIGINT 存毫秒数展示时格式化。格式化的工具方法public static String formatMilliseconds(Long ms) { long minutes ms / 60000; long seconds (ms % 60000) / 1000; long millis ms % 1000; return String.format(%d:%02d.%03d, minutes, seconds, millis); }同时排名循环处理并列成绩相同名次相同后续名次跳号。这两件事合起来排名才算正确。血泪经验是不要在需求阶段接受“成绩随便填文本”的说法宁可录入时多做一步格式校验也别让脏数据进库。4.4 时间字段差 8 小时时区配置的一致性现象数据库里 create_time 是早上 8 点页面上显示凌晨 0 点差了 8 小时。原因MySQL 连接串没配 serverTimezone驱动用了默认时区或者 JDBC 的时区和 JVM 时区不一致写入时 LocalDateTime 被转成 UTC 存储。解决连接串固定加 serverTimezoneAsia/Shanghai同时 JVM 启动参数加 -Duser.timezoneAsia/Shanghai。实体字段如果存的是 LocalDateTime数据库列用 DATETIME 而不是 TIMESTAMPDATETIME 不受数据库会话时区影响能少一类隐藏问题。注意MySQL 8 的驱动在连接串缺少 serverTimezone 时会直接拒绝连接而不是打日志警告所以这个问题通常在项目启动阶段就暴露。4.5 检录后成绩找不到报名记录外键约束和冗余字段现象检录完成裁判录入成绩时按运动员学号查不到报名记录只能看到报名已删除。原因报名表记录了运动员 ID 和项目 ID删除报名时没有校验是否已产生成绩成绩表里残留了悬挂数据或者成绩查询只 join 了报名表报名被删后 join 失败。解决业务上禁止删除已检录或已完赛的报名记录删除前先查 result 表。数据库层面给 result.enrollment_id 加外键约束或者至少加唯一索引保证一条报名只有一条有效成绩。我一般还会在成绩表里冗余 athlete_name 和 event_name 两个字段查询排名时少两次 join这个对报表导出也有帮助。删除操作走软删除加一个 deleted 字段避免误删后找不回数据算是给自己留一颗后悔药。5. 收尾两件事用 EasyExcel 导出成绩册再压一次报名接口5.1 流式导出Excel 成绩册不再卡内存运动会结束要交纸质成绩册几十个项目、几千条成绩用 POI 一次性写入很容易堆内存溢出。EasyExcel 的流式写解决这个问题边读边写内存占用稳定。ListResultExcelRow rowList buildExcelRows(eventId); EasyExcel.write(outputStream, ResultExcelRow.class) .sheet(成绩册) .doWrite(rowList);ResultExcelRow 用 ExcelProperty 注解列名比如“项目名称”“运动员姓名”“班级”“成绩”“名次”。导出时只查需要的列不要在循环里逐条查数据库。导出的顺序和页面排名一致直接用排名刷新后的列表不要重新排序。如果数据量大可以分批每次 5000 条循环写入用 EasyExcel 的 WriteSheet 复用同一个 sheet。5.2 用 JMeter 压一次报名接口看慢 SQL 出在哪里上线前我会用 JMeter 模拟 100 个并发同时报名线程数 100循环 1 次。跑完看三件事接口平均响应时间、错误率、数据库里有没有重复报名数据。如果响应时间超过 2 秒去 MySQL 开慢查询日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;参数值说明线程数100模拟 100 个运动员同时提交Ramp-up0所有线程立即启动制造并发峰值循环次数1不重复提交避免干扰唯一索引判断压测目的不是追求高并发而是验证两层东西唯一索引确实挡住了重复报名以及 SQL 有没有走索引。我习惯把压测结果截个图存在项目 docs 目录下验收时直接拿给体育部老师看比嘴上说“很稳”有说服力得多。这半年多经手的运动会系统最容易挂的地方永远是成绩排名和并发报名界面好看不好看反而是次要的。我现在的习惯是拿到任何运动会需求先问三句成绩是径赛还是田赛、存储单位用什么、要不要并列名次这三个问题不定下来我不会动第一行代码。希望帮到你。本文还有配套的精品资源点击获取