news 2026/10/22 0:30:26

高校学科竞赛管理系统开发实战:从状态机到并发控制的JavaWeb实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
高校学科竞赛管理系统开发实战:从状态机到并发控制的JavaWeb实践

做毕业设计这几年,学科竞赛管理系统基本上每年都会出现在选题清单上。这个题目看起来很"标准"——发布竞赛、学生报名、评委打分、出成绩,听起来就是一套普通的管理系统。但真正动手做下去,你会发现这里面涉及角色权限、状态流转、并发报名、文件存储、成绩汇总统计,甚至还有一点评委公平性的业务逻辑,复杂度远不是增删改查那么简单。这篇文章就把我从零搭建这套系统的完整过程、设计思路、核心代码实现和踩过的坑全部梳理一遍,适合正在准备这类毕业设计,或者想接手类似高校业务系统的同学参考。系统完全基于常规JavaEE技术栈实现,可以直接运行、方便二次扩展。

1. 项目概述与核心需求拆解

1.1 高校学科竞赛到底在管什么

先说清楚业务场景。高校里的学科竞赛种类非常多,有校级选拔赛、省级赛、国家级赛,还有各类行业学会办的赛事。通常一个学校会同时运行几十个竞赛项目,每个竞赛又有自己的报名周期、作品提交截止时间、评审规则和奖项设置。在没有系统之前,这些工作大部分靠人工表格和即时通讯软件完成:学院发通知、学生填表格汇总、教务处手动核对、评委线下收打分表、最后再由人手工统计排名。这个过程不仅效率低,而且很容易出错——漏报、重报、截止时间错过、成绩算错,都是常见问题。

这个系统的核心目标,就是把"竞赛从发布到发奖"的完整生命周期管理起来。具体来说包括几个关键节点:竞赛信息发布、学生在线报名、团队信息维护、作品材料提交、评委在线评审打分、成绩自动汇总、获奖名单公布、数据统计查看。你一定要把系统当做一个业务流程管理工具来设计,而不是单纯的信息展示平台,这个定位决定了后面的数据表结构和代码设计方向。

1.2 角色权限与核心功能场景

这类系统最基础也最重要的设计点就是角色权限。我把它分成四个角色,每个角色看到和操作的内容完全不一样:

  • 系统管理员:管理所有用户(学生、评委)、发布和审核竞赛、配置评审规则、处理异常报名、发布公告、查看统计报表。这是权力最大的角色,所有关键节点都需要管理员介入。
  • 普通学生:查看竞赛列表、查看详情、在线报名(个人或团队)、提交作品材料、查看自己的报名状态和最终成绩/奖项。
  • 评委教师:查看被分配的作品列表、在线评分、填写评审意见、查看自己评过的分数记录。
  • 二级管理员/院系负责人:这个角色如果你做的是大型毕设可以加,小系统可以先不加。它的作用是为了处理"一个竞赛需要各学院先选拔再推送校级"的场景。

功能场景则是围绕竞赛生命周期展开的。我梳理过核心流程,大致如下:管理员创建竞赛并发布 → 学生看到通知并报名 → 截止后管理员确认参赛名单 → 学生在期限内提交作品 → 管理员分配评委 → 评委打分并提交 → 系统自动汇总成绩、生成排名 → 管理员确认获奖名单 → 学生查看成绩。每个环节之间都有严格的状态依赖,所以你需要让数据库表具备状态字段,并且封装好状态变更逻辑。

这让我在设计时重点考虑了两件事:第一,竞赛本身的状态流转;第二,报名记录的状态流转。每个环节谁可以触发、什么条件下可以触发,都必须理清楚。很多同学做的时候想不清楚,最后代码写出来要么是状态满天飞,要么就是什么状态都没有,全凭前端按钮控制,一发布就乱了。

2. 技术选型与整体架构设计

2.1 毕业设计最常见也最稳妥的技术栈

