☰
基于Spring Boot的高校学科竞赛平台设计与实现复盘
2026/10/21 6:05:56 网站建设 项目流程

报名截止那天下午,某高校负责竞赛的老师拿着Excel里多出来的一长串名字找到我:系统开放了200个名额,收到的报名却超过220条,还有好几个班级在走廊里排队等着交纸质表。这台基于Spring Boot的高校学科竞赛平台,最初就是为了终结这种“Excel排期+微信群通知+人工核对”的混乱状态而设计的。项目本身是一个标准的毕业设计课题,但我是按生产系统的标准去做的,不是只交一版CRUD。这套系统跑完了一整届校级比赛,覆盖了从竞赛发布、学生报名、作品提交、评委评审到成绩公示的完整流程,代码和文档都完整打包。这篇就把我的设计思路、踩过的坑和真正磨出来的经验完整复盘一遍,给正在做同类课题或者想认真学习Spring Boot落地的人一条可以参考的路线。

1. 比赛还没开始,Excel先罢工了:高校学科竞赛平台到底在解决什么问题

先说清楚需求。很多人在网上看项目时,容易把这类平台理解成“竞赛管理系统的增删改查”,这是最大的误区。学科竞赛平台表面上是一个信息管理系统,本质上是一套流程控制系统。竞赛和其他业务最大的区别在于:它有严格的时间窗口、明确的状态流转、多角色协作和大量的边界条件。错过报名时间不能补报,作品提交截止后不能修改,评委打分提交后不能反悔。这些规则在Excel里靠人传人去提醒,在系统里就必须靠数据模型和代码去强制约束。

1.1 从一场报名超员事故说起

高校里办一场校级学科竞赛,实际流程远比外人想象得琐碎。通知发布之后,学生要组队、找指导老师、填写成员信息、反复确认比赛要求;组织者要统计报名信息、核对参赛资格、分配评委、安排评审顺序;评委要在规定时间内下载作品、打分、写评语;最后还要公示成绩、处理申诉、发放证书。每一个环节都有异常情况:队伍成员变动、作品文件不合格、评委临时有事、成绩出现并列。

最典型的就是报名阶段。我接手这个项目时,负责老师给我看过往年的做法:参赛队伍在微信群接龙报名,组织者再用Excel手动汇总,结果经常出现名字打错、队伍重复、名额超员的情况。有一年因为超员,临时又搞了一场选拔赛,整个赛程往后拖了两周。这类问题靠“人更细心”是解决不了的,必须把报名变成一个带有名额上限和唯一性校验的系统化动作。

所以平台的核心价值从来不是替老师省一张Excel表,而是把“什么时候能做什么事”固化成规则:不在报名时间窗口内,学生根本点不了“报名”按钮;名额满了,系统直接拒绝;同一支队伍重复提交,数据库层面的唯一索引就能拦截。规则前置,才是最省心的地方。

1.2 四个角色、一条主线:平台的核心业务闭环

设计这类平台的第一步,不是建表,不是画页面,而是把角色和主线理清楚。我按实际操作场景划分了四类角色:

  • 学生/参赛队伍:浏览竞赛、报名、填写队伍信息、上传作品、查看成绩与公示
  • 指导教师:确认指导关系、查看所带队伍进度
  • 评委/专家:查看分配的作品、在线评审与打分
  • 系统管理员/赛事组织者:发布竞赛、配置阶段时间、管理队伍、分配评委、发布成绩

主线业务可以概括为一条链路:竞赛发布 → 队伍报名 → 作品提交 → 评委评审 → 成绩计算 → 结果公示。每个环节都是下一环节的前置条件,缺一环整个流程就断了。我在最开始画这条链路时,其实已经能隐约感觉到,后面做数据库设计时要围绕竞赛和作品这两个核心实体去展开,而不是围绕用户去展开。

