简介这套面向高校课程设计与Java学习者的田径运动会信息管理系统实践项目围绕运动员报名、赛事编排、成绩记录与排名发布等场景提供从需求分析到功能实现的完整参考。压缩包共81个文件以25个Java源码和48个class文件为主体辅以2个jar依赖、数据库初始化SQL脚本及PDF课设报告等整体约2.04MB目录按源码、数据库与文档组织便于导入Eclipse并梳理运行链路。系统覆盖运动员管理、比赛项目安排、在线报名、成绩录入与排名计算、通知发布及角色权限控制等模块可帮助读者理解图形界面与关系型数据库的协作方式。已有2169人学习下载适合用作数据库课设、Java综合实训和答辩演示的参考也便于在现有结构上扩展比赛项目、补充新功能或进行二次开发。1. 从一场校运会报名混乱说起这套 Java 田径运动会管理系统能解决什么每年四五月某高校体育部的老师都会经历同一场混乱几十个班级的报名表散在微信群、Excel 附件和纸质回执里截止前一晚才发现有人报了三个项目、有人性别和项目组别对不上、有人到底属于哪个学院还写错了。java 田径运动会管理系统 就是冲着这类场景做的——把运动员建档、项目设置、报名审核、赛程编排、成绩录入和团体积分收进一个后台用浏览器就能操作。它适合高校体育部、中学运动会组委会也适合拿它当 Java Web 练手项目的开发者权限、事务、报表、并发校验这些真实业务点一个不少改起来有得改。2. 技术栈与整体架构Spring Boot MyBatis 三层结构怎么落地2.1 技术选型与版本组合这类管理系统的特点是使用人数不多一场校运会几千条报名、几百条成绩但使用时间集中、不能出错部署环境还经常是一台教务处的旧服务器或者体委办公室的办公电脑。所以技术选型的核心不是新而是“一个 jar 包丢上去就能跑”。我一般会用的组合是这样的层次选型说明运行环境JDK 8 或 JDK 11JDK 8 兼容性最好老服务器不用折腾框架Spring Boot 2.7.x内置 Tomcat打成可执行 jar持久层MyBatis-Plus 3.5.x单表 CRUD 不用写 XML统计类 SQL 手写数据库MySQL 5.7 / 8.0字符集统一 utf8mb4页面Thymeleaf Layui服务端渲染不搞前后端分离权限拦截器 RBAC 五表管理员、裁判、领队三种角色构建Maven打包命令固定交付简单为什么不做前后端分离血泪经验在这儿运动会系统的实际使用者是体育老师和学生志愿者培训成本极高。一个端口、一个地址、一套静态资源出问题只需要看一个日志文件真拆成 Vue 后端两个工程前端打包路径、跨域、Nginx 反向代理每一项都能在比赛当天变成事故。单体结构在这里不是技术落后是运维现实。2.2 分层结构与请求链路工程按标准四层分包职责边界卡得比较死src/main/java/com/sports/meet ├── config # 拦截器注册、跨域、MyBatis-Plus 分页插件 ├── controller # 只做参数接收和结果包装不写业务 ├── service # 业务规则集中在这里事务加在这一层 │ └── impl ├── mapper # 单表继承 BaseMapper统计走 XML ├── entity # 与数据库表一一对应 ├── dto # 前端提交对象、导出对象 ├── common # R 统一返回、BizException、常量枚举 └── interceptor # 登录校验 角色校验一次“裁判提交成绩”的请求链路是这样的ResultController.save()接收入参 →Valid做基础校验 →ResultServiceImpl.saveResult()开启事务 → 查报名记录确认该运动员确实报了这项 → 写t_result→ 重算该项目名次 → 更新积分缓存表 → 返回R.ok()。中途任何一步抛BizException事务整体回滚前端拿到的是统一格式的错误提示而不是 500 白页。统一返回体很简单但一定要有否则前端每个页面都要写一套错误处理public class RT { private int code; // 0 成功非 0 失败 private String msg; // 直接可展示给用户的提示 private T data; public static T RT ok(T data) { RT r new R(); r.code 0; r.msg 操作成功; r.data data; return r; } public static T RT fail(String msg) { RT r new R(); r.code 500; r.msg msg; return r; } }code用 0 表示成功而不是 200是为了和前端 Layui 表格的默认code: 0约定对齐省掉一层转换。msg字段的约定是“能直接弹给用户看”所以业务异常里写的是“该项目名额已满”这种人话不是EVENT_QUOTA_FULL。全局异常处理器负责把两类异常分开BizException原样返回提示其他异常统一记日志后返回“系统繁忙请联系管理员”。这样成绩录入时数据库字段超长之类的低级错误不会把堆栈甩到裁判脸上但日志里查得到。2.3 数据库核心表设计表结构决定了这个项目改起来难不难下面几张是主干其余都是围绕它们挂的字典表和日志表。表名作用关键字段t_meet运动会届次id、meet_name、start_date、statust_team代表队按学院/班级id、team_name、meet_id、leader_idt_athlete运动员id、name、gender、student_no、team_idt_event比赛项目id、event_name、type径赛/田赛、gender_limit、quota、max_per_persont_registration报名记录id、athlete_id、event_id、meet_id、statust_result成绩id、registration_id、score_value、rank_no、is_break_recordt_sys_user/t_sys_role/t_sys_user_role登录与权限标准 RBAC 三张表两个设计点值得说。第一t_event.gender_limit用0/1/2表示不限/男/女而不是靠项目名字里带“男子”两个字去判断——按名字匹配是很多同类系统的通病改个名字规则就失效。第二t_result不直接关联athlete_id而是关联registration_id因为成绩的合法前提是“这个人报了这项”走报名表做外键能天然防止给没报名的人录入成绩。t_event上的quota项目总名额和max_per_person每人限报数是报名校验的两把锁字段直接放在项目表上不同项目可以配置不同规则比如 100 米限报 40 人、每人限报 2 项而 4×100 接力每人限报 1 项。3. 环境搭建与启动从空库到能登录跑起来的完整步骤3.1 环境依赖与目录结构本地要跑起来机器上装好这几样就够了JDK 8 及以上、Maven 3.6、MySQL 5.7 或 8.0。IDE 用 IDEA 或者 VS Code 都行不挑。首次拉下来先把 Maven 依赖跑通国内网络环境下建议在settings.xml里配好镜像仓库否则spring-boot-starter-web那一串下载能卡十几分钟。工程根目录的关键内容大概长这样athletics-meet/ ├── pom.xml ├── sql/ │ ├── schema.sql # 建表脚本 │ └── data.sql # 初始数据管理员账号、字典 ├── src/main/java/com/sports/meet/ ├── src/main/resources/ │ ├── application.yml # 数据源、端口、上传路径 │ ├── mapper/ # 统计类 SQL 的 XML │ ├── templates/ # Thymeleaf 页面 │ └── static/ # Layui、js、css └── README.md注意sql目录这类项目交付时最容易漏的就是初始化脚本接手的人对着空库连管理员账号都没有登录页面都进不去。我一般要求data.sql里至少包含一个admin/123456的账号和一组项目类型字典。3.2 建库建表与初始化数据先建库字符集必须是 utf8mb4这一步错了后面中文项目名会全变问号CREATE DATABASE athletics_meet DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE athletics_meet;然后按顺序执行schema.sql和data.sql。核心表里报名表的唯一约束是重点也是后面防重复报名的第一道防线CREATE TABLE t_registration ( id BIGINT PRIMARY KEY AUTO_INCREMENT, meet_id BIGINT NOT NULL COMMENT 运动会届次, athlete_id BIGINT NOT NULL COMMENT 运动员ID, event_id BIGINT NOT NULL COMMENT 比赛项目ID, status TINYINT NOT NULL DEFAULT 1 COMMENT 1已报名 2已审核 3已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_athlete_event (athlete_id, event_id, meet_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT报名记录;uk_athlete_event这个唯一索引在应用层校验之外兜底哪怕两个人同时点了报名按钮、Service 里的 count 判断都通过了数据库也会拦下重复插入并抛DuplicateKeyException在全局异常处理器里翻译成“该项目你已经报过名了”。t_result表还有一个容易忽略的字段score_value用DECIMAL(10,3)而不是DOUBLE。径赛成绩是秒13.256田赛是米5.87跳高是米1.85统一存成定点小数排序和相等判断不会出现浮点误差尤其是“成绩并列”这种要精确判定的场景。3.3 配置文件与启动命令application.yml里几个参数经常被改错单独说一下server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/athletics_meet?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: your_password thymeleaf: cache: false # 开发阶段关缓存改页面不用重启 mybatis-plus: mapper-locations: classpath*:mapper/*.xml configuration: map-underscore-to-camel-case: true meet: upload-path: /data/meet/upload # 运动员照片、秩序册导出目录 result-lock: false # 成绩录入锁定开关比赛结束后置 trueserverTimezoneAsia/Shanghai不加的话 MySQL 8 会报时区错误allowPublicKeyRetrievaltrue是 MySQL 8 默认加密方式下连接被拒时的常见解法。meet.result-lock这个自定义开关是我后来加的比赛进行中允许改成绩闭幕式前锁定防止有人事后改分比事后查日志靠谱。启动方式两种开发时直接跑主类交付时打 jar# 开发环境 mvn spring-boot:run # 打包部署 mvn clean package -DskipTests java -jar athletics-meet-1.0.0.jar --spring.profiles.activeprod启动成功后访问http://localhost:8080/login用初始化脚本里的管理员账号登录。第一次进去如果页面样式全丢先看static目录下的 Layui 是不是被.gitignore漏掉了这是打包读者最容易翻车的地方。4. 核心模块拆解报名审核、成绩录入与积分排名的实现4.1 运动员报名与项目名额校验报名是整套系统里业务规则最密的一环性别、组别、限报数、项目名额、重复报名全都要在一个事务里判完否则两个人抢最后一个名额就会双写。Service public class RegistrationServiceImpl implements RegistrationService { Resource private AthleteMapper athleteMapper; Resource private EventMapper eventMapper; Resource private RegistrationMapper registrationMapper; Override Transactional(rollbackFor Exception.class) public void signUp(Long athleteId, Long eventId) { Athlete athlete athleteMapper.selectById(athleteId); Event event eventMapper.selectById(eventId); if (athlete null || event null) { throw new BizException(运动员或项目不存在); } // 1. 项目是否处于报名期status: 1 开放报名2 报名截止 if (event.getStatus() ! 1) { throw new BizException(该项目已停止报名); } // 2. 性别限制0不限 1男 2女 if (event.getGenderLimit() ! 0 !event.getGenderLimit().equals(athlete.getGender())) { throw new BizException(性别不符不能报名该项目); } // 3. 每人限报数量同届同运动员的有效报名数 Long myCount registrationMapper.selectCount( new LambdaQueryWrapperRegistration() .eq(Registration::getAthleteId, athleteId) .eq(Registration::getMeetId, event.getMeetId()) .ne(Registration::getStatus, 3)); if (myCount event.getMaxPerPerson()) { throw new BizException(本项目每人最多报 event.getMaxPerPerson() 项); } // 4. 项目总名额在项目行上加行锁防并发超卖 Event lockEvent eventMapper.selectForUpdate(eventId); Long total registrationMapper.selectCount( new LambdaQueryWrapperRegistration() .eq(Registration::getEventId, eventId) .ne(Registration::getStatus, 3)); if (total lockEvent.getQuota()) { throw new BizException(该项目名额已满); } // 5. 落库唯一索引兜底重复报名 Registration reg new Registration(); reg.setAthleteId(athleteId); reg.setEventId(eventId); reg.setMeetId(event.getMeetId()); reg.setStatus(1); registrationMapper.insert(reg); } }逐点说明。第三步的限报数用的是ne(status, 3)而不是eq(status, 1)因为已审核2的报名同样占名额很多人写错这里导致审核通过后还能继续报。第四步的selectForUpdate对应 XML 里的SELECT ... FOR UPDATE它把项目这一行锁住后续并发请求排队等锁读到的total一定是最新值——这是防超卖最省事的做法代价是同一项目的报名串行化但校运会这个量级完全扛得住。失败时看什么抛BizException说明规则正常拦截前端会弹提示抛DuplicateKeyException说明唯一索引生效了说明应用层的重复校验有漏洞通常是没判status3的取消记录被重新报名如果报Lock wait timeout说明有长事务占着项目行锁去查是不是审核接口里嵌套调用了报名。4.2 成绩录入与名次自动计算成绩录入要区分两类项目。径赛100 米、800 米、跨栏比的是时间越小越好录入值一般来自秒表读数格式13.25田赛跳远、铅球、跳高比的是距离或高度越大越好。这两类排序方向相反如果代码里写死ORDER BY score_value ASC田赛名次会全部反掉。Override Transactional(rollbackFor Exception.class) public void saveResult(ResultDTO dto) { Registration reg registrationMapper.selectById(dto.getRegistrationId()); if (reg null || reg.getStatus() 3) { throw new BizException(该运动员未报名或报名已取消); } // 已出成绩的不允许重复录入除非走修改接口会留痕 Result exist resultMapper.selectByRegistrationId(dto.getRegistrationId()); if (exist ! null !dto.isUpdate()) { throw new BizException(成绩已录入请使用修改功能); } Result result exist null ? new Result() : exist; result.setRegistrationId(dto.getRegistrationId()); result.setEventId(reg.getEventId()); // 径赛存秒田赛存米保留三位小数 result.setScoreValue(dto.getScoreValue().setScale(3, RoundingMode.HALF_UP)); result.setIsBreakRecord(checkRecord(reg, dto.getScoreValue()) ? 1 : 0); if (exist null) { resultMapper.insert(result); } else { resultMapper.updateById(result); } // 重新计算该项目的名次 recalcRank(reg.getEventId()); } private void recalcRank(Long eventId) { Event event eventMapper.selectById(eventId); // 径赛升序田赛降序一条 SQL 决定排序方向 ListResult list resultMapper.selectRankList(eventId, event.getType()); int rank 0; BigDecimal last null; for (int i 0; i list.size(); i) { Result r list.get(i); // 成绩相同名次并列下一名跳过1,1,3 if (last null || r.getScoreValue().compareTo(last) ! 0) { rank i 1; } r.setRankNo(rank); last r.getScoreValue(); resultMapper.updateRank(r.getId(), rank); } }recalcRank是全量重算不是增量插入。这个选择看着笨但对运动会场景是对的只要有一个人改了成绩后面所有人的名次都可能平移增量维护的边界条件并列、取消资格、退赛多到写不完几百条数据全量重算不到一秒。并列名次用 1、1、3 还是 1、1、2不同比赛规程要求不一样。代码里rank i 1是 1、1、3 的跳号方案如果规程要求 1、1、2把赋值改成rank并保留last判断即可我在注释里会写清楚方便二次开发直接改。checkRecord是破纪录校验查该项目的历史最好成绩t_record表按项目 性别 组别存径赛比小、田赛比大。这个功能不上锁也行但必须放在recalcRank之前并且用同一条成绩值判断别再查一次库——两次查库之间成绩被改会出破纪录标记和名次对不上的玄学 bug。4.3 积分榜与团体总分聚合团体总分是闭幕式上最受关注的那块大屏算法本身不复杂名次换积分第一名 9 分、第二名 7 分、第三名 6 分、依次递减到 1 分按代表队求和破纪录额外加分。积分规则必须做成配置表而不是写死因为不同学校、不同届次的规则经常改。用一条 SQL 把积分榜算出来比在 Java 里循环几十个队再查库快得多select idselectTeamScore resultTypeTeamScoreVO SELECT t.id AS teamId, t.team_name AS teamName, IFNULL(SUM(r.point), 0) AS totalPoint, SUM(CASE WHEN res.rank_no 1 THEN 1 ELSE 0 END) AS goldCount FROM t_team t LEFT JOIN t_registration reg ON reg.team_id t.id AND reg.meet_id #{meetId} LEFT JOIN ( SELECT r.registration_id, r.rank_no, !-- 名次换分1名9分2名7分3名6分4-8名递减破纪录额外加2分 -- CASE r.rank_no WHEN 1 THEN 9 WHEN 2 THEN 7 WHEN 3 THEN 6 WHEN 4 THEN 5 WHEN 5 THEN 4 WHEN 6 THEN 3 WHEN 7 THEN 2 WHEN 8 THEN 1 ELSE 0 END IF(r.is_break_record 1, 2, 0) AS point FROM t_result r JOIN t_registration g ON g.id r.registration_id WHERE r.status 1 ) res ON res.registration_id reg.id LEFT JOIN t_result r ON r.id res.registration_id WHERE t.meet_id #{meetId} GROUP BY t.id, t.team_name ORDER BY totalPoint DESC, goldCount DESC /select参数只有meetId多个届次的数据不会串。status 1过滤掉退赛和取消资格的成绩这是很多同类项目漏掉的取消资格DNS/DSQ不应该拿分但成绩行还在表里不过滤就会把零分甚至负分算进团体总分。排序用了totalPoint DESC, goldCount DESC两级分数相同比金牌数这是运动会通行的排名规则。如果规程还要求比银牌、铜牌再加两个SUM(CASE WHEN rank_no 2 ...)字段就行ORDER BY后面追加。别在 Java 里用Comparator排SQL 排完直接给大屏逻辑只有一份。积分榜页面一般还要一个「实时刷新」做法是前端定时拉接口30 秒一次不是 WebSocket。运动会现场网络抖动是常态WebSocket 断线重连的处理成本比轮询高得多而 30 秒延迟对观众完全无感。5. 避坑与排查环境、并发、并列名次这五条血泪经验5.1 环境与启动类问题现象一启动报Communications link failure或Access denied。原因通常不是密码错而是连接串缺参数或者驱动类名写成了老的com.mysql.jdbc.Driver。MySQL 8 必须用com.mysql.cj.jdbc.Driver连接串要带serverTimezone。另外 MySQL 8 默认的caching_sha2_password加密方式在部分客户端下会握手失败加上allowPublicKeyRetrievaltrueuseSSLfalse能解决大半。 解决把application.yml里的 url、driver 按 3.3 节的模板完整抄一遍先确认用命令行mysql -u root -p能连上再启动项目把问题范围缩小到配置还是数据库本身。现象二页面能打开但项目名、运动员姓名全是问号。原因在建库那一步数据库字符集用了默认的 latin1 或者 utf8三字节中文存进去就废。还有一种情况是建库对了但连接串里characterEncoding写成UTF-8而库是 utf8mb4部分生僻字运动员名字里真有会丢。 解决SHOW CREATE DATABASE athletics_meet;确认是 utf8mb4不是就ALTER DATABASE ... CHARACTER SET utf8mb4;然后清空重建表重导数据。已经写成问号的数据改不回来只能重录。现象三点击报名没反应控制台报 404 但接口路径看着没问题。原因多半是拦截器把静态资源也拦了或者 Controller 类上少了RequestMapping前端请求拼出来的路径对不上。Thymeleaf 页面里用{/registration/signUp}是最稳的写法别手写相对路径。 解决打开浏览器 Network 面板看实际请求 URL和PostMapping的完整路径逐字符比对拦截器的excludePathPatterns里补上/static/**、/layui/**、/login。5.2 业务与数据类问题现象四最后一个名额被两个人同时抢到项目超员。原因就是 4.1 节说的并发Service 里先selectCount再insert两个请求都读到 39上限 40都判断通过都插入成功。这是典型的“先查后写”不加锁必翻车。 解决两层保险。项目行加SELECT ... FOR UPDATE串行化名额判断报名表加(athlete_id, event_id, meet_id)唯一索引兜重复。如果不想加行锁用quota字段做乐观扣减UPDATE t_event SET quota quota - 1 WHERE id ? AND quota 0影响行数为 0 就说明抢完了性能更好但改造成本略高。现象五成绩一改名次全乱大屏上出现两个第一名却没有第二名之外的第三名。原因有两个。一是改成绩时只更新了本人那条rank_no没触发重算别人名次原地不动二是并列名次规则实现成了rank i导致 1、1、2 和 1、1、3 混用。这类问题是真正的黑匣子因为数据看着都对就是排名看着别扭。 解决成绩的任何写操作新增、修改、取消资格之后一律对该项目全量调用recalcRank别做增量优化并列规则写成一个可配置项跳号 / 不跳号在项目维度的配置里明确选一种录完第一个项目就跟主办方确认别等到闭幕前。还有一条不算坑但值得写进注意成绩录入要加meet.result-lock开关比赛结束后锁定。我曾经见过闭幕式前有人把 100 米第一名的成绩从 11.32 改成 11.28全靠事后翻日志才定位到人。锁定之后修改要走专门的“成绩更正”流程带操作人和原因留痕比后悔药便宜。6. 二次开发多届运动会、导出报表与小程序的改造思路这套系统拿下来之后最常见的改造需求有三个按投入产出比排序说一下。第一是多届运动会并行。原设计如果只有一届所有查询都不带届次条件换届时只能删库重来历史成绩全丢。改造核心是全面引入meet_idt_event、t_registration、t_result、t_team都加字段所有 Mapper 查询加eq(meet_id)登录后先在Session里放一个当前届次拦截器统一注入。表结构变更示例ALTER TABLE t_event ADD COLUMN meet_id BIGINT NOT NULL DEFAULT 1 COMMENT 所属届次; ALTER TABLE t_team ADD COLUMN meet_id BIGINT NOT NULL DEFAULT 1 COMMENT 所属届次; CREATE INDEX idx_meet ON t_event (meet_id);改完之后所有历史数据默认归属第 1 届新届次从后台新建即可积分榜查询天然隔离。这一步做完系统才算真正能长期用。第二是 Excel 导出。秩序册、成绩册、团体总分表主办方最后都要纸质版。别用 POI 手写直接上 EasyExcel几行代码搞定public void exportScore(Long meetId, HttpServletResponse response) throws IOException { ListTeamScoreVO list teamScoreMapper.selectTeamScore(meetId); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment;filename URLEncoder.encode(团体总分.xlsx, UTF-8)); EasyExcel.write(response.getOutputStream(), TeamScoreVO.class) .sheet(团体总分) .doWrite(list); }TeamScoreVO上直接打ExcelProperty(代表队)这类注解字段顺序就是列顺序改导出格式只改注解不碰逻辑。第三是加一个移动端查询入口。运动员和观众最想知道的只有两件事自己报的项目什么时候比、成绩排第几。这两个接口按运动员查赛程、按项目查成绩复用好现有 Service单独开一个/api/open/**路径拦截器里放行并用简单签名校验前端用小程序或者 H5 都行。别把整个后台接口暴露出去尤其是写接口移动端只读就够。最后说个习惯。这套系统我改过几轮最大的教训是每次改完积分或名次的逻辑一定要拿一届完整数据从头跑一遍建届次、报满名额、录全部成绩、看积分榜、导出 Excel。从那以后我每次动排名相关代码都强制走一遍这条全链路不再相信“就改了一行排序”这种话。希望帮到你。本文还有配套的精品资源点击获取