☰
基于Spring Boot和Vue的校园体育赛事管理系统设计与实现
2026/10/9 16:09:07 网站建设 项目流程

做校园体育赛事管理系统这个项目,其实最早是帮我们学院学生会的一个忙。往年校运会报名都是各班体育委员发Excel表格,学生会再手动汇总,几百号人的项目分类、赛程编排要折腾好几天,还经常出错。后来校运会改成线上报名,各种bug层出不穷——截止时间到了还有人能改、成绩录错找不着操作记录、排名算错没法解释。我才意识到,这种看起来"小"的管理系统,真要做扎实了,里面的门道不比做电商后台少。所以就有了这个基于Spring Boot + Vue的校园体育赛事管理系统,从报名、分组、赛程编排到成绩录入、积分排名,一套流程全部线上化。这篇文章就完整复盘一下这个项目的设计与实现过程,希望能给正在做类似"业务管理系统"(不管是赛事、活动还是社团)的同学一些参考。

1. 项目拆解与整体设计思路

1.1 校园赛事管理到底在管什么

开始编码之前,我花了两天时间把学生会的业务流梳理了一遍。体育赛事管理系统表面上不就是"报名+录入成绩",但实际业务远比这复杂。我把核心流程拆成四个阶段:赛前筹备阶段、报名审核阶段、赛中执行阶段、赛后统计阶段。

  • 赛前阶段要处理的是:赛事创建(春季运动会、篮球联赛等)、比赛项目定义(100米、跳远、男子篮球)、组别设置(男子组/女子组/团体赛)、参赛资格限制。
  • 报名阶段是并发压力最大的环节:各班级体委给多名学生批量报名,单项限报人数控制,每人限报项目数控制,跨组别误报拦截。
  • 赛中阶段最重要:裁判录入成绩、成绩审核、破纪录标记、实时排名刷新。
  • 赛后阶段看似简单但坑最多:团体总分怎么算(单项积分累加还是按名次加权重)、并列名次怎么处理、优秀组织奖怎么评选。

系统设计时就把这四个阶段的边界划清楚,后端按模块划分服务,前端按阶段组织页面。这样做的好处是:如果明年赛制改了个规则(比如团体分改成1、3、5、7名次权重),我只需要改成绩计算这一个模块,不影响报名和赛程。这个模块化边界的划分是第一个设计决策,后面所有代码都是围绕这个骨架展开的。

1.2 技术选型为什么是Spring Boot + Vue

技术选型上我基本没犹豫。后端用Spring Boot是必然选择:第一,Java生态在学校场景里最稳妥,学院机房和服务器普遍装的是JDK 8以上环境,部署不折腾;第二,Spring Boot的自动配置把大量繁琐的配置项收敛了,一个内嵌Tomcat的jar包就能跑起来,这对没人专职运维的学校服务器太重要了;第三,用MyBatis-Plus操作单表CRUD近乎零成本,而复杂的联表统计又可以用XML写原生SQL控制。

前端选Vue同样是务实考量。Vue 3 + Element Plus组件库做管理后台效率极高,表格、表单、弹窗、日期选择器都是现成的,不需要从零写DOM。Vue Router做页面路由,Pinia做共享状态(存用户信息、赛事ID这些全局数据),Axios统一封装请求。相比React,Vue的中文资料和社区方案更丰富,遇到问题搜一下就能找到答案,对学生开发者友好得多。

有一点我要明确:这个系统的定位是"校园级",不是"互联网级"。所以没有上Redis缓存、没有RabbitMQ消息队列、没有微服务,这些都是合理的——业务体量决定了架构复杂度。凡是跟你说"必须上XX中间件"的,要么是炫技,要么是被培训机构的课表洗脑了。我在选型时的一条原则就是:能简单绝不复杂,一个MySQL5.7数据库 + 一个Spring Boot应用 + 一个Nginx静态托管Vue,足够了。

1.3 项目的核心用户角色拆解

把用户角色直接对应到权限模型上,我设计了三类基础角色:管理员、裁判(教师/工作人员)、学生(含班级体委)。注意我加了一个"体委"的能力扩展,它不是独立角色,而是学生角色上的一个标志位。

  • 管理员:创建赛事、维护项目库、设置报名规则、管理全部数据、发布公告。
  • 裁判/工作人员:录入与修改成绩(必须有操作日志)、查看赛程安排、打印成绩单。
  • 学生:浏览赛事、报名/取消报名、查看自己的成绩和名次。
  • 体委(学生的扩展):可以给本班同学批量报名、代报、查看本班报名统计。