这也是这类项目和其他管理系统的本质区别。普通后台管理系统的核心实体是用户和订单,而竞赛平台的核心实体是竞赛批次和参赛作品。用户只是参与者,真正承载状态变化的是“竞赛”和“作品”。

1.3 为什么“找个外包做个网页”解决不了问题

很多人会想,这种平台找个外包公司几天就做完了,为什么要自己开发?这里要泼一盆冷水。外包公司能交付的是一个“看起来能跑的网页”,但很难交付一套能适应学校竞赛规则的业务模型。高校的比赛规则几乎没有两届是完全一样的:今年有的竞赛可以跨校组队,明年可能就不行;有的竞赛允许提交多个作品,有的只允许一个;评分规则可能是去掉最高最低分,也可能是按加权平均。

这意味着系统必须把业务规则做成可配置的,而不是把规则写死在页面里。比如评分规则,我在系统里预留了评分策略字段,评委人数、去掉最高最低分、成绩权重都可以在后台配置。这些细节外包团队不会替你考虑,因为他们不对赛事的最终结果负责。这也是“源码+文档”这个课题的真正价值所在:你要交付的不是一段能跑的程序,而是一个别人能长期维护、按需修改的工程。

2. 技术选型复盘:Spring Boot是标准解法,但周边搭配才见真章

技术选型其实没什么悬念。用Spring Boot做这类平台,在今天几乎是高校项目里的标准答案。但为什么是它、搭配什么组件、如何取舍,这些才真正影响开发体验和后期维护成本。

2.1 为什么是Spring Boot + MyBatis-Plus + MySQL的组合

先说后端框架。Spring Boot相比早期的SSH和SSM框架,最大优势是解决了配置地狱的问题。我用的是Spring Boot 2.7版本,配合JDK 1.8,足够稳定且大部分服务器环境都能兼容。有人问为什么不用Spring Boot 3,我说实话,3.x虽然新,但很多高校项目跑在旧的服务器上,JDK版本不一定跟得上,课程评审阶段也更愿意看到成熟稳定的组合。技术栈求稳,不求出风头。

持久层我选了MyBatis-Plus。很多人纠结它和JPA的选择,我从实际体验讲:竞赛平台的查询条件多且杂,比如按竞赛名称模糊搜索、按报名时间范围筛选、按作品状态分组统计,MyBatis-Plus的QueryWrapper写起来非常灵活,而且分页插件稳定,对SQL的掌控力更强。JPA适合CRUD极其规范的场景,但这种带着各种筛选统计的赛事系统,MyBatis-Plus更顺手。

数据库用MySQL 8.0,存储引擎InnoDB。选InnoDB不是因为它是默认选项,而是因为平台里报名记录、评审记录都需要事务保证,InnoDB的行级锁和事务支持是硬需求。后面讲报名并发处理时你会看到,这个选型直接决定了并发方案可不可行。

2.2 JWT、Redis、Vue:认证与前端怎么衔接

认证这块我用了JWT,而不是传统Session。原因是平台采用前后端分离结构,前端是Vue3 + Element Plus构建的管理页面,后端只开放RESTful API。JWT的无状态特性让后端不需要维护Session映射关系,用户登录后拿到Token,后续请求只要在Header里带上就行。

Redis在这个项目里干了三件小事:存图形验证码、做报名名额的预校验缓存、缓存竞赛列表的热点数据。其实名额的真正控制还是放在数据库事务里,Redis只是用来降低数据库压力。这要特别说明一下,很多初学者把Redis当成万能并发方案,这是不安全的。用INCR计数去“卡名额”,一旦Redis和数据库数据不一致,就会出现页面显示有名额但报名失败,或者页面显示已满但库里还有空隙的情况。我的原则是:Redis做流量削减,MySQL做最终裁决。

前端不需要多高级,Vue3配上Element Plus组件库,能够快速搭出表格、表单、弹窗这些后台管理页面。整个项目没有复杂的页面交互逻辑,重点全在接口设计和权限控制上。

