简介:这套田径运动会管理系统以Java为主要开发语言,面向需要学习Java信息管理系统开发的学生与开发者,用于解决运动会组织中的运动员报名、赛事安排、成绩记录与排名展示等问题。压缩包共81个文件,包含48个class与25个java源码文件,另有两份jar包、SQL数据库脚本、PDF实验报告及配置文件,整体大小约2.04MB,可配合Eclipse直接打开源码并初始化数据库。已有2168人学习下载。内容提供完整的项目源码、数据库脚本、可执行jar依赖以及详细实验报告,覆盖从系统架构、功能模块到实现过程的说明,适合用于课程设计、毕业设计或Java实战项目练习。资源还体现了软件工程实践,包含权限管理、信息发布和可扩展设计,便于学习者理解实际项目开发流程,并配有详细实验报告供模仿参考。
1. 运动会管理系统落地:先解决一场校运会报名统计的崩溃
每年校运会前,体育老师最头疼的不是比赛本身,而是报名表。Excel 发下去之后,各年级发回来几十个版本,光合并表格就能折腾两天;等到了比赛当天,检录表、成绩记录表、团体总分统计又得重新誊抄一遍,稍不留神就把第一名的成绩算错到别人头上。我拆过的这套 java 田径运动会管理系统,就是用来终结这种手工流程的——它把报名、赛程编排、成绩录入、团体排名整个串成一条线,裁判在手机端录成绩,大屏同步出排名,管理员后台能看见每个项目的实时状态。适合两类人:一是接手了类似运动会组织工作、想用信息化系统替代 Excel 的开发者;二是想做 Spring Boot 全栈实战、需要一个完整业务闭环练手的同学。围绕这个项目,下面从技术选型、核心代码、部署运行到二次改造,一条一条拆开讲。
2. 整体设计与技术栈:Spring Boot + MyBatis-Plus + Vue 的分层思路
2.1 功能模块拆解:从报名到成绩榜单的总链路
运动会管理系统的业务链路不算复杂,但胜在完整。整条线可以拆成四个环节:赛前准备、报名管理、赛中编排、赛后统计。赛前准备包括管理员维护运动会基本信息、比赛项目字典、参赛单位(班级或学院)、运动员账号;报名管理则由各参赛单位的负责人登录系统,为运动员选择参加的项目;赛中编排需要将每个项目的参赛运动员随机分组分道,生成检录表,并支持裁判录入成绩;赛后统计则按项目的计分规则(例如取前八名,按 9、7、6、5、4、3、2、1 计分)自动累加到团体总分,生成奖牌榜和积分榜。
我拆的这个项目并没有把功能做成大杂烩,而是严格按角色划分能力边界。管理员拥有全部权限,包括导入运动员名单、设置项目、调整赛程、仲裁成绩;裁判只拥有成绩录入权限,能看到自己被分配的比赛项目,录完提交后不可自行修改;参赛单位负责人只能维护本单位的运动员和报名信息。这样做的好处是:比赛期间裁判甚至不需要管理员账号,一个手机浏览器就能完成录入,避免了比赛现场账号互相借用导致的权限混乱。
从系统结构上看,前端是 Vue 搭建的单页应用,后端是 Spring Boot 提供的 REST API,中间走 JSON 交互。登录后前端拿到 JWT 令牌,每次请求带在 Authorization 头里,后端通过拦截器校验令牌并解析出当前角色。数据库用 MySQL,存储层用 MyBatis-Plus,连表查询少、单表操作多的场景下,它的条件构造器非常省事。缓存没有引入 Redis,因为运动会的并发量峰值也就几百人同时访问,MySQL 配合适当索引足够撑住,引入缓存反而增加维护成本。
2.2 目录结构:后端按业务分包,前端按页面组件化
后端目录结构遵循常见的"按业务模块分包"方式,而不是按技术层分包。com.某个包名下,先按模块区分:auth、user、event、registration、schedule、result,每个模块内部再放 controller、service、mapper、entity。这样做的好处是,改动一个业务模块时,相关文件都集中在一个目录下,不需要在 controller、service、entity 三个大包里来回跳。
前端目录则按页面和组件划分。src/views 下放运动会管理后台、裁判工作台、报名管理、成绩榜单等页面;src/components 放通用组件,比如运动员选择器、项目分组表格、成绩弹窗表单;src/api 目录统一封装 axios 请求,每个模块一个文件。前后端分离之后,联调的重点就在 API 路径约定和数据结构上,后面讲启动运行时我会专门说这一步。
这种分层的设计其实也在暗示一个改造空间:如果后续要加"运动会线上直播"或者"运动员风采展示",你不需要改动已有的报名和成绩核心模块,只需要新增一个 front 模块,在菜单表里加两个入口即可。因为整体已经按业务边界切干净了,新需求不会污染老代码。
2.3 数据库设计:几张核心表的字段与关系
拆项目的时候,我先从数据库脚本看起,因为表设计能直接反映业务复杂度。这个系统的核心表大概有六张:运动员表、项目表、报名表、赛程分组表、成绩表、用户表。其中报名表和赛程分组表是业务核心,它们决定了成绩计算的准确性。
运动员表字段包括 id、name、gender、class_name、unit_id(所属参赛单位)、student_no、phone、status。项目表包括 id、event_name、event_type(田径/径赛/田赛/全能)、gender_limit、age_group、score_rule(JSON 格式存储奖品名次与积分规则)。报名表是整个系统的关系纽带,字段有 id、athlete_id、event_id、registration_unit_id、status,并且建了 athlete_id + event_id 的唯一索引,用来在数据库层面防止重复报名。
赛程分组表记录每个项目的分组和道次信息,字段有 id、event_id、group_no、lane_no、athlete_id、schedule_time、status。成绩表则记录名次和得分,字段有 id、schedule_id、athlete_id、event_id、rank、score、raw_result(如"12.34秒"或"5.68米")、is_valid、confirmed_by、confirmed_time。设计上特意把 raw_result 和 score 分开,因为很多计分规则并非简单的名次对应分数,遇到破纪录加分时,需要在 score 上额外做调整,保留原始成绩后期比较。
2.4 技术选型与常见误区
这套系统在技术选型上走的是"务实"路线。后端没有引入微服务、消息队列这些重武器,因为运动会管理系统是典型的内部管理系统,用户量级小,数据一致性要求高,单体应用加事务就能解决所有问题。常见误区是一上来就搞分布式锁,实际上报名并发冲突用数据库唯一索引加事务重试就能处理,后面我会在避坑章节单独讲。
前端选择 Vue 而不是 React,主要考虑到国内开发者上手门槛低,Element Plus 组件库可以直接拿来做后台界面,表格、表单、弹窗这些高频组件开箱即用。实战中我还见过有人在这个项目里硬上 TypeScript + Pinia + Vite 全家桶,结果团队成员不熟悉类型约束,反而拖慢开发进度。Tech Stack 的选择一定要跟团队能力和业务复杂度匹配,这个项目用 JavaScript 写业务完全够,强上 TypeScript 属于自我感动。
3. 核心模块实现:从权限登录到编排分组的完整代码
3.1 登录鉴权与角色控制:JWT 令牌加拦截器
登录接口是前后端交互的第一道门。后端接收用户名和密码后,从用户表查出用户,用 BCrypt 匹配密码,成功后生成 JWT 令牌返回。令牌里只放 userId、username、role 三个字段,过期时间设置为 8 小时,比赛当天基本够用。
// AuthServiceImpl.java 核心登录逻辑 public LoginResponse login(LoginRequest req) { User user = userMapper.selectOne(new LambdaQueryWrapper<User>() .eq(User::getUsername, req.getUsername())); if (user == null || !BCrypt.checkpw(req.getPassword(), user.getPassword())) { throw new BizException("用户名或密码错误"); } String token = JwtUtil.createToken(user.getId(), user.getUsername(), user.getRole()); return new LoginResponse(token, user.getUsername(), user.getRole()); }这段代码的逻辑很直接:先按用户名查用户,再用 BCrypt 校验密码。BCrypt 的好处是每次校验时自动加盐,相同密码生成不同密文,比起 MD5+固定盐更安全。token 由 JwtUtil 工具类生成,内部用 HMAC-SHA256 签名,签名密钥配置在 application.yml 里。
后续每次请求,前端会把 token 放在 Header 里,后端通过拦截器统一校验。拦截器里做的事有三步:解析 token 是否合法,从 token 中取出用户信息,将用户信息放入 ThreadLocal 供后续业务代码直接获取。这里有个关键点:拦截器只判断"有没有登录",不判断"能不能访问某个接口",后者交给注解来做。
// AuthInterceptor.java 拦截器核心处理 public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); try { Claims claims = JwtUtil.parseToken(token); UserContext.set(claims.get("userId", Integer.class), claims.get("username", String.class), claims.get("role", String.class)); return true; } catch (Exception e) { response.setStatus(401); return false; } } response.setStatus(401); return false; }注意这里的参数:Authorization 头传过来的 token 一般带"Bearer "前缀,所以要截取前 7 个字符再解析。解析成功就把用户信息放入 UserContext,这是一个 ThreadLocal 容器。接口层通过@RequireRole("ADMIN")这类注解判断角色,注解通过 HandlerMethod 拿到后比对即可,这样登录和鉴权两个职责就分开了。
3.2 比赛项目与运动员报名:校验冲突的线程安全问题
报名是整个系统里最容易出 bug 的地方。业务规则是:同一运动员同一项目只能报一次,同一运动员不能同时报两个时间冲突的项目,每个参赛单位每个项目报名人数不能超过上限。前两条规则如果只靠前端控制,后端一定会被并发穿透。
我的做法是在数据库层面建唯一索引,再在后端做事务校验。数据库唯一索引已经能挡住"同一运动员同一项目重复报名",但"时间冲突"和"人数上限"必须用事务加锁来处理。
// RegistrationServiceImpl.java 报名核心逻辑 @Transactional(rollbackFor = Exception.class) public void register(RegistrationRequest req) { // 1. 锁定运动员记录,防止并发修改 Athlete athlete = athleteMapper.selectByIdForUpdate(req.getAthleteId()); // 2. 检查项目时间冲突 List<Registration> existed = registrationMapper.selectList( new LambdaQueryWrapper<Registration>() .eq(Registration::getAthleteId, req.getAthleteId()) .eq(Registration::getStatus, 1) .in(Registration::getEventId, findConflictEventIds(req.getEventId()))); if (!existed.isEmpty()) { throw new BizException("该运动员已有时间冲突的项目报名"); } // 3. 检查单位报名人数上限 Long count = registrationMapper.selectCount( new LambdaQueryWrapper<Registration>() .eq(Registration::getEventId, req.getEventId()) .eq(Registration::getUnitId, athlete.getUnitId())); if (count >= eventService.getMaxPerUnit(req.getEventId())) { throw new BizException("本单位在该项目已达报名上限"); } // 4. 插入报名记录 registrationMapper.insert(new Registration(req.getAthleteId(), req.getEventId(), athlete.getUnitId())); }这里selectByIdForUpdate是 MyBatis-Plus 提供的方法通过FOR UPDATE锁行,锁住运动员记录后,同一个运动员的并发报名请求会串行执行,避免两个事务同时读到不存在冲突然后都插入成功。时间冲突的检查通过 findConflictEventIds 查出一个项目的起止时间范围,再查出该运动员已报项目中时间有重叠的项目集合。
有个细节容易踩坑:事务方法里如果 try-catch 吞掉了异常,事务不会回滚。所以上面的代码故意不用 try-catch,而是让 BizException 直接抛出去,由全局异常处理器统一转换响应。报名的唯一索引创建语句是UNIQUE KEY uk_athlete_event (athlete_id, event_id),这条索引是并发防重的最后一道防线。
3.3 赛程编排与分组:按道次随机分配的算法
赛程编排是把报名成功的运动员按项目进行分组分道。径赛项目需要分组决定道次,田赛项目主要决定出场顺序。分组算法要求随机但不重复:同一单位的运动员不能全部排在优势道次,也不能让同一单位的两人分在同一组(初赛阶段)。
实现上我用了一个简单的贪心随机算法:先把某项目的所有参赛运动员按单位打乱,然后按"每个单位最多一人出现在同一组"的规则依次填充到各组。
// ScheduleServiceImpl.java 分组算法核心 public List<List<AthleteEvent>> groupAthletes(List<AthleteEvent> athletes, int maxPerGroup) { Collections.shuffle(athletes); // 全局随机打乱 Map<Integer, Integer> unitGroupCount = new HashMap<>(); List<List<AthleteEvent>> groups = new ArrayList<>(); for (int i = 0; i < maxPerGroup; i++) { groups.add(new ArrayList<>()); } int unitIdIndex = 0; // 按单位轮流分发,避免同组同单位 Map<Integer, List<AthleteEvent>> byUnit = athletes.stream() .collect(Collectors.groupingBy(AthleteEvent::getUnitId)); List<Map.Entry<Integer, List<AthleteEvent>>> unitEntries = new ArrayList<>(byUnit.entrySet()); Collections.shuffle(unitEntries); int groupIdx = 0; for (Map.Entry<Integer, List<AthleteEvent>> entry : unitEntries) { for (AthleteEvent ae : entry.getValue()) { // 找到当前组里没有该单位运动员的位置 while (groups.get(groupIdx).stream() .anyMatch(g -> g.getUnitId().equals(ae.getUnitId()))) { groupIdx = (groupIdx + 1) % maxPerGroup; } groups.get(groupIdx).add(ae); groupIdx = (groupIdx + 1) % maxPerGroup; } } return groups; }这个算法的思路是先把运动员按单位分组,然后轮转地塞到各个组里,如果当前组已有该单位的运动员,就顺延到下一组。效果是每个组内的单位尽量分散,同时全局随机。分组完成后再对组内运动员重新打乱一次,按顺序分配道次号,避免出现同一个单位固定占据某一根道的情况。
参数 maxPerGroup 是每个组最多人数,一般根据跑道数或项目类型配置,比如 100 米预赛每 8 人一组。调用方可以先根据报名总人数除以每组人数计算出需要几个组,再把剩余人数均匀塞到前面的组。
3.4 成绩录入与排名计算:按项目规则动态计分
成绩录入是裁判的核心操作,录入界面通常是一个表格,列出当前分组的运动员和道次,裁判填写时间或距离,后端校验成绩格式后自动排序、赋分。计分规则不能写死在代码里,因为运动会有的项目取前六名,有的取前八名,分数也不一定等差。
我把计分规则存储在项目表的 score_rule 字段里,用 JSON 数组表示,例如[{"rank":1,"score":9},{"rank":2,"score":7},{"rank":3,"score":6},{"rank":4,"score":5}]。成绩录入后,后端的排名计算从 score_rule 中找出对应名次的得分,写入成绩表。
// ResultServiceImpl.java 成绩提交与排名 @Transactional(rollbackFor = Exception.class) public ResultRecord submitResult(ResultSubmitRequest req) { // 1. 校验裁判权限已经在上层完成,这里直接处理业务 List<ResultEntry> entries = req.getEntries(); entries.sort(Comparator.comparing(ResultEntry::getRawResult)); // 按成绩升序排序 // 2. 获取项目计分规则 Event event = eventMapper.selectById(req.getEventId()); List<ScoreRule> rules = JSON.parseArray(event.getScoreRule(), ScoreRule.class); // 3. 遍历每条成绩记录,判断是否有效并给分 ResultRecord record = new ResultRecord(); record.setEventId(req.getEventId()); record.setScheduleId(req.getScheduleId()); record.setItems(new ArrayList<>()); for (int i = 0; i < entries.size(); i++) { ResultItem item = new ResultItem(); item.setRank(i + 1); item.setAthleteId(entries.get(i).getAthleteId()); item.setRawResult(entries.get(i).getRawResult()); // 取前若干名才给分 ScoreRule rule = rules.stream() .filter(r -> r.getRank() == i + 1) .findFirst().orElse(new ScoreRule(0)); item.setScore(rule.getScore()); item.setValid(rule.getScore() > 0); record.getItems().add(item); } // 4. 保存并级联更新团体总分 resultMapper.insert(record); unitScoreService.refreshTotalScore(req.getEventId()); return record; }这段代码的核心是"按排序结果自动赋名次和分值",不依赖人工手动输入名次,减少裁判操作。参数 req 里携带的是某个分组下所有运动员的原始成绩列表,后台按成绩升序排列,成绩优的排前面。这里假设径赛项目数字越小成绩越好,田赛项目(跳远、铅球)数字越大越好,所以需要在上层调用前把原始成绩转换成一个可比较的数值字段。
团体总分刷新是个容易忽略的点。成绩录入后,得分变了,团体总分不能等赛后统一算,要在每次提交成绩后增量更新。refreshTotalScore 方法会先清除该项目现有的团体得分,再重新累加,这样即使成绩被修正也能保证总分同步。数据库事务保证了成绩表和团体总分表永远处于一致状态,这也是后期计算排名不翻车的根本。
4. 项目启动与运行:配置文件、初始化数据、前后端联调
4.1 环境准备与数据库初始化
运行这个项目前,需要准备好三样基础环境:JDK 8 以上版本(建议 JDK 8 或 11 长期支持版)、MySQL 5.7 及以上、Node.js 14 以上(前端构建用)。MySQL 需要先建好数据库,推荐创建专属库,字符集设为 utf8mb4,因为运动员姓名可能有生僻字,utf8mb4 能存四字节字符。
数据库初始化文件在项目的doc/sql目录下,包含建库语句、建表语句和初始数据。我拆到的版本里,初始化脚本已经写好了一个管理员账号(admin)和几个测试运动员账号,密码统一加密存储。第一次导入时用source命令直接执行整个 SQL 脚本即可,不需要手动逐条执行。建议在导入前先看一眼脚本里的 DELETE 语句,确认不会误删已有的其他数据。
# 初始化数据库 mysql -u root -p < doc/sql/init_db.sql如果是在 Windows 命令行环境下,可以进入 MySQL 客户端后用source D:/path/init_db.sql;执行。导入成功的标志是最后出现几条 Query OK 提示。常见翻车点是 SQL 脚本里使用了存储过程或自定义函数,需要确认 MySQL 的用户允许创建函数,否则会报 1418 错误,解决办法是在命令行加--log-bin-trust-function-creators=1参数重启 MySQL。
4.2 后端启动步骤与 application.yml 配置说明
后端配置集中在src/main/resources/application.yml。里面最需要改的是数据源、JWT 密钥、文件存储路径三段。我一般习惯把数据库密码放到环境变量里而不是写死在 YAML 文件,避免代码泄露到 Git 仓库后账号密码跟着泄露。
spring: datasource: url: jdbc:mysql://localhost:3306/sports_meeting?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: ${DB_PASSWORD:123456} servlet: multipart: max-file-size: 10MB mybatis-plus: mapper-locations: classpath:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl jwt: secret: your-secret-key-change-in-production expire-hours: 8这段配置里有几个关键参数:数据源 URL 里的serverTimezone=Asia/Shanghai是必须的,MySQL 8 默认时区设置不对会报连接错误;map-underscore-to-camel-case开启后,数据库的class_name字段能自动映射到 Java 的className属性,省去手写 ResultMap 的工作;log-impl设为 StdOutImpl 后,开发环境能看到每条 SQL 的完整参数,排查问题时非常有用,上线前记得关掉。
启动后端的命令很简单,在项目根目录执行mvn spring-boot:run或者直接用 IDE 运行主类。启动成功的标志是日志中出现Started Application in x.xxx seconds且没有异常堆栈。如果启动时报数据库连接失败,大概率是密码不对或时区参数没写对,按提示把 URL 里的时区改成Asia/Shanghai即可。
4.3 前端启动与代理转发
前端是标准 Vue 工程,进入frontend目录安装依赖并启动开发服务器。
cd frontend npm install npm run serve默认开发服务器端口是 8080,后端接口地址是 8081。为了避免开发时跨域,我在vue.config.js里配置了 devServer 代理,把所有/api开头的请求转发到后端服务地址。这样前端代码里请求都写成相对路径,部署时只需要改代理目标。
// vue.config.js 代理配置 module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true, pathRewrite: { '^/api': '' } } } } };这里pathRewrite把/api前缀去掉,所以后端接口实际路径是http://localhost:8081/login,前端请求写成/api/login。这个配置还解决了 cookie 跨域问题,因为浏览器认为所有请求都在同一个域下,不会触发 CORS 预检。如果不想用代理,后端也可以配置 CORS 允许跨域,但代理方式在生产环境里更常见,因为前端构建后的静态文件可以由 Nginx 统一托管,反向代理到后端。
4.4 验证功能:从管理员到裁判的完整流程
启动完前后端后,建议按一条主线走通整个流程来验证系统可用性。第一步用管理员账号登录,进入"运动会设置"页面,创建一届运动会,录入比赛项目。第二步在"运动员管理"页面导入运动员 Excel,或者手工添加几个运动员。第三步用参赛单位负责人账号登录,为运动员报名参加项目,检查报名人数限制是否正确。第四步在"赛程编排"页面选择项目,点击自动分组,查看生成的检录表。第五步用裁判账号登录,进入"成绩录入"页面,选择对应的分组,录入一组模拟成绩,提交后查看个人名次和团体总分是否按规则计算。
如果每一步都符合预期,说明系统的核心链路已经跑通。验证过程中特别要关注报名阶段的报错提示是否正确、成绩提交后分数是否立即刷新到团队总分。一个隐藏的检查点是:用同一个运动员账号重复报名同一项目,后端必须返回"已报名"错误,而不是抛出 500 异常。这能验证数据库唯一索引和事务是否起作用。
5. 避坑与排查:我从这个项目里学到的几件后悔事
5.1 并发报名导致同一运动员重复报项
现象:测试时用两个浏览器同时为同一运动员报名同一个项目,两个请求都显示成功,数据库里出现两条重复记录。
原因:第一个请求执行完插入前,第二个请求已经通过了"该运动员该项目是否已报名"的查询条件。两个事务彼此看不到对方未提交的数据,这就是经典的"事务隔离级别下的并发空窗"。
解决:除了业务查询校验,一定要在数据库层加唯一索引uk_athlete_event。我在改造时还加了一层selectByIdForUpdate锁行,让同一运动员的报名请求串行化。从那以后并发报名测试再也没出现过重复数据。
5.2 成绩录入时小数精度与计分规则不一致
现象:裁判录入跳远成绩 5.75 米,数据库存成 5.75,但排名计算时被当成 5.7 处理,导致名次和得分错误。
原因:原始成绩在实体类中使用了Float类型,MySQL 中对应字段是float,而 5.75 转成二进制浮点数后存在微小误差,排序比较时精度丢失。
解决:将成绩字段改为BigDecimal,数据库字段类型改为decimal(10,2)。入库时用new BigDecimal(String value)而不是Float.parseFloat转换,避免构造时的精度丢失。
5.3 跨域配置失效导致前端请求进不来
现象:前端开发服务器访问接口时,F12 控制台出现 "CORS policy: No 'Access-Control-Allow-Origin' header",所有请求全部失败。
原因:前后端分离场景下,前端地址是http://localhost:8080,后端是http://localhost:8081,浏览器认为这是跨域请求。我最初在后端加了@CrossOrigin注解,但只加在单个 Controller 上,其他接口没覆盖到。
解决:统一用全局过滤器添加 CORS 响应头,或者干脆用 Webpack 代理转发。我选择了代理方式,把跨域消解在开发服务器层,生产环境用 Nginx 做反向代理,两边都不需要额外配置跨域,少踩很多坑。
5.4 刷新页面后登录态丢失
现象:前端页面刷新后,跳回登录页,用户需要重新输入账号密码,比赛现场混乱。
原因:token 被放在sessionStorage里,这个存储空间在浏览器标签页关闭后自动清除。而且我没有配置"记住我"逻辑,刷新页面时没有用旧令牌重新拉取用户信息。
解决:把 token 改为存储在localStorage,并在应用初始化时判断 localStorage 里有没有 token,有就尝试调用/auth/current_user接口恢复用户上下文。同时给用户提供"记住我"勾选框,勾选后 token 存 localStorage,否则存 sessionStorage。
5.5 数据库连接池参数不合适导致高峰期掉线
现象:比赛当天几百名裁判同时录入成绩,系统突然报 "Connection is not available, request timed out",页面无法访问。
原因:开发环境连接池默认最大连接数是 10,生产环境没有调整。高峰期并发录成绩时,多个线程同时占用连接,一旦某个慢查询拖住连接,其他请求就排队等连接超时。
解决:在 application.yml 中调整 Hikari 连接池配置,设置maximum-pool-size: 50,minimum-idle: 10,并加上connection-timeout: 30000。另外把成绩录入接口里的一些聚合查询改用 Redis 缓存结果,减少对数据库的重复查询。经过压测,高峰期可以稳定支撑。
6. 二次开发改造:给系统增加多届运动会数据隔离
如果你想把这个单届运动会系统改造成能连续使用多年的版本,最核心的改动就是引入"届次"维度。原系统的所有业务表都只关联了当前这一届的数据,没有历史届次的概念。改造的第一步是给核心表增加session_id或year字段,例如运动会表增加session_name(如"2024 年秋季运动会"),报名表、成绩表、分组表都增加session_id外键。然后在所有查询 service 层统一从登录用户上下文或者请求参数中获取当前届次,添加到每个查询条件里。
改造过程中最需要注意的是成绩统计的历史数据隔离。原来的团体总分统计是从所有成绩表数据里聚合,改造后必须先按session_id过滤。一个小技巧是,在 MyBatis-Plus 的查询条件构造器里封装一个eqSession(Wrapper, sessionId)的静态方法,所有涉及业务表的查询都过这个工具方法,避免遗漏某个查询导致新老数据混在一起。
// SessionFilterUtil.java 统一届次过滤工具 public static void apply(LambdaQueryWrapper<?> wrapper, Integer sessionId) { if (sessionId != null) { wrapper.eq(Event::getSessionId, sessionId); } }这里参数sessionId从登录后的用户上下文取,管理员可以切换当前主办的届次。切换后所有榜单和统计都会自动变成新届次的数据,报名和编排也基于新届次重新开始。验证方法是:在数据库中为同一届运动员报名并录入一组成绩,然后切换届次,观察原届次的成绩和排名不受影响;再切回来确认数据仍在。
做完这个改造后,系统的生命周期就从"一届赛事"变成了"多年运营平台",每一次运动会的数据都是独立可查的,历史成绩对比、优秀运动员档案这些后续功能也都可以在这个基础上延伸。当初我拆这个项目时,因为没有提前留 session_id 字段,后续加这个功能时改了十几张表,还手工修了一次历史数据,颇为折腾。从那以后我每接手一个管理系统,都会先问一句:这份数据以后会不会有多个年份?如果会,一开始就在设计里把"租户/届次"字段加进去。希望这段踩坑经验帮你在做二次开发时少走点弯路。
本文还有配套的精品资源,点击获取