这个角色模型决定了后端接口的权限控制粒度。我用了Spring Security + JWT,没有引入更重的Shiro或Sa-Token。用@PreAuthorize注解在Controller方法上做权限拦截,比如"录入成绩"只允许ROLE_REFEREE和管理员调用。JWT无状态认证也方便前端把token存在localStorage里,拦截器统一加到请求头,后端解析后放到SecurityContext里,干净利落。

2. 数据库设计与后端核心实现

2.1 核心数据表怎么设计才够用

数据库设计是整个项目的基石。我前后改了四版,最终稳定在10张核心表,这里挑重点说:

  • user表:用户基础信息,包括username、password(BCrypt加密)、real_name、student_no(学号)、class_name、role(枚举字符串)、is_committee(是否体委)等。注意我特意存了class_name冗余字段,而不是单独建班级表,因为校园场景下班级信息基本不变化,没必要增加关联复杂度。
  • event表:赛事表,存赛事名称、赛事状态(draft/registration/ongoing/finished)、开始与结束时间、举办地点、描述。状态机是这表的灵魂。
  • sport_item表:项目表,存项目名称、类别(track/field/ball)、性别限制(male/female/all)、报名人数上限、比赛规则描述。
  • event_item表:赛事与项目的关联表,因为同一个项目可以在不同赛事中设置不同的报名限制(比如校运会100米限报4人/班,但院运会限报2人/班),所以把"限额"放在关联表里而不是项目表。这个细节是我第二版才想明白的。
  • registration表:报名记录表,核心字段有student_id、event_item_id、status(pending/approved/rejected)、reject_reason、created_at。唯一索引必须建在(student_id, event_item_id)上,防止同一人重复报名同一项目。
  • schedule表:赛程表,存比赛项目、分组编号、比赛时间、比赛地点、裁判ID。
  • score表:成绩表,存schedule_id、student_id、成绩值、成绩单位、名次、是否破纪录、积分、录入人、录入时间。成绩值和名次分开存储,因为田赛(跳远)和径赛(100米)的计量方式完全不同。
  • operation_log表:操作日志表。这个是隐藏功臣——所有写操作都记录操作人、操作时间、操作内容、请求IP。后来真有裁判录错成绩,就是靠它追回的。

表结构见下方精简SQL,这是我认为"刚好够"的一种设计形态。此处仅为示意,真实项目中还需要根据业务调整字段长度和索引:

CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL, `real_name` VARCHAR(50) NOT NULL, `student_no` VARCHAR(20) NOT NULL UNIQUE, `class_name` VARCHAR(50) DEFAULT '', `role` VARCHAR(20) NOT NULL DEFAULT 'student', `is_committee` TINYINT(1) DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE `event` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL, `status` VARCHAR(20) NOT NULL DEFAULT 'draft', `start_date` DATE, `end_date` DATE, `location` VARCHAR(200), `description` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE `sport_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL, `category` VARCHAR(20) NOT NULL, `gender_limit` VARCHAR(10) NOT NULL DEFAULT 'all', `max_participants` INT DEFAULT 0, `rule_desc` VARCHAR(500), `unit` VARCHAR(20) DEFAULT NULL COMMENT '成绩单位,如秒/米' ); CREATE TABLE `event_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `event_id` BIGINT NOT NULL, `item_id` BIGINT NOT NULL, `quota_per_class` INT DEFAULT 0 COMMENT '每班限报人数', `max_total` INT DEFAULT 0 COMMENT '总人数上限' ); CREATE TABLE `registration` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `student_id` BIGINT NOT NULL, `event_item_id` BIGINT NOT NULL, `status` VARCHAR(20) NOT NULL DEFAULT 'pending', `reject_reason` VARCHAR(200), `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_student_item` (`student_id`, `event_item_id`) ); CREATE TABLE `schedule` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `event_item_id` BIGINT NOT NULL, `group_no` INT DEFAULT 1, `round` INT DEFAULT 1 COMMENT '第几轮', `start_time` DATETIME, `location` VARCHAR(100), `referee_id` BIGINT ); CREATE TABLE `score` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `schedule_id` BIGINT NOT NULL, `student_id` BIGINT NOT NULL, `score_value` VARCHAR(50), `ranking` INT, `is_record_breaking` TINYINT(1) DEFAULT 0, `points` DECIMAL(5,1) DEFAULT 0, `recorded_by` BIGINT NOT NULL, `recorded_at` DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE `operation_log` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `operator_id` BIGINT, `action` VARCHAR(50), `target_type` VARCHAR(50), `target_id` BIGINT, `detail` VARCHAR(500), `ip` VARCHAR(50), `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP );

索引方面有几点实际经验:报名表的联合唯一索引是最关键的,没有它你能在并发下录进去无数条重复报名。score表要建(schedule_id, ranking)索引,因为排名页面的查询经常按这个条件。schedule表要建(event_item_id, start_time)索引,赛程列表页按项目和时间段筛选是高频操作。所有外键关系我都保留了,但没有真正建物理外键约束,而是靠代码层面保证关联性,为什么?校园系统经常要手工调整数据(比如补录、批量修复),物理外键在这种场景下只会添乱。

2.2 用MyBatis-Plus还是手写SQL

MyBatis-Plus在单表CRUD上确实香,但我的经验是:超过三张表的关联查询,或者带了复杂条件动态拼装,就别用它的Wrapper硬凑了,直接上XML里手写SQL。硬凑出来的Wrapper代码可读性极差,改一个条件要琢磨半天,远不如查看XML里的SQL一眼看清逻辑。

我举一个实际例子:赛程列表页需要展示:项目名称、组别、时间、地点、裁判姓名、已报名人数/上限。这涉及:schedule表 + event_item表 + sport_item表 + user表(裁判姓名)+ 聚合子查询(报名人数)。用MP的QueryWrapper去写,条件嵌套多不说,关联查询也不好表达,最终我是在Mapper的XML里写了这样一段:

<select id="selectScheduleList" resultType="com.example.vo.ScheduleVO"> SELECT s.id, si.name AS item_name, si.category, si.gender_limit, e.name AS event_name, s.group_no, s.round, s.start_time, s.location, u.real_name AS referee_name, (SELECT COUNT(*) FROM registration r WHERE r.event_item_id = s.event_item_id AND r.status = 'approved') AS registered_count, ei.max_total FROM schedule s LEFT JOIN event_item ei ON s.event_item_id = ei.id LEFT JOIN sport_item si ON ei.item_id = si.id LEFT JOIN `event` e ON ei.event_id = e.id LEFT JOIN `user` u ON s.referee_id = u.id WHERE s.event_item_id IN ( SELECT id FROM event_item WHERE event_id = #{eventId} ) ORDER BY s.start_time </select>

这种查询用XML写出来,整个联调过程中我和前端同学都没有产生过"这个字段到底是哪个表的"的歧义。关于MyBatis-Plus还有一个重要用法:数据库建表语句可以直接用Java实体类生成。我在IDEA里装了个MyBatisX插件,实体类写好注解后右键就能生成建表SQL,甚至还能反向生成实体类。这个效率提升是实打实的,比手写几十行建表语句快得多。

2.3 认证授权和操作日志

登录流程我用的是Spring Security + JWT这套标准组合,但有些细节我想展开说。

JWT的过期时间设置是个需要权衡的点。我设置的是2小时过期,然后前端在Axios拦截器里做统一处理:如果返回401,就跳转登录页并清除本地token。有的项目图省事把过期时间拉长到7天,这在校园系统里问题不大,但安全隐患确实存在——学生共用机房电脑很常见,token存在localStorage里,忘了退出登录下次别人能直接登进去。我后来加了一个简单的安全策略:改密码后强制token失效,实现方式是给user表加一个password_changed_at字段,JWT生成时把这个时间戳作为jti的一部分带进去,校验时比对一下是否一致。逻辑不复杂,但能堵住很多实际运营中的安全漏洞。

操作日志我是在Service层通过注解+AOP实现的,自定义了一个@OpLog("录入成绩")注解,切面里记录方法名、参数、操作人、时间。这里有个细节:参数里如果直接存对象,序列化时可能因为JSON循环引用导致日志写入失败,所以我是手动提取关键参数的。操作日志的查询界面做了时间和操作人两个筛选条件,有一次学生会的老师要查"上周三到底是谁改了一号场地的赛程时间",这个功能直接给出了答案,当场就觉得这个模块做得值。

3. 前端架构与页面实现细节

3.1 Vue3工程怎么组织和搭起来

前端我用的是Vue 3 + Vite + Element Plus + Pinia + Vue Router组合。Vite的冷启动速度比Webpack快了一个量级,开发体验完全不一样。工程结构方面我按业务模块划分,而不是按技术类型划分,这点和很多初学者习惯的"一堆views文件夹平铺"不一样:

src/ ├── api/ # 按模块拆分的接口请求封装 │ ├── event.js │ ├── registration.js │ ├── score.js │ └── user.js ├── router/ # 路由配置,包含动态路由逻辑 ├── stores/ # Pinia状态声明 │ └── user.js ├── views/ │ ├── dashboard/ # 赛事总览仪表盘 │ ├── event/ # 赛事管理页 │ ├── registration/ # 报名管理页 │ ├── schedule/ # 赛程管理页 │ ├── score/ # 成绩管理页 │ └── statistics/ # 排名统计页 ├── components/ # 公共组件 └── utils/ └── request.js # Axios封装

路由方面我加了动态路由:用户登录后,前端根据角色从后端拉取可访问的路由配置,router.addRoute()动态注入。这样学生登录后根本不可能访问到管理后台的URL,比单纯"前端菜单里隐藏"可靠得多。不过这里有个坑:动态路由在刷新页面后会丢失(因为store里的数据重置了),必须在路由守卫里重新拉取再addRoute,而且要注意重复addRoute会警告,用router.hasRoute()判断一下。

3.2 报名页面的前端交互设计

报名模块的前端交互是这个项目里最考验细节的部分。学生端报名分为"个人项目"和"团体项目"两种操作路径。

个人项目报名页面长这样:左侧是赛事和项目列表(按田赛/径赛分类),右侧是已选项目清单。关键交互点有三个:

  • 点击"报名"按钮之前,前端先发一个"查询资格接口",后端返回这个学生还能不能报、超没超过限报项目数。但注意:这个校验不能只靠前端,后端在提交时必须做最终的并发校验,否则别人在最后几秒抢占了名额。
  • 已满员的项目,前端要把"报名"按钮置灰,并且显示"已满"标签。这个状态需要实时从后端获取,轮询或者进入页面时拉取一次,这里我选的是进入页面拉取 + 提交后刷新,没有做WebSocket实时推送,对这个场景够了。
  • 报名成功后需要一个成功态反馈,让用户明确知道"提交成功了",否则学生通常不确定就会反复提交,反而制造更多数据问题。

体委批量报名是我觉得最有价值的一个功能,它的交互是一个可搜索可多选的学生选择器。这个组件我用Element Plus的el-select+remote-method实现远程搜索:体委输入学号或姓名关键字,前端调用后端搜索接口,返回匹配的学生列表,勾选后一起提交。后端接口做了班级范围校验——体委只能搜索到自己班级的学生,防止越权操作他人。

3.3 成绩录入和实时候排名更新

成绩录入是裁判最常用的页面。我一开始做的是每个项目一个表格,成绩手动填,提交后刷新排名。但实际用下来体验很差——田赛项目几十号人,要翻页找某个运动员,录完还要记名次,特别容易错。

后来我改成了按组批量录入模式:裁判选择比赛项目和组别,页面展示该组所有参赛学生(两个表格放一起:径赛按道次,田赛按出场顺序),成绩列是输入框。裁判按顺序依次填入成绩,填完一个组点提交,后端批量校验并计算排名/积分。提交前会弹出确认框,展示每个学生的名次计算结果,确认后才落库。这一步确认非常关键,因为名次一旦被后续排名统计消费掉,再修改就要级联更新所有下游数据。

排名统计页有几个快捷筛选:按赛事、按项目、按班级,默认展示"项目排名"视图,可切换"团体总分"视图。团体总分榜是用的服务端计算:先算单项每个人的积分,再按班级聚合SUM。这里有个细节:如果某项目有并列名次,比如两个人都跳了2米10并列第一,规则是下一个名次是第三名(跳过第二名),积分按"1、3、5"而不是"1、2、3"来算,这个规则是在积分规则表里配置的,通过代码在计算时校验。所有积分配置支持动态修改,且修改后可以触发"重新计算",这样就不用担心规则临时调整。

4. 权限控制和并发问题的深度处理

4.1 用Spring Security精细到按钮级别

权限控制在后端Controller上是注解式的,但光做到接口级别还不够——按钮级权限也得考虑。比如:裁判进入成绩管理页,能看到"编辑"和"删除"按钮,但普通管理员如果也要进入该页面(只读),就不能看到这些操作按钮。我封装了一个v-permission自定义指令,从Pinia的store里取当前用户角色,根据角色判断DOM元素是否渲染:

const permissionDirective = { mounted(el, binding) { const roles = useUserStore().roles; const requiredRoles = binding.value; // 例如 ['admin', 'referee'] if (!requiredRoles.some(role => roles.includes(role))) { el.parentNode?.removeChild(el); } } };

这套方案在Vue3的 classrooms里实测有效,但它和v-if的语义有一个细微差别:v-permission是直接移除元素而不是控制渲染,在权限变化(比如角色切换)时不会自动恢复。解决方式是在权限变化后强制刷新当前路由页面。用下来总体OK。

4.2 报名人数超限的并发控制

这是我实现过程中最典型的并发坑。参考下面这个超卖问题的处理方式:100米项目限报50人,在报名截止瞬间,第50和第51个人同时提交,后端如果先查后插,那大概率两个人都能插入成功——因为查的时候都看到剩余名额是1个。

我的解决方案其实很朴素:利用数据库唯一键和原子更新。

@Transactional public RegistrationResult register(Long studentId, Long eventItemId) { // 1. 原子扣减剩余名额,返回受影响行数 int updated = eventItemMapper.deductQuota(eventItemId); if (updated == 0) { throw new BusinessException("该赛事项目名额已满"); } try { // 2. 插入报名记录(唯一索引兜底) Registration registration = new Registration(); registration.setStudentId(studentId); registration.setEventItemId(eventItemId); registration.setStatus("approved"); registrationMapper.insert(registration); } catch (DuplicateKeyException ex) { // 3. 唯一索引冲突,说明重复报名,需要回滚扣减 eventItemMapper.increaseQuota(eventItemId); throw new BusinessException("您已报名该项目"); } return RegistrationResult.success(); }

上面的deductQuota对应SQL是UPDATE event_item SET current_count = current_count + 1 WHERE id = ? AND current_count < max_total,它本身就是原子操作。加上registration表的(student_id, event_item_id)唯一索引兜底,才算真正解决了并发问题。实测模拟50个并发请求压测,没有一条重复数据,也没有超限数据。

另外一个值得提醒的点是:扣减名额的时机不能放在报名审核通过时,而应该放在提交报名时。因为校园场景里大部分报名是提交即通过(审核只是一个流程节点),如果名额在审核通过才扣,那系统显示的剩余名额对已提交未审核的用户失去意义,这就是业务上"名额实时性"的要求。

4.3 事务边界与回滚的细节

我踩过的一个经典坑:报名接口里扣名额、插记录都在同一个事务里,事务内部先用deductQuota扣减名额,然后拼装成绩记录和积分流水。但是如果在插入成绩记录时因为数据异常(比如维度超长)抛了RuntimeException,整个事务会回滚,这是预期的。可我最初没意识到的是:deductQuota是和注册记录同一事务,但注册成功后关联创建的赛程提醒通知记录却是单独的事务(因为我用REQUIRES_NEW让它独立提交)。结果就是:报名成功,通知记录漏掉了。这个场景虽然不致命,但用户收到不通知的体验确实很差。

正确做法是评估好逻辑边界:核心业务(报名+扣减名额+成绩初始记录)务必在同一个事务里,外围的通知、日志需要失败不影响主流程的,单独事务隔离,或者干脆失败重试。做这类管理系统时,永远要记得:先保证核心数据一致,再谈外围体验。

5. 项目部署与联调过程中的经验教训

5.1 环境准备与快速启动

开发环境的基本组成:JDK 8(我用的是1.8.0_202,别问我为什么不用17,问问学校机房的老机器)、Maven 3.6+、Node.js 14+、MySQL 5.7。如果你也是学生团队或单人开发,我强烈建议用统一的环境版本,别各搞各的,不然联调时"我这里能跑你那里崩了"的场景分分钟上演。

后端启动流程不复杂:

# 初始化数据库,执行建表脚本 mysql -uroot -p < database/init.sql # 修改配置文件里的数据库连接 vim src/main/resources/application.yml # 打包并启动 mvn clean package -DskipTests java -jar target/sports-event-system.jar --spring.profiles.active=prod

前端方面,开发模式用npm run dev启动Vite服务,但生产环境需要构建静态文件再让Nginx托管,同时要配置API代理,否则前端页面无法请求后端接口。我用的Nginx配置片段大致是这样:

server { listen 80; server_name sports.example.edu; root /var/www/sports-front; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

注意try_files $uri $uri/ /index.html这一行,Vue Router的history模式必须加这个,否则刷新页面时Nginx会404。如果不想配置Nginx、只是临时演示,可以把前端构建产物丢进Spring Boot的static目录直接访问——但这种方案只建议应急用,真正的项目还是前后端分离部署比较清晰。

5.2 联调中遇到的典型接口问题

前后端联调几乎必踩的问题就是CORS跨域。我用了一个非常直接的方式:后端配置CorsFilter,允许指定源(前端地址)和指定请求头。但是有一天前端同学说"我明明加了token,后端还说我没登录",最后发现是预检请求(OPTIONS)没有通过拦截器——Axios发POST请求时带Content-Type和Authorization头,浏览器会先发一个OPTIONS预检,如果后端拦截器直接拦截了OPTIONS请求导致预检失败,后续真正请求根本无法到达Controller。处理方案是在JWT拦截器里对OPTIONS请求直接放行:

@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } // 正常token校验逻辑... }

还有一个非常隐蔽的问题:后端返回的时间字段是LocalDateTime,JSON序列化默认格式是"2024-05-01T10:30:00",前端直接展示很丑,需要全局配置Jackson的日期格式,或者干脆在实体类上用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")。我们后来是用了全局配置方案:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

这个配置别看就两行,没有它你前端所有时间显示都会慢8小时。因为MySQL连接时区如果设置不对,存进去的时间看起来正常,取出来就乱了。这是"时区偏移8小时"问题,排查起来特别耗费精力,建议在application.yml里把url驱动参数加上serverTimezone=Asia/Shanghai,一步到位。

5.3 服务器部署和运维注意事项

服务器上跑的项目,内存分配一定要想清楚。我校实际情况是给了台2核4G的云主机,部署了MySQL和Java应用。Spring Boot默认的JVM堆内存启动时会占掉相当大一块,如果不加限制,系统很可能因为内存不足频繁SWAP。我用的启动参数是:

nohup java -Xms256m -Xmx512m -XX:+UseG1GC -jar sports-event-system.jar --spring.profiles.active=prod > app.log 2>&1 &

这样堆内存上限512m,配合系统其他进程,4G内存基本够用。日志输出到app.log,排查问题直接tail -f app.log就行。

还有个特别实际的问题:校园网络环境下学生偶尔会在同一时间集中提交报名,如果后端线程池太小会导致请求排队超时。我在application.yml里调大了Tomcat线程配置:

server: tomcat: threads: max: 200 accept-count: 300

这是保障报名高峰时段"页面还能打开"的基础参数。当然如果真想并发更大还是要上负载均衡,但对一个校园赛事系统,这组参数足够用了。

6. 常见问题排查实录与优化手段

6.1 后端接口常见异常速查表

我把联调过程中遇到的高频问题整理成了一张速查表,这里挑几个有代表性的分享:

现象可能原因排查方式
启动报Unable to acquire connectionMySQL未启动或连接池配置错误先检查application.yml的url/username/password,再确认MySQL服务状态
登录成功后接口仍然401JWT过期或请求头没带确认Axios请求拦截器设置Authorization: Bearer xxx,检查token是否过期
时间字段差了8小时JDBC连接时区配置不对url上强制加serverTimezone=Asia/Shanghai;同时检查JVM默认时区
报名提交后名额没减少事务回滚看异常日志,多半是插入时报了DuplicateKeyException
批量导入Excel报内存溢出POI一次性读入大文件使用SXSSFWorkbook分批写入,或限制上传文件行数
前端页面白屏/请求404Nginx配置或路由模式确认try_files配置了/index.html,确认静态文件路径正确

还有一个极其隐蔽的问题:MyBatis-Plus的字段映射下划线转驼峰。数据库字段is_committee对应实体类isCommittee,默认是打开的,但如果某些字段名不对应,查询出来的对象就是null。遇到这种情况第一反应检查实体类字段名,而不是怀疑SQL写错了。

6.2 前端渲染性能优化和体验改进

成绩排名页是前端最重的页面。最初我做的是:后端一次性把某赛事全部项目的所有排名数据返回,前端用表格渲染。结果项目多的时候(比如田径运动会40多个项目),接口返回数据量巨大,前端渲染卡顿明显。

优化的做法分两步:

  • 后端支持按项目聚合分页查询,默认只加载当前项目视图的排名,切换项目时按需请求。
  • 前端使用虚拟滚动。有人会想到分页,但排名榜用户习惯滚动浏览,我最后是用了一个简单的虚拟滚动组件(只渲染可视区的行),体验立刻流畅了。如果不想引入第三方库,可以先固定表格高度、只渲染startIndex到endIndex之间的行,用overflow-y: auto的容器包住,滚动时动态计算。

这个优化的核心思路是"能不渲染的DOM就不渲染",对长列表尤其有效。报名管理页也是同样的问题:一个班几十人报名,全校几千人报名,表格要一次性渲染几千行,不卡才怪。

6.3 数据修复与对账技巧

赛事结束后统计排名时,经常发现数据对不上的情况。比如:A项目录入的参赛人数和报名通过人数不一致(有人报了名没参加,这是正常的);团体总分和单项积分加起来对不上(肯定是某个成绩被删改但积分没重算)。

我的解决思路是建立一个"对账页面":展示每个项目的报名人数、成绩记录数、积分总和,管理员可以一键做一致性校验。校验规则很简单:成绩记录数不能大于报名通过人数,积分总和应该等于该项目已确认成绩积分之和。如果有异常,直接高亮标红,管理员去核对修改。

这个功能最初没在设计文档里,是一次运营事故倒逼出来的:校运会闭幕前一晚,体育部老师发现团体总分榜第二名和第三名只差2分,但手工核算发现有三名同学的成绩积分没被计入。排查后发现是裁判录入成绩时,没点"确认该组成绩"按钮,导致成绩停在草稿状态。之后的修正做法是:在score表加了status字段(draft/confirmed),只有confirmed状态的成绩参与积分计算——这也是数据一致性的一个典型场景。

7. 项目迭代方向与个人经验分享

这个系统从最初的需求调研到第一版上线,用了大约三周时间。核心开发周期其实只占一半,另一半花在了需求沟通和联调修bug上。做完这个项目后,有几个认知让我印象很深,分享给大家:

第一,技术永远是为业务服务的。看起来是"一个简单的报名系统",但业务规则一旦细化(跨班代报、单项限报、并列名次积分规则、破纪录标记),复杂度远超想象。做这类系统,前期花足够时间把业务规则梳理清楚,比多写几千行代码更有价值。我在第一版把"并列名次"处理简单粗暴地按报名顺序分配名次,结果被体育部老师怼了回来,重新研究田径比赛规则才改对。

第二,系统的演进要留好扩展位。赛事管理系统的核心玩法是"变化":今年的比赛项目、规则、积分制度、奖项设置和去年一定不完全一样。所以我在代码里尽量把"规则"做成配置而不是写死逻辑。比如积分规则表、项目限额表、赛事状态机都是可以运营阶段动态调整的。第三阶段我还计划把抽签编排功能做成自动化的:输入参赛人数和分组规则,自动生成秩序册——这项工作如果手工安排,校运会前一周体育部的老师几乎每天加班到晚上十点。

第三,安全性和日志不能省。学生用户对系统的信任度是从"出了问题能查到原因"建立的。操作日志是校园管理系统里最容易被忽略又最关键的模块,越不显眼越重要。

最后再分享一个小技巧。如果时间允许,尽量自己先把所有角色的典型操作都走一遍,管理员、裁判、学生,切换身份完整跑一遍流程。很多bug其实不需要用户反馈,自己代入角色走一遍就能发现大半。第一版上线前我就是这么干的:第一次以裁判身份录成绩时,发现竞走项目的成绩单位(时间显示成"分:秒:毫秒")完全没法填——因为设计时考虑的都是秒表。后来加了两个字段支持自定义成绩展示格式。否则上线第一天就会被裁判们集体吐槽。

做这类管理系统,个人体感最深的不是写了多少优雅的代码,而是把一个"原本要靠Excel和口头沟通维持的流程"真正变成了稳定、可追溯、可复用的线上系统。如果你也在做类似的校园管理项目,希望这篇文章能让你少踩几个坑,把更多精力留给真正有意义的功能打磨上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询