2.3 单体优先原则:不要在不该分布式的地方分布式

这个项目我有意控制成了单体应用。有些同学做课设时喜欢追求“微服务架构”,把用户、竞赛、评审拆成三个服务,然后自己维护一堆服务间调用。这属于典型的过度设计。一个单届参赛规模几百支队伍的平台,单体架构完全撑得住,部署也简单,一个jar包加一个MySQL就完了。微服务带来的服务注册、配置中心、分布式事务,每一个都是额外的维护负担,对课设项目来说完全是负重前行。

做技术选型时,要想清楚系统的真实瓶颈在哪里。竞赛平台最大的并发压力在报名开启那一刻,几百人同时点击,这种规模用数据库行锁加唯一索引就能轻松应对,不值得为此引入消息队列和复杂的缓存架构。这个决策帮我在后期省下了大量时间,可以把精力集中在业务细节和文档整理上。

3. 数据模型与状态机设计:让“竞赛”这张表自己会说话

如果说框架选型决定地板高度,那么数据模型设计决定天花板。竞赛平台最需要花心思的不是怎么写接口,而是怎么把竞赛规则映射成表结构。我在这一阶段的设计原则是:表要少而精,字段要能表达业务边界,状态流转必须有唯一出口。

3.1 五张核心表的职责边界

经历了完整开发后,我把整个系统收敛为五张核心业务表,其他辅助表都围绕它们展开:

表名职责核心字段
tb_user账号与角色user_id, username, password, role, real_name, student_no, phone
tb_competition竞赛主体信息competition_id, title, description, status, registration_start, registration_end, submission_start, submission_end, review_start, review_end, max_teams, score_rule
tb_registration队伍报名记录registration_id, competition_id, user_id, team_name, member_list, status, created_at
tb_work作品提交记录work_id, competition_id, registration_id, title, file_url, file_name, file_size, status, submitted_at
tb_review评审打分记录review_id, work_id, reviewer_id, score, comment, review_time

这个表结构看着简单,但每一张表的边界都经过了刻意控制。tb_user只存账号信息和最基本的人员属性,队伍关系不在用户表里硬塞;tb_registration的member_list字段,我用了一个JSON字符串来存额外成员。有人会质疑这样不符合范式,但在实际场景中,成员数量不固定、字段也经常追加,拆成子表反而让查询变复杂。对毕设级的项目来说,JSON字段解决“不确定多对多关系”是合理的折中。

3.2 唯一索引与联合索引:光靠代码挡不住脏数据

代码可以挡掉99%的非法操作,但剩下1%必须靠数据库兜底。我最看重的是下面这两组索引。

第一组是tb_registration表上的唯一索引:UNIQUE KEY uk_competition_user (competition_id, user_id)。这组索引的含义是:同一个竞赛里,同一个用户只能有一条报名记录。哪怕代码里漏写了重复判断,数据库也会直接拒绝第二条插入,抛出DuplicateKeyException。这在并发场景下比代码先查询再判断可靠得多。

第二组是tb_review表上的唯一索引:UNIQUE KEY uk_work_reviewer (work_id, reviewer_id)。它的作用是防止评委对同一份作品重复打分。评审系统最怕的就是同一个评委手滑提交了两次,导致成绩翻倍。有了唯一索引,第二次插入直接被拒。

查询性能方面,别忘了给tb_work的(competition_id, status)建联合索引。这套系统运行一个月后,作品表和评审表的记录会快速增长,按照竞赛ID加状态检索是最频繁的操作路径,没有索引的查询会随着数据量增加越来越痛苦。

3.3 状态机:竞赛和作品的状态流转才是整个系统的骨架

这是整个项目里我认为最有技术含金量的一部分。竞赛类和作品类实体,不能只用一个status字段存数字,必须在代码里定义清晰的状态枚举和流转规则。

