ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Spring Boot+MyBatis的田径运动管理系统课设实战:从建表到成绩排名与导出

基于Spring Boot+MyBatis的田径运动管理系统课设实战:从建表到成绩排名与导出 简介基于Java的田径运动管理系统源码包面向高校计算机专业学生、田径赛事组织者及需要快速搭建体育管理原型的开发者。项目采用面向对象设计覆盖运动员信息管理、比赛日程编排、成绩记录与统计、器材管理等核心模块可作为课程设计或毕业设计的完整参考方案。资源共69个文件包含57个Java源文件、9个文本说明、2个PPT演示文稿及1个License文件压缩包仅351KB。Java源码主要承载界面与业务逻辑文本文件收录比赛规则、触发器说明、readme等资料PPT演示文稿提供E-R图及系统架构讲解能帮助理解数据库设计与功能实现。目前已有261人学习浏览。通过学习这份源码可以掌握Java图形界面开发、数据库连接与触发器应用、多版本工程组织等实践技能并借助现成的E-R图和演示文稿快速完成项目汇报与二次开发。1. 基于Java的田径运动管理系统这门课设到底在做什么如果你搜到这个标题大概率正在做课程设计或者毕业设计手头需要一个能跑通、能答辩、能演示的Java Web项目。田径运动管理系统说白了就是一套运动会成绩管理后台解决的是学校或单位办田径运动会时最头疼的问题运动员报名信息靠Excel传来传去、成绩手工登记到纸上再二次录入、名次和团体总分算错没人发现。这类系统在Java课设里属于经典中的经典因为它覆盖面刚好——要有CRUD、要有业务规则田赛径赛不同排名方式、要有报表输出难度不大不小正适合作为课程设计案例源码来参考。我写这篇笔记的目标很直接帮你在本地把一套基于Spring Boot MyBatis的田径运动管理系统从零搭起来数据表设计、成绩录入、自动排名、团体总分计算、Excel导出全走一遍最后把那些在答辩现场才会暴露的坑提前告诉你。这套方案的代码量不大如果你已经学过Java Web基础按着步骤走三四个小时能跑通如果你是新手照着抄也能交差但更重要的是搞清楚每段代码在干什么。2. 从需求到数据模型运动员、项目与成绩的三张核心表2.1 运动员注册与项目报名的表结构设计田径运动会的信息模型并不复杂核心就三张表运动员表、项目表、报名表。但很多课设翻车就翻在表结构上——有人把性别和项目写死在运动员表里有人把成绩字段直接挂在报名记录上结果一个运动员报多个项目时数据冗余到没法看。运动员表我一般这样设计CREATE TABLE athlete ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 运动员ID, name VARCHAR(50) NOT NULL COMMENT 姓名, gender TINYINT NOT NULL COMMENT 性别 1男 2女, student_no VARCHAR(20) UNIQUE COMMENT 学号/工号, unit VARCHAR(100) NOT NULL COMMENT 所属单位/班级, phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT运动员表;注意几个关键点gender用TINYINT而不是CHAR(1)查数据时用1和2判断避免字符串比较的性能浪费和大小写问题student_no加唯一约束防止同一个人被重复录入两次unit这个字段是后续团体总分统计的分组依据一定要单独存而不是写在别的表里。项目表event反而简单重点是要区分田赛和径赛因为这直接决定了成绩怎么算、名次怎么排CREATE TABLE event ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 项目ID, event_name VARCHAR(100) NOT NULL COMMENT 项目名称, event_type TINYINT NOT NULL COMMENT 1径赛 2田赛, group_type VARCHAR(20) COMMENT 组别 如男子甲组, limit_count INT DEFAULT 0 COMMENT 每单位限报人数0不限, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT比赛项目表;event_type是这个表的灵魂字段。100米、200米、400米是径赛按时间排名数值越小越好跳高、跳远、铅球是田赛按距离或高度排名数值越大越好。这个字段在后续写排名算法时直接决定排序方向千万别省。报名表是三张表里最容易设计错的一张。常见错误是把报名信息合并到运动员表里加一个event_id字段运动员报三个项目就要插入三条运动员记录数据翻三倍不说改个电话号码要改三处。正确的做法是单独的报名关联表CREATE TABLE sign_up ( id BIGINT PRIMARY KEY AUTO_INCREMENT, athlete_id BIGINT NOT NULL COMMENT 运动员ID, event_id BIGINT NOT NULL COMMENT 项目ID, sign_up_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_athlete_event (athlete_id, event_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT报名表;UNIQUE KEY uk_athlete_event (athlete_id, event_id)这个联合唯一约束非常关键它从数据库层面保证同一个人不能重复报同一个项目。课设答辩时老师一定会问“怎么防止重复报名”这个索引就是你的答案。实际插入时先用INSERT IGNORE或捕获DuplicateKeyException都能优雅处理。2.2 成绩表的两种设计分项目存还是统一存成绩表是设计分歧最大的地方。有人按每个项目建一张成绩表跑出一个100米成绩表、一个跳远成绩表物理隔离但代码要写N套有人只建一张成绩表用一个result_value字段存所有成绩代码统一了但没法区分“12.5秒”和“6.12米”谁更好。我的建议是课设规模用统一成绩表加冗余字段这也是最贴合实际的做法。CREATE TABLE result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sign_up_id BIGINT NOT NULL COMMENT 报名记录ID, athlete_id BIGINT NOT NULL, event_id BIGINT NOT NULL COMMENT 冗余方便按项目查, score_value DECIMAL(8,2) NOT NULL COMMENT 成绩数值, score_unit VARCHAR(10) COMMENT 单位 秒/米/厘米, rank_no INT COMMENT 名次, is_valid TINYINT DEFAULT 1 COMMENT 成绩是否有效 1有效 0犯规取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_event_score (event_id, score_value) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT比赛成绩表;这里的核心设计有两个。第一score_value用DECIMAL(8,2)而不是DOUBLE或FLOAT。100米短跑的成绩精度要到百分之一秒12.55秒跳远成绩要精确到厘米6.12米DECIMAL能精确存储这些定点小数而FLOAT在比较大小和排序时可能因为二进制浮点误差翻车。第二把athlete_id冗余进成绩表里虽然在范式上不完美但查成绩列表时能少一次JOIN课设阶段这个性能收益和简化收益是划算的。另外我建议加一个is_valid字段。比赛中经常出现犯规、抢跑、成绩无效的情况——成绩记录还在但名次计算时要把它剔除。如果直接删除记录报名数据没了后续统计团体总分时人数对不上很麻烦。用软标记最稳妥。3. 成绩录入与排名计算把秒表和皮尺换算成排行榜3.1 田赛与径赛的判定逻辑取最好成绩还是最短时间成绩录入是整个系统中业务规则最重的模块。录入界面并不复杂就是选择运动员、选择项目、填成绩、点保存但成绩存进去之后怎么排名规则和径赛完全不同。径赛100米、200米、400米、800米等按成绩数值从小到大排用时少者胜。田赛跳高、跳远、铅球按成绩数值从大到小排距离远者胜。跳高还有一个特殊情况——每个高度有三次试跳机会取成功跳过的高度但课设里如果把它做得太细会陷入成绩单流程的泥潭我建议录取“最好成绩”即可即田赛取三次试跳/试投中的最优值径赛直接录最终枪表成绩。知道了这个逻辑实现就分两步第一步是录入成绩时根据项目类型做校验第二步是计算名次时根据项目类型决定排序方向。录入成绩的Service层核心代码长这样Service public class ResultService { Resource private EventMapper eventMapper; Resource private ResultMapper resultMapper; Transactional public void enterScore(ScoreEnterDTO dto) { // 先查出项目类型决定后续校验逻辑 Event event eventMapper.selectById(dto.getEventId()); if (event null) { throw new BusinessException(项目不存在); } // 径赛成绩判断100米不可能低于5秒超过100秒也不合理 if (event.getEventType() 1) { if (dto.getScoreValue() 0 || dto.getScoreValue() 300) { throw new BusinessException(径赛成绩不合法请检查录入数值); } } else { // 田赛成绩判断单位统一存米跳远不可能超过10米 if (dto.getScoreValue() 0 || dto.getScoreValue() 100) { throw new BusinessException(田赛成绩不合法请检查录入数值); } } Result result new Result(); result.setSignUpId(dto.getSignUpId()); result.setAthleteId(dto.getAthleteId()); result.setEventId(dto.getEventId()); result.setScoreValue(dto.getScoreValue()); result.setScoreUnit(event.getEventType() 1 ? 秒 : 米); result.setIsValid(1); resultMapper.insert(result); } }这段代码把事务和基本校验放在了Service层Transactional保证成绩插入和后面的名次更新要么都成功要么都回滚不会出现成绩插进去了名次没更新的脏数据。成绩范围校验是防止录入员手误把“12.55”打成“125.5”的第一道防线毕竟在浏览器里输入错误数据的速度远比后端接口校验快。录入界面我用的是Vue Axios表单里根据当前选中项目的类型动态切换输入框的单位标签——径赛显示“秒”田赛显示“米”。前端校验只是体验优化后端校验才是安全底线两者都留着不能只做一端。名次计算我单独提一个方法在成绩录入后、以及成绩被修改后都会调用。核心逻辑是查这个项目的所有有效成绩按项目类型排序然后逐个赋名次。3.2 自动排名用Java Stream实现分组排序名次计算的实现方式很多最直白的写法是查出来后在Java里排序赋值。我一般用Stream来处理简洁且不容易出错public void recalcRank(Long eventId) { // 查出该项目全部有效成绩按运动员分组 ListResult results resultMapper.selectValidByEventId(eventId); // 按项目类型决定排序方向 ComparatorResult comparator results.get(0).getScoreUnit().equals(秒) ? Comparator.comparing(Result::getScoreValue) // 径赛时间短在前 : Comparator.comparing(Result::getScoreValue).reversed(); // 田赛距离远在前 ListResult sorted results.stream() .sorted(comparator) .collect(Collectors.toList()); // 相同成绩并排名次12.55和12.55都算第1名下一个是第3名 int rank 1; for (int i 0; i sorted.size(); i) { if (i 0 sorted.get(i).getScoreValue().compareTo(sorted.get(i - 1).getScoreValue()) ! 0) { rank i 1; } sorted.get(i).setRankNo(rank); resultMapper.updateById(sorted.get(i)); } }这段代码里有三个细节值得展开。第一个是用Comparator.comparing的reversed()处理排序方向而不是在SQL里写ORDER BY。这是因为同一个项目可能既有预赛又有决赛预赛成绩和决赛成绩要分别排名如果每次都在SQL里改排序方向Mapper方法要写两套而用Java比较器只需换一个Comparator复用性更好。第二个细节是并列名次的处理。体育比赛的规则是如果有两个人并列第一下一个是第三名而不是第二名没有第二名。我这里的实现逻辑是从第二个成绩开始如果和上一个成绩数值不同名次更新为i 1如果相同保持上一个的名次。所以12.55、12.55、12.60三个成绩对应的名次是1、1、3完全符合竞赛规则。第三个细节是名次更新放在循环里的updateById成绩量不大时没有问题。如果项目数量多、每个项目上百人参赛可以改成批量更新用MyBatis的批量SQL或foreach标签拼一个UPDATE语句一次提交。课设阶段不需要优化到这个程度但答辩时能说得出这个优化方向比答不上来强很多。成绩录入页面还有一个小细节值得做同一个运动员在同一项目里录入成绩时前端要能显示已有成绩并在二次录入时给出确认提示。这能防住“录了两遍成绩导致名次重复”的尴尬。逻辑上是看成绩表里有没有这个signUpId的记录有就提示覆盖还是新增。4. 团体总分与报表导出让Excel替你加班4.1 团体总分聚合按单位分组累加的SQL与Java实现运动会最让体育老师崩溃的环节就是闭幕式前的团体总分统计。每个项目取前八名得分按9、7、6、5、4、3、2、1计算最后汇总到每个单位班级或部门。手工算的时候经常是几个人拿计算器一起按半天还容易漏掉并列名次的得分规则。在系统里团体总分就是一条带连接查询的聚合SQLSELECT a.unit AS unit_name, SUM( CASE WHEN r.rank_no 1 THEN 9 WHEN r.rank_no 2 THEN 7 WHEN r.rank_no 3 THEN 6 WHEN r.rank_no 4 THEN 5 WHEN r.rank_no 5 THEN 4 WHEN r.rank_no 6 THEN 3 WHEN r.rank_no 7 THEN 2 WHEN r.rank_no 8 THEN 1 ELSE 0 END ) AS total_score FROM result r INNER JOIN athlete a ON r.athlete_id a.id WHERE r.is_valid 1 AND r.rank_no 0 AND r.rank_no 8 GROUP BY a.unit ORDER BY total_score DESC;这个SQL不算复杂但有两个坑。第一个是rank_no大于零小于等于8的限制如果已经通过软标记把犯规成绩的is_valid置为0那么rank_no还可能保留旧值——比如运动员犯规后成绩无效了但名次还在。所以查有效成绩时必须带上is_valid 1。第二个是并列名次的得分处理如果两个人并列第一他们应该分别拿到9分和9分而不是9分和7分。上面的SQL天然支持这个规则因为两个rank_no 1的记录各自算9分但如果你用了“第一名之后下一个名次是3”的规则即rank_no为1、1、3那么并列第一的两人仍然各得9分第三名得6分这也没错。Java侧我建议把这条SQL写在Mapper接口里用Select注解直接返回一个Map列表Select(SELECT a.unit AS unitName, SUM(CASE WHEN r.rank_no 1 THEN 9 WHEN r.rank_no 2 THEN 7 WHEN r.rank_no 3 THEN 6 WHEN r.rank_no 4 THEN 5 WHEN r.rank_no 5 THEN 4 WHEN r.rank_no 6 THEN 3 WHEN r.rank_no 7 THEN 2 WHEN r.rank_no 8 THEN 1 ELSE 0 END) AS totalScore FROM result r INNER JOIN athlete a ON r.athlete_id a.id WHERE r.is_valid 1 AND r.rank_no 0 AND r.rank_no 8 GROUP BY a.unit ORDER BY totalScore DESC) ListMapString, Object selectGroupScore();这里直接返回Map而不是DTO是因为聚合结果的字段是动态的、固定两个键用Map最省事。如果后续要在列表页展示更多信息比如加一个“金牌数”字段再改成VO类也不迟。4.2 用Apache POI导出Excel成绩单光在网页上看到排行榜还不够体育老师要的是能直接打印张贴的纸质成绩单。导出Excel这件事Java领域最常用的方案就是Apache POI。课设里不需要搞复杂模板直接用POI创建Workbook写数据就行public void exportScoresToExcel(Long eventId, HttpServletResponse response) { ListResultVO list resultMapper.selectResultVOByEventId(eventId); XSSFWorkbook workbook new XSSFWorkbook(); Sheet sheet workbook.createSheet(成绩表); // 表头 String[] headers {名次, 运动员, 单位, 成绩, 备注}; Row headerRow sheet.createRow(0); for (int i 0; i headers.length; i) { headerRow.createCell(i).setCellValue(headers[i]); } // 数据行 for (int i 0; i list.size(); i) { Row row sheet.createRow(i 1); ResultVO vo list.get(i); row.createCell(0).setCellValue(vo.getRankNo()); row.createCell(1).setCellValue(vo.getAthleteName()); row.createCell(2).setCellValue(vo.getUnit()); row.createCell(3).setCellValue(vo.getScoreValue() vo.getScoreUnit()); row.createCell(4).setCellValue(vo.getIsValid() 1 ? : 犯规); } // 自动列宽 for (int i 0; i headers.length; i) { sheet.autoSizeColumn(i); } // 写出到响应流 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filename URLEncoder.encode(scores.xlsx, UTF-8)); workbook.write(response.getOutputStream()); workbook.close(); }POI导出有几个细节要注意。第一是URLEncoder.encode文件名如果直接拼中文文件名浏览器下载时可能乱码或下载失败。第二是autoSizeColumn在数据多的时候性能不太好但课设数据量小无所谓。第三是workbook.close()一定要放在最后否则文件流没有正确关闭导出一次两次没事连续导出十几次之后可能出现“文件被占用”的报错。另外导出成绩单的接口要做权限控制。我见过课设里直接把导出接口暴露出来任何人知道URL就能下载全部成绩虽然不是什么机密数据但期末答辩时老师可能顺手点一下安全性的问题。用一个简单的拦截器或者在Controller里判断登录状态就行别裸奔。5. 避坑字段精度、并发报名和MyBatis-Plus的隐形坑5.1 成绩精度丢失Double不适合存秒表读数现象录入的跳远成绩6.12在排名时变成6.1199999导致排序错位。原因FLOAT和DOUBLE是IEEE 754浮点数用二进制表示十进制小数时会有误差。6.12这个数字在二进制里是无限循环小数存进DOUBLE字段后再读出来精度已经变了。MySQL里FLOAT的精度大概只有7位有效数字而径赛成绩要用到百分之一秒刚好卡在精度边界上。解决成绩字段全部使用DECIMAL(8,2)。Java实体对应BigDecimal避免用Double接收。如果你接手的老项目已经在用DOUBLE可以在查询时用CAST(score_value AS DECIMAL(8,2))弥补但这是治标不治本新表一定要用DECIMAL。这里也是面试时可能被追问的点BigDecimal和Double的区别、为什么金额和成绩不能用浮点数答清楚能加分。5.2 并发报名超卖同项目人数限制失效现象项目设置了“每单位限报4人”但当多个人同时提交报名时数据库里同一单位报了5个人。原因判断“是否达到人数上限”是在应用层做的——先SELECT COUNT(*)再INSERT两个操作之间存在时间窗口。两个请求同时读到“当前3人”都认为还能再报一人然后各自插入了一条记录最终变成5人。解决最稳妥的方式是把限制逻辑下沉到数据库。做法是在报名表上建一个unit event_id的联合唯一索引限制不了这个问题正确做法是对“每单位限报人数”做应用层加锁或者把计数更新放在一条SQL里用条件更新。课设阶段最简单的方案是对unit event_id做分组计数然后在插入前用SELECT ... FOR UPDATE锁住该项目的报名统计行。如果不想引入锁可以做一个冗余字段——在event表里加一个current_count每次报名用UPDATE event SET current_count current_count 1 WHERE id ? AND current_count limit_count这个方法用数据库的行锁天然防住并发更巧妙但同学们基本用不上并发场景。答辩时能说清楚“应用层判断不是线程安全的”比强行加锁更显水平。5.3 MyBatis-Plus实体类与数据库字段映射不一致现象用MyBatis-Plus的insert方法插入数据时报错“字段athleteId没有对应的列”或者查出来的gender字段永远是null。原因MyBatis-Plus默认开启驼峰映射athlete_id会自动映射到athleteId这本身没问题。问题往往出在两个地方一是实体类里字段名和数据库列名对不上比如数据库列叫student_no而实体类叫studentNo但实体类某个字段名拼写错了二是在application.yml里把map-underscore-to-camel-case配置成了false导致自动映射失效。解决检查配置文件确认以下配置存在mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto另外在实体类上建议显式加TableName注解字段上用TableField标注与数据库列名不一致的字段不要依赖默认命名规则Data TableName(athlete) public class Athlete { TableId(type IdType.AUTO) private Long id; private String name; private Integer gender; TableField(student_no) private String studentNo; private String unit; }TableField(student_no)是显式指定映射关系避免因为命名习惯问题导致字段匹配不上。还有一个现象是gender查出来是null常见原因是数据库是TINYINT但实体类用了Boolean接收MyBatis-Plus在类型转换时直接返回了null。这里统一用Integer最省心。5.4 成绩修改后名次没重算现象管理员修正了一个录错的成绩但排行榜没有变化。原因修改成绩的接口只更新了result表没有调用recalcRank方法。解决在成绩修改、成绩删除、成绩作废这三个操作的Service方法里统一在事务提交前调用recalcRank(eventId)。我建议把“排名重算”的逻辑抽成一个独立方法在每个入口都调用而不是只在某个业务方法里写一次。这也是一个典型的“业务一致性”问题——成绩数据变了但派生数据名次没有跟着更新。如果以后系统做大了可以考虑用消息队列异步重算但课设阶段同步调用就够了。5.5 前端传来的时间格式把后端接口打崩现象录入时选“12:55.44”这种带冒号的格式后端用BigDecimal接收直接报NumberFormatException。原因前端展示用的格式和后端存储用的格式没对齐。运动员跑出来的成绩是“12.55秒”这种带小数的数值但有的前端组件默认会把数字格式化成“12:55.44”这样的计时格式。解决统一后端接口只接收纯数字字符串或数值前端负责把计时器格式转换成12.55这样的浮点字符串再传给后端。如果前端实在要展示计时格式转换逻辑放在前端做完不要指望后端去解析“分:秒.百分秒”这种格式。后端要做的就是严格校验不合法就直接拒绝并返回提示而不是尝试去猜。6. 从“能跑”到“好用”赛程编排与检录单生成的进阶思路数据表、报名、成绩、排名、团体总分、导出走到这里系统已经“能用”了。但课设答辩和实际使用之间还有一道坎体育老师拿到系统后第一个额外需求往往是“能不能按项目自动生成检录单”——每个项目的参赛运动员名单打印出来检录时对着名单叫人。检录单的本质是把报名数据按项目维度重新组织。实现上不复杂查sign_up表联athlete表过滤出某个event_id的所有运动员按单位分组排序后渲染成一张可打印的HTML页面。打印用浏览器的window.print()就行不需要再走POI导出PDF。我在做这类功能时习惯先把数据查出来整理成MapEvent, ListAthlete的结构再交给模板渲染public MapEvent, ListAthlete getCheckListByEvent() { ListEvent events eventMapper.selectList(null); MapEvent, ListAthlete resultMap new LinkedHashMap(); for (Event event : events) { ListAthlete athletes signUpMapper.selectAthletesByEventId(event.getId()); // 按单位排序方便检录时按区域找人 athletes.sort(Comparator.comparing(Athlete::getUnit)); resultMap.put(event, athletes); } return resultMap; }这段代码的价值不在于技术难度而在于它把“报名数据”变成了“赛场可用的工具”。课设如果能做出检录单和成绩公告牌一个只读的排行榜展示页整体完成度会明显上一个档次——因为这些功能在需求答辩时人人都说要做但最终交出来的系统里大部分都没做。另一个进阶点是把时间相关的字段补上。现在的数据模型里没有比赛时间和场地实际使用时体育老师会希望每个项目有预定的开始时间运动员能看到自己几点参赛。给event表加一个scheduled_time字段在列表页按时间排序展示这比在代码里写死“第几天第几场”灵活得多。我做过的类似系统里最有成就感的一次改动是把Excel导出从“全部成绩表”细化为“每个项目一张单独的工作表”——一个Excel文件里几十个Sheet每个Sheet一个项目体育老师拿回去可以直接打印张贴。这个改动在POI里就是一个循环创建Sheet的事但使用体验完全不同。技术难点反而是Sheet名不能重复、不能包含特殊字符项目名称里有“/”就得替换掉这些细节只有真正做过才知道。最后说一个我自己的习惯项目做完后把建表SQL、初始化数据SQL、启动步骤写进一个README.md放在项目根目录。课设答辩时老师打开你的项目第一眼看到的如果是清晰的启动说明而不是一堆莫名其妙的文件印象分会高不少。我就因为这事在答辩时被夸过“工程习惯好”其实只是花二十分钟写了个文档的事。这个方向你如果能从“能跑”走到“能用”收获的不仅仅是课设分数。排名算法、事务边界、并发意识、导出实操每一条都是面试里实实在在的考察点。希望这篇笔记能帮你把系统做扎实少走我当年走过的弯路。本文还有配套的精品资源点击获取
返回列表