技术选型是很多人纠结的第一关。我见过有人选Python Flask、有人选Node.js,甚至有人为了炫技上一套微服务加分布式事务,结果把自己坑得很惨。做毕设,我的原则很简单:优先选择自己熟悉且社区资料最全的主流技术,保证能按时完成、能顺利演示和答辩。

这套系统最终采用的技术栈如下,基本是JavaWeb毕设的黄金组合:

层次技术选择选型理由
后端框架Spring Boot 2.7.x自动配置能力强,内嵌Tomcat,开发效率高,Java毕设生态最成熟的框架
持久层MyBatis Plus单表CRUD几乎不用写SQL,报表统计可以用自定义SQL补充
数据库MySQL 8.0免费稳定,胜任这种业务量级,InnoDB支持事务
前端框架Vue 2 + Element UI组件化开发,适合管理后台类页面,标签页表格表单都很现成
权限方案Spring Security + JWT前后端分离下最标准的组合,或简化用JWT拦截器也行
文件存储本地磁盘存储毕设没必要上OSS,本地存文件简单可控,部署时讲得清楚即可

说明一下,Spring Security 对很多同学来说上手成本偏高。如果时间紧张,可以只用JWT加拦截器做登录和权限校验,在答辩时说明"通过拦截器实现接口级权限控制"完全够用。我这里最终用了Spring Security,主要是为了权限注解更规范,展示起来更有说服力。

2.2 项目模块划分与数据结构关系

整个系统我从一开始就按模块划分,而不是把所有Controller堆在一个包下。后端代码按业务功能拆包:

com.cms ├── controller # 接口层 │ ├── admin # 管理员相关接口 │ ├── student # 学生相关接口 │ └── judge # 评委相关接口 ├── service # 业务逻辑层 ├── mapper # MyBatis Plus数据访问层 ├── entity # 实体类 ├── dto # 请求/响应DTO ├── common # 通用工具、统一返回结果、异常处理 └── config # 配置类(JWT、拦截器、跨域等)

这种"按角色分包、按职责分层"的好处是后期维护和答辩讲Project Structure时都特别好讲。

前端页面同样按角色拆路由:登录页、管理员布局、学生端、评委端。页面组件复用通过通用列表组件和表单弹窗来处理。整个项目的前后端通过JSON交互,后端统一返回结果格式:

{ "code": 200, "message": "操作成功", "data": { } }

有了统一返回结构,前端处理逻辑就非常简单,只需要封装一次axios响应拦截器,根据code判断业务是否成功。这样前端不用在每次请求里重复写错误处理代码。

3. 数据库设计与核心业务建模

3.1 核心数据表结构解析

数据库是整个系统最不能省脑子的一环。我核心设计了7张业务表,每张表都有它的存在理由。下面挑几张关键的讲:

用户表(user)

CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(100) NOT NULL, real_name VARCHAR(50) NOT NULL, role VARCHAR(20) NOT NULL COMMENT 'STUDENT/JUDGE/ADMIN', college VARCHAR(100), major VARCHAR(100), phone VARCHAR(20), email VARCHAR(100), status TINYINT DEFAULT 1 COMMENT '1正常 0禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );

密码字段建议存BCrypt加密后的哈希值,不要存明文。虽然只是毕设,但答辩老师很可能会问密码安全问题,预处理比现场改进容易得多。

竞赛表(competition)

CREATE TABLE competition ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, description TEXT, type VARCHAR(50) COMMENT '学科类型:如程序设计/数学建模/电子设计', organizer VARCHAR(100) COMMENT '主办方', enroll_start DATETIME NOT NULL, enroll_end DATETIME NOT NULL, work_start DATETIME, work_end DATETIME, status VARCHAR(20) DEFAULT 'DRAFT' COMMENT 'DRAFT-草稿 REGISTERING-报名中 SUBMITTING-提交中 REVIEWING-评审中 FINISHED-已结束', max_team_members INT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );

这里把竞赛拆成三个时间段(报名段、作品提交段、评审段)是为了让系统对流程的掌控更精确。比如报名结束后,学生不能再报名;提交结束后,评委开始打分。每个阶段对应不同的操作许可。

报名表(enroll)