竞赛的状态我定义了六个:草稿、报名中、作品提交中、评审中、已结束、已公示。注意这里有个容易混淆的点:报名结束不代表竞赛进入作品提交期,两者是独立的时间窗口,我分开设计。竞赛可以处于“报名中”的状态,但作品提交尚未开放;也可以处于“作品提交中”,此时报名通道早已关闭。很多入门项目把所有时间混在一起,导致比赛已经结束还能报名,这是一个大坑。

作品的状态则要反应评审前的过程:草稿、已提交、被退回、已重新提交、已评阅。学生提交作品后,管理员可以审核文件是否合格,不合格的要能退回,学生修改后再交。这一来一回,“被退回”和“重新提交”的状态就必须存在,否则无法区分“学生还没交”和“交了但被退了”。

状态流转我不允许在Controller里随意set修改。我在Service层单独写了一个状态流转方法,每次修改前先检查原状态和目标状态是否在合法转移表里:

public void transition(String targetStatus) { Set<String> allowedNext = STATE_MACHINE.get(this.status); if (!allowedNext.contains(targetStatus)) { throw new BusinessException("非法状态流转:" + this.status + " -> " + targetStatus); } this.status = targetStatus; }

这套设计的本质是把业务规则收拢到一个地方。之后不管加页面还是加接口,状态控制都走这一条路,不会出现“这个接口里状态能跳过去,另一个接口又不行”的混乱。

4. 核心链路实战:报名防重、文件上传、评审打分怎么一步步调通

数据模型定下来后,剩下的就是核心功能实现了。这一章我会把三个最容易出问题的链路单独拿出来讲,因为这三处都涉及“项目能跑”和“项目在真实场景能稳定运行”的分水岭。

4.1 报名接口:并发场景下的防重与名额保护

报名开启那一刻,全校几百支队伍同时点按钮,这是整个系统压力最大的瞬间。最容易出现的问题有两个:同一支队伍重复报名、最后一秒名额超录。我的方案分为三层。

第一层,状态与时间校验。接口第一件事就是查竞赛当前状态,不是“报名中”直接拒绝。第二层,数据库行锁结合名额判断。查询竞赛记录时加上FOR UPDATE,锁定这一行,防止并发条件下两个请求同时读到剩余名额为1,然后同时插入成功。第三层,唯一索引兜底。即使前面逻辑全部漏掉了,插入时数据库也会拦住重复的报名记录。

核心代码如下:

@Transactional(rollbackFor = Exception.class) public void register(RegisterDTO dto) { CompetitionEntity competition = competitionMapper.selectByIdForUpdate(dto.getCompetitionId()); if (competition.getStatus() != CompetitionStatus.REGISTRATION_OPEN) { throw new BusinessException("当前不在报名时间内"); } if (competition.getRegisteredCount() >= competition.getMaxTeams()) { throw new BusinessException("报名名额已满"); } try { registrationMapper.insert(buildRegistration(dto)); competitionMapper.increaseRegisteredCount(dto.getCompetitionId()); } catch (DuplicateKeyException e) { throw new BusinessException("你已报名该竞赛,请勿重复提交"); } }

这里我想强调一下为什么不用synchronized或者分布式锁。单实例部署时synchronized确实能挡住并发,但系统一旦部署两个实例,锁就失效了。数据库行锁是跨实例生效的,只要竞争的是同一行数据,无论多少实例都能正确串行化。用数据库自带的能力解决并发,是最稳妥也最省心的方式。

4.2 文件上传:存储路径、访问映射、类型校验

作品提交是竞赛平台的文件核心。最常见的错误是把文件直接存进数据库,或者塞到项目根目录里。我对文件存储的处理方式是:本地磁盘存储 + 数据库存元数据。

存储路径设计成/data/works/{yyyy}/{competitionId}/{userId}/,按年份和竞赛分目录。好处有三个:按年归档方便赛后清理;按竞赛隔离保证评委访问权限清晰;按用户分组避免不同队伍的文件互相干扰。文件名使用UUID做重命名,保留原始文件名到数据库的file_name字段,用于下载时展示。这样彻底规避了中文文件名和特殊字符带来的URL编码问题。

类型校验不能只信前端。我在后端同时做了两层校验:第一层是扩展名白名单(zip、rar、pdf、docx、jpg、png),第二层是魔数校验。魔数校验的原理是读取文件开头几个字节判断真实类型,比如ZIP文件的魔数是PK开头,PDF文件是%PDF开头。这样即使有人把exe文件改成zip后缀,也会在第二层被拦下来。

文件下载访问我做了两种方案:开发环境用Spring Boot的静态资源映射指向本地目录,生产环境用Nginx直接alias到/data/works/。生产环境不建议经过Java应用转发文件流,那是白白消耗应用内存和CPU。Nginx处理静态文件是高效率的,一个location块就能搞定。

另一个容易被忽略的是上传大小限制。初版我忘了配置,默认1MB,学生交一个几十MB的演示视频直接报错。后来在配置文件里设置:

spring.servlet.multipart.max-file-size=100MB spring.servlet.multipart.max-request-size=120MB

注意max-request-size要大于max-file-size,因为一个请求里可能包含多个文件。还有,服务器Nginx的client_max_body_size也要同步调整,否则请求会在到达应用前就被Nginx拒绝,应用日志里什么都看不到,排查起来很困惑。

4.3 评审打分:一个作品多个评委,成绩怎么算才公平

评审打分是整个平台业务最敏感的部分,因为它直接决定了比赛结果。设计上我保持了几个约束。

首先是评委与作品的分配关系。管理员在评审开始前,为每个竞赛配置一组评委,系统按规则自动分配作品。分配逻辑很简单:均匀分配,每个作品分配到2到3名评委,同时确保评委不评审本学院的作品(如果有相应规则)。这部分做成后台配置项,不同比赛可以灵活调整。

其次是打分幂等性。评委提交打分时,前端按钮要做防重复点击,后端靠之前提到的唯一索引兜底。接口返回时要捕获DuplicateKeyException并转成友好提示,而不是抛出500。评审结束后,管理员可以一键计算成绩。计算规则我设计为可配置:平均分、去掉最高最低后的平均分。这个规则存到比赛的score_rule字段里,算分时读取配置:

public BigDecimal calculateScore(Long competitionId) { List<ReviewEntity> reviews = reviewMapper.selectByCompetition(competitionId); return switch (scoreRule) { case AVERAGE -> average(reviews); case DROP_HIGHEST_LOWEST -> dropAndAverage(reviews); }; }

成绩计算必须在一个事务里完成,并且批量更新到作品表。这里我踩过一个坑:最初是一次性把所有作品查进内存再循环写库,作品数量超过500份时数据库连接等待明显。后来改为分页批量更新,每批100条,性能立刻稳住了。

5. 半年后再看这个项目:真正值钱的是幂等、状态机和文档

项目上线运行半年多,经过一届完整比赛验证后,我再回头审视这套系统,发现当初代码能跑通是一回事,能不能稳定运行是另一回事。这里面的经验,比写代码本身更值得沉淀。

5.1 线上踩过的三个坑:时区、编码、文件名

第一个坑是MySQL连接时区。我部署的服务端时间正常,但数据库时间差8个小时,列表里显示的比赛截止时间比实际晚了8小时。排查到最后是连接串里少了serverTimezone=Asia/Shanghai。这个问题最好在项目初期就通过统一的数据库URL参数解决,而不是靠修改应用层代码来补偿,否则多个环境很容易各修一遍又对不上。

第二个坑是文件名的中文乱码。前端上传文件时包含中文名称,下载时Header里的Content-Disposition如果不做URL编码处理,浏览器的中文名会变成乱码。我之前统一用UUID存储文件名,下载时再把原始文件名编码放回响应头,才彻底解决。