CREATE TABLE enroll ( id BIGINT PRIMARY KEY AUTO_INCREMENT, competition_id BIGINT NOT NULL, user_id BIGINT NOT NULL COMMENT '队长ID', team_name VARCHAR(100), status VARCHAR(20) DEFAULT 'REGISTERED' COMMENT 'REGISTERED-已报名 SUBMITTED-已提交作品 AWARDED-已获奖 CANCELLED-已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_comp_student (competition_id, user_id) );

这张表上我加了**(competition_id, user_id)的唯一索引**,这一步非常关键。它可以从数据库层面防止同一个学生重复报名同一个竞赛。很多同学没加唯一索引,只靠代码判断,结果并发请求下还是插入了重复数据。

作品表(work)、评审表(review)、奖项表(award)

作品表存提交的文件路径和对应的报名记录ID;评审表存评委ID、作品ID、分数和评语;奖项表存竞赛ID、获奖等级、获奖学生ID和证书信息。评审表还有一层设计考虑:一场竞赛可能多个评委打分,所以成绩汇总时要按规则取多个评委的均分或去掉最高最低分。

3.2 竞赛状态机与报名流程设计

状态机是这个项目里最值得花时间设计的内容。我经历过一开始状态乱写导致前后台逻辑混乱的教训,所以后来切换到明确的状态机模型。

竞赛状态流转:DRAFT(草稿) → REGISTERING(报名中) → SUBMITTING(提交中) → REVIEWING(评审中) → FINISHED(已结束)。

不是所有状态都可以随意跳转。比如:

  • DRAFT只能由管理员手动发布为REGISTERING,同时设置报名截止时间。
  • REGISTERING到SUBMITTING,由系统定时任务或管理员手动操作触发,触发条件是当前时间大于报名截止时间。
  • SUBMITTING到REVIEWING,同理,时间到提交截止时自动或手动触发。
  • REVIEWING到FINISHED,在评审成绩汇总完成后,管理员确认发布结果,竞赛结束。

每次状态变更我都建议在service层写一个统一方法,把状态校验和更新写在事务里。比如:

@Transactional public void changeCompetitionStatus(Long competitionId, String fromStatus, String toStatus) { Competition competition = competitionMapper.selectById(competitionId); if (competition == null) { throw new BizException("竞赛不存在"); } if (!competition.getStatus().equals(fromStatus)) { throw new BizException("当前状态不允许该操作"); } competition.setStatus(toStatus); competitionMapper.updateById(competition); }

核心思路就是:任何状态变更必须显式声明来源状态,防止用户绕过流程直接跳转。前端也可以根据后端返回的当前状态来动态渲染按钮。

报名流程相对简单,但有一个关键点要注意:报名插入必须结合竞赛状态校验。学生请求报名时,后端要查一次竞赛当前是否处于REGISTERING状态,然后加上唯一索引兜底,在事务中完成校验与插入。只做前端隐藏按钮而忽略后端校验,是这类系统最常见的安全漏洞。

3.3 评审打分与成绩汇总逻辑

评委评分这部分,我采用了单作品多评委的模式。管理员在分配评委后,评委登录系统只能看到分配给自己的作品列表,每个作品只能评一次分。评审表里把work_id + judge_id设成唯一键,防止同一评委重复提交。

成绩汇总时,系统先按作品分组计算均分,然后按分数倒序排列,生成排名。这里有两个细节值得注意:

  • 去掉无效分:如果竞赛规则是去掉最高分和最低分取平均,应该在汇总方法里先排序再排除首尾。我把这条规则写在配置项里,答辩时可以现场演示开关效果。
  • 评委打分范围:一般竞赛是百分制或十分制。前端要做输入校验,后端同样要校验,否则有人绕过前端直接调用接口就能提交非法分数。
public void submitScore(Long reviewId, BigDecimal score, String comment) { if (score == null || score.compareTo(BigDecimal.ZERO) < 0 || score.compareTo(new BigDecimal("100")) > 0) { throw new BizException("分数必须在0-100之间"); } // 更新评审记录,然后重新计算该作品的平均分 }

4. 核心模块实操:从0到1搭建关键功能

4.1 登录认证与权限拦截

这个模块是系统的门面,也是无数人的翻车点。我最终采用的技术方案是:Spring Security 负责密码加密与认证逻辑,JWT 作为无状态令牌,通过自定义过滤器解析请求头里的Authorization字段。为了降低复杂度,我只保留了UsernamePasswordAuthenticationFilter和OncePerRequestFilter两条链路。

核心实现步骤分三步:

第一步:用户登录接口。接收用户名和密码,用BCryptPasswordEncoder.matches()比对明文和数据库哈希。比对成功,根据用户ID和角色生成JWT令牌。令牌中包含三个核心信息:用户ID、用户名、角色。

第二步:自定义JWT过滤器。拦截所有/api/**请求,从Header中摘取Token,解析通过后把用户信息放入SecurityContextHolder。没带Token或Token过期,直接返回401。

第三步:接口级权限校验。在Controller方法上使用@PreAuthorize("hasRole('ADMIN')"),对管理员接口做保护;学生报名和提交作品接口则校验STUDENT角色。这样即使前端误调用他人接口,后端也能拒绝访问。

以下是我开发时频繁使用的一个技巧:把认证用户信息统一切面注入参数。

@RestController @RequestMapping("/api/enroll") public class EnrollController { @PostMapping("/{competitionId}") public Result enroll(@PathVariable Long competitionId, @AuthenticationPrincipal LoginUser loginUser) { enrollService.enroll(competitionId, loginUser.getId()); return Result.success(); } }

这样拿当前登录用户ID非常干净,不用每个接口手动解析Token。

4.2 竞赛发布与在线报名联调

竞赛发布和报名是整个系统演示时的重头戏。管理员发布竞赛后,学生端列表应立即出现新条目。这一步我在实现时有一个小坑:前后端数据的时间格式化不一致,导致前端显示NaN-NaN-NaN,看着就像系统坏了。

解决方案是统一约定时间传递格式。后端所有时间字段以yyyy-MM-dd HH:mm:ss字符串返回,前端解析只用dayjs(字符串),不额外做转换。这个约定从第一个模块开始就写进项目注释里,后面再也没有出现过时间显示问题。

在线报名还有一件事需要处理——团队报名。很多竞赛允许3人组队。我的做法是:队长创建团队并填写团队名称,然后将队员的用户名添加进列表。报名表里只存队长ID和团队名称,队员关系存在一张team_member表里。这样做的好处是:成绩评到团队上,奖项可以批量颁发给全体成员。

报名联调时最容易出错的是事务边界。比如创建团队时,需要同时插入enroll和team_member两张表,任何一个失败都应该回滚。我用@Transactional把整个方法包住,并且抛异常时统一在service层捕获转换成业务异常,避免给前端返回一堆堆栈日志。这里有个实际教训:有一次创建团队重复调用添加成员方法,导致队员关系重复插入,后来我在team_member表加了(team_id, student_id)唯一索引,同时用insertOrUpdate方式处理,才彻底解决。

4.3 作品文件上传与下载

作品提交是竞赛系统的核心技术点之一,涉及文件上传、路径存储、文件类型校验、大小限制、以及后续评委下载查看。

我使用Spring Boot的MultipartFile接收文件,本地存储到配置指定目录:

file.upload-dir=./upload/ file.max-size=50MB

上传代码里需要注意几个点:

  • 随机文件名:不要用原始文件名。简单做法UUID.randomUUID()拼接原文件后缀,避免中文名和路径穿越问题。
  • 扩展名白名单:.zip、.rar、.pdf、.docx、.jpg,其余一律拒绝。后端按扩展名判断,前端选择器只是辅助。
  • 限流与容量:每个作品目录独立存放,路径规则为upload/{competitionId}/{enrollId}/。这样评委下载时通过报名记录就能定位,文件管理也清晰。
public String storeFile(MultipartFile file, Long competitionId, Long enrollId) { if (file.isEmpty()) { throw new BizException("上传文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); if (!ALLOWED_EXTENSIONS.contains(ext.toLowerCase())) { throw new BizException("不允许的文件类型"); } String dir = uploadDir + File.separator + competitionId + File.separator + enrollId; File dirFile = new File(dir); if (!dirFile.exists()) { dirFile.mkdirs(); } String filename = UUID.randomUUID().toString().replace("-", "") + ext; file.transferTo(new File(dirFile, filename)); return dir + File.separator + filename; }

文件下载时我做了权限控制:普通学生只能下载自己团队的作品,评委可以下载分配给自己的作品,管理员可以下载全部。这个逻辑放在service层判断,而不是直接在Controller里把文件流输出给所有人。

5. 常见问题与排查实践记录

5.1 并发报名导致数据错乱

系统开发完成后的压测阶段,我用模拟并发请求脚本同时发起了20个报名请求,发现最终报名记录数大于预期。排查后发现问题出在"先查后插"的时序漏洞:多个请求同时查到竞赛是REGISTERING状态,同时执行插入,结果重复报名。

解决方案分两层:

  • 数据库层:enroll表的(competition_id, user_id)唯一索引兜底。
  • 代码层:报名方法加@Transactional,在校验状态和插入数据之间不留空隙。

如果你还想更严谨,可以用MySQL的SELECT ... FOR UPDATE锁住竞赛记录,保证同时只有一个请求能进入校验逻辑。我在教学演示中特意保留了一版无锁版本的代码,对比效果非常直观。

5.2 文件上传路径与部署环境差异

本地开发文件路径写死了Windows的D:/upload,部署到服务器跑起来之后,上传文件一直报FileNotFoundException。原因是服务器没有这个绝对路径,且服务进程对目标目录没有写权限。

这个问题解决有三个关键步骤:

  • 上传目录放到项目相对路径下,可配置,不要写死。
  • 服务启动时主动检测目录,不存在就自动创建。
  • 定期备份或同步上传目录,因为一旦容器重启或重新部署,目录数据不会自动保留。

另外还要注意,如果用了反向代理或网关,文件下载接口的请求大小限制和超时设置也要同步调整,否则评委下载大文件时会遇到响应被截断的问题。

5.3 评审打分业务规则的边界处理

评审模块是一开始最容易忽略细节的地方。我遇到过几个问题:

  • 评委给分了,但作品平均分没变。原因是汇总逻辑只统计了review表里status为SUBMITTED的评价,但是提交打分时忘了更新该状态。
  • 多个评委同一个作品重复打分。因为评审表没有加唯一索引。后来加上UNIQUE(work_id, judge_id)解决。
  • 管理员删除竞赛时,关联作品和评审记录没有清理,导致统计报表出现孤儿数据。这个通过数据库外键加级联删除,或者service层手写事务清理解决。

评审公平性也是一个值得讲的设计点。我在系统里控制评委只能看到作品编号和内容,看不到学生姓名和学校。这样演示时能拿"双盲评审"作为一个亮点介绍。具体做法是给作品表增加一个anonymous_code字段,平台生成匿名编号,评委打分页面统一展示匿名编号而不是报名ID。

6. 项目部署与源码运行指南

6.1 本地运行环境准备

拿到一套源码之后,第一步是把环境跑通。整个项目最小的运行环境如下:

  • JDK 1.8 或 11
  • MySQL 8.0(MySQL 5.7也可以,但注意时间字段默认值语法差异)
  • Maven 3.6+
  • Node.js 14+(用于前端构建,或者前端部分直接集成在后端静态目录)

启动顺序我推荐:

  1. 创建数据库,执行项目根目录下的sql/init.sql,完成初始化表结构和测试数据。
  2. 修改后端application.yml中的数据库账号密码,确保和本地一致。
  3. 启动后端服务,等待端口8080正常监听。
  4. 前端如果独立运行,进入前端目录执行npm install和npm run dev;如果前端已经打包放入后端static目录,直接把前端包放到src/main/resources/static下,启动后端后访问http://localhost:8080即可。
  5. 使用初始化脚本里的管理员账号登录,密码通常是admin/123456,首次登录建议修改。

6.2 顺利运行务必注意的几个关键配置

这里单独列一份避坑清单,都是实际接手项目时最常见的问题:

  • 数据库时区问题:连接字符串必须带serverTimezone=Asia/Shanghai,否则传时间到后端会差8小时。
  • Redis有没有被强制依赖:如果项目里设计了验证码或Token黑名单,可能依赖Redis。不想用Redis,可以直接用内存Cache,但部署后重启服务Token会失效,这个要在答辩时说明取舍。
  • 跨域配置:前后端分离模式下,前端开发服务器端口通常是8081,后端8080。必须配置CorsFilter允许的源、方法和Header,否则前端请求全部被浏览器拦截。这里经常出现"后端接口测试没问题,前端调用就403"的怪象,根源就在跨域。
  • 文件上传大小:Spring Boot默认单文件上传限制1MB,如果竞赛作品材料超过这个大小,必须手动在配置里把spring.servlet.multipart.max-file-size调大。
  • 静态资源路径:如果上传文件要能通过URL直接访问,需要配置资源映射器,把/upload/**映射到本地磁盘目录。忘记配置就会出现文件保存成功但页面图片显示不出来的情况。

后端启动如果报端口占用,用命令查一下是哪个进程占用8080端口,直接杀掉或者换端口配置,处理掉这个问题后再启动。

源码目录结构我建议拿到手后先花20分钟通读一遍,不要急着点运行。理清楚每个包是干什么的,再对照数据库表过一遍字段含义,后面遇到问题定位会快得多。

7. 个人实操体验与一点点建议

整套系统从建表到前端页面联调完成,常规节奏差不多要三周到一个月。我自己的经验是:数据库设计阶段多花两天思考状态和唯一索引,后面编码、联调阶段能省出一周;反过来前期表结构乱,后面到处打补丁,返工成本极高。

在做的过程中,我对"毕业设计要不要用框架"这件事的体会比较深。框架确实是提高效率的工具,但它不是万能的。学科竞赛管理系统业务流程相对清晰,用Spring Boot这类主流框架能快速搭建骨架,但真正的功夫在业务建模和边界条件处理上。比如状态流转、并发控制、权限校验、文件存储,这些逻辑和框架无关,需要自己一层层想清楚。这些点也恰恰是答辩时老师最愿意追问的地方,因为能体现你对一个完整业务系统的理解深度。

如果你以后想把这个项目继续扩展,有几个方向可以考虑:增加竞赛类别与学分认定对接、加入邮箱或短信通知、接一个简单的图表统计页面展示各学院参与率和获奖率、把评审模块改成专家互评模式。这几个方向都能让系统更接近实际生产环境。

这套系统的完整源码、数据库脚本和部署文档我整理在附件里,项目编号18952。下载后按说明一步步执行,基本就能跑起来。如果有哪个模块卡住了,对照这篇文章的章节定位问题,大部分坑在前面已经帮你趟过了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 14:49:40

SSM+Vue旅游出行系统毕设全攻略:从需求拆解到答辩通关

又到一年毕设季。如果你正在为选题发愁&#xff0c;或者已经在百来个备选题目里挑花了眼&#xff0c;那我强烈建议你认真考虑一下"旅游出行系统"这个方向。说句老实话&#xff0c;计算机专业做毕设&#xff0c;最重要的不是选题多惊艳&#xff0c;而是"稳"…

作者头像 李华
网站建设 2026/10/16 10:32:25

人脸识别系统zip包快速跑通指南:Ubuntu+RTX3060环境实操

简介&#xff1a;本资源是一套基于深度学习的人脸识别系统完整实现方案&#xff0c;面向计算机专业本科生、毕业设计与课程设计学习者&#xff0c;聚焦AI视觉方向的工程实践能力培养。系统以YOLO辅助人脸定位、CNN主干网络进行特征提取与分类&#xff0c;覆盖数据预处理、模型训…

作者头像 李华