第三个坑是时间字段的精度。有一场比赛中,学生报名截止时间和系统判断时间差了十几秒,有队伍成功提交了报名,但报名时间在数据库中显示已经超过截止时间。这是因为前端页面用本地时间计算,和后端服务器时间存在偏差。后来所有时间展示都统一走后端返回值,前端不做任何本地时间假设,才消除了这类争议。

5.2 成绩Excel导出慢的问题排查

一次赛后,管理员要导出所有队伍成绩,两千多条记录,接口硬是转了二十多秒,页面一直转圈。一开始以为数据量大,后来发现根本不是数量问题,而是用POI的XSSFWorkbook把所有数据一次性写进内存,再加上查询本身没有分页,导致内存和CPU双双飙高。

修复思路分两层。查询层面,不能一次性把所有字段查出来,而是用游标或分页方式分批读取。导出层面,换成了SXSSFWorkbook流式写入,生成一行写一行,不会把整个Excel在内存中构建完毕才返回。改完之后,同样数据量从二十秒降到了两秒左右。

再往后我甚至把导出做成了异步任务:管理员点击导出,后端异步生成文件,完成后通过通知中心提醒下载。这个思路可以扩展到其他耗时操作上,比如统计报表、比赛证书批量生成,都是同一套模式。

5.3 公示期隐私脱敏

成绩公示这个环节看似简单,其实最容易踩到隐私红线。最初的公示列表把所有学生的姓名、学号、手机号完整展示,被负责老师提醒后我才意识到问题。高校里的竞赛公示,虽然面向校内,但学号和手机号属于个人敏感信息,不能直接完整暴露在页面上。

我做了两处改动。第一处,公示页面只展示队伍名称、作品名称和成绩,学生姓名只在详情页显示;第二处,手机号码统一脱敏,中间四位用星号代替。导出PDF版公示名单时也走脱敏逻辑。这个细节做起来很简单,但体现的是对业务流程的完整理解,也避免了很多后续麻烦。

5.4 文档怎么组织才不算白写

最后说回标题里的“源码+文档”。很多人写课程设计文档都是凑字数,写功能列表和界面截图,但真正的项目文档要做到“一个没参与开发的人拿着它也能维护系统”。我的文档分成了四部分。

第一部分是需求说明,重点是问题定义而不是功能列表。我花了很大的篇幅描述办赛流程中会出现什么场景、每种角色有什么诉求,这样后来人才能理解为什么要做出这些设计,而不是只会照着功能列表点点点。

第二部分是数据库设计文档,包含完整的字段字典、表关系说明、状态枚举解释。我在文档里把每张表的关键索引、唯一约束的设定原因全部写清楚了。一个表格字段为什么这样设计、为什么要有联合唯一索引,这些思考是没法从代码里直接读出来的,只能靠文档传承。

第三部分是接口文档。我直接使用Swagger生成基础接口描述,再补上关键业务接口的时序说明和异常返回码约定。统一返回体、统一错误码、统一时间格式,这些约定在文档里白纸黑字写清楚,避免后来人接手机车大战。

第四部分是部署手册。包括环境版本、建库初始化SQL、配置文件说明、启动命令、Nginx反向代理配置、备份脚本。目标是让一个新同学在一台干净的服务器上,按着手册半小时内把系统跑起来。这个标准,比任何精彩的功能介绍都更有价值。

最后说点个人体会

如果让我重新做一遍这个项目,我不会急着写代码,而是先把状态机和数据流图画完整,把所有“什么阶段能做什么事”的规则理清楚再动手。这样后面基本不会被推倒重来。另外,无论做什么项目,都要养成从第一天就维护部署文档的习惯,不要等到最后答辩前才开始补。项目运行半年后我发现,真正让一个系统活下来并持续被使用的,往往不是某个功能的炫技,而是状态机设计得是否严谨、接口是否幂等、文档是否能让下一个人快速接手。希望这篇复盘能帮正在做同类课题的人少走几个弯路。

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

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

立即咨询