简介:这是一套面向高校学科竞赛管理场景的全栈式Web系统,适用于毕业设计、课程设计及工程实训等教学实践环节,为管理员、教师和学生三类角色提供统一平台支持,解决竞赛全流程数字化管理难题。资源包共975个文件,含217个Java后端逻辑文件、153个JavaScript前端交互脚本、70个Vue组件、83个HTML页面及44个CSS样式文件,辅以SQL数据库脚本与多套静态资源(SVG/GIF/PNG),整体压缩包仅20.75MB,结构清晰、模块解耦明确。已有42人下载学习,可直接复现运行——包含教师管理、学生档案、竞赛发布、学院专业配置、获奖成果统计等五大核心模块,配套说明文档与批处理脚本(如install.bat、run.bat)便于快速部署。代码经实测功能完整,设计报告可直接参考,亦支持在现有架构上扩展新功能,是全栈开发入门与项目实战的优质范例。 高校学科竞赛平台这类项目,我这两年陆陆续续见过不少,有拿来当毕业设计的,也有学校信息中心老师私下找人做的。说句实话,大多数项目做完就躺在硬盘里吃灰,真正能被学校用起来的,往往不是功能最花哨的那个,而是把细节和权限逻辑理顺的那一版。最近刚好在复盘一个给某高校做的竞赛管理平台,结构和你这个标题基本一致——管理后台加用户网页端,角色覆盖管理员、教师、学生,模块包含教师管理、学生管理、竞赛信息、学院专业、获奖情况。趁着还有印象,我把它从需求拆解到落地实现完整梳理一遍,里面有很多是踩过坑之后才想明白的选型逻辑,希望能给你提供一些参考。
1. 这个平台到底在解决什么问题:竞赛管理的真实痛点
先聊一个很现实的事情:高校里的学科竞赛,以前是怎么管的?我调研过好几所学校,发现流程惊人地相似。教务处发一个红头文件,说今年有哪些A类竞赛、B类竞赛要开始了,各学院的教学秘书把通知转发到学院群,辅导员再转到班级群。学生报名的时候填个Excel表,打包发给教学秘书,秘书整理完发给教务处,教务处再手动汇总。比赛结束之后,获奖证书拍照、扫描、存网盘,最后填一张总表报给学校。
这个过程最大的问题,不是某个环节特别难做,而是所有环节都在用"文档传递"的方式做"流程管理"。Excel表在微信群和QQ群里被改名成"最终版""最终版2""最终版_千万不要再改了",数据重复录入,格式五花八门,审核人根本分不清哪一版是准的。到年底统计各学院竞赛成果的时候,教务处要花一两周时间翻聊天记录、开网盘、打电话核对,才能把数据拼完整。
所以这个平台解决的核心问题,不是"把Excel搬到网页上",而是把竞赛参与的全生命周期从线下流程变成线上协同。从竞赛信息发布、学生报名、教师审核、获奖录入、成果统计,每一个环节的数据只用录入一次,后续所有角色都在同一份数据上做操作,靠权限而不是靠口头确认来约束行为。
这三个角色在这个平台里的诉求是完全不同的:
- 管理员要的是掌控和数据汇总能力。学院专业怎么维护、教师怎么分配、年度获奖数据怎么导出,管理员关心的是"全校的竞赛盘子长什么样"。
- 教师要的是审核和指导效率。学生报名之后,老师要能快速看到项目信息,判断团队够不够格参加这个级别的竞赛,获奖了之后要能录入或者批量导入。
- 学生要的是信息获取和报名便捷性。竞赛通知从哪查、怎么组队、怎么上传材料、什么时候截止,学生只关心这些。
一个平台如果只做某一个人的需求,那它就是个小工具;如果能把这三种角色的需求同时满足,它才算是一个"平台"。
我在梳理这个项目的时候列过一个需求清单,里面有几个点我认为是这个平台区别于普通管理系统的关键。第一个是竞赛级别的概念,A类、B类、C类竞赛的认定标准不同,含金量也不同,它直接影响后续的获奖统计和教师工作量计算。第二个是竞赛与学院专业的弱关联,一个竞赛可能是面向全校的,也可能是某个学院的专业赛事,这里需要在竞赛信息和学院专业模块之间设计恰当的关联方式,不能写死。第三个是获奖情况与指导教师的关系,获奖数据不是孤立的一条记录,它需要能回溯到竞赛、团队、指导教师和学院,这样年终统计时才能多维度出报表。
2. 技术选型与整体架构:为什么我推荐Spring Boot加Vue这套组合
技术选型是这类项目里经常被问到的部分。管理后台加用户网页端,这本身就是一种前后端分离的典型形态。我给的方案是:前端用Vue 3 + Element Plus,后端用Spring Boot 3 + MyBatis-Plus,数据库用MySQL 8,权限认证用JWT + 拦截器实现。
先说为什么是Spring Boot。它不是性能最强、也不是最前沿的框架,但它是这个场景下生态最完整、团队协作成本最低的框架。高校项目、毕业设计、信息中心二次开发,这三类场景都有一个共同特点:人员流动率极高,项目要能被后来的人接得住。Spring Boot的MVC分层结构足够直观,Controller、Service、Mapper三层一拆,新人上手很快。MyBatis-Plus则是对SQL开发效率的极大提升,单表CRUD基本不用手写SQL,分页插件、逻辑删除、自动填充这些功能都是开箱即用。
前端选Vue 3而不是Vue 2,主要是考虑到Element Plus组件库对Vue 3的支持已经很成熟了。管理后台界面的核心组件无非是表格、表单、对话框、树形控件,Element Plus在这几块的表现非常稳定。而且Vue 3的组合式API(Composition API)在管理后台这种"多个模块共享逻辑"的场景里,确实比选项式API更适合抽出公共逻辑。
再往下是数据库选型。MySQL 8绝对够用,这个系统的数据量撑死几十万条,根本到不了分库分表的规模。唯一要注意的是字符集一定要用utf8mb4,不要用utf8,否则碰到存emoji或者某些特殊符号的朋友圈式内容会直接报错。
系统的整体架构分成三块:
- 管理后台(管理员和教师使用,也可以开放给教学秘书):负责学院专业维护、教师管理、学生管理、竞赛信息审核、获奖信息管理、数据统计。
- 用户网页端(学生使用为主):负责竞赛信息浏览、在线报名、报名进度查询、获奖公示查询。
- 后端接口服务:统一提供RESTful API,JWT做无状态认证,拦截器做角色权限校验。
这套架构有一个隐藏的好处,就是后续想加一个移动端或者小程序端的时候,后端接口不需要重新设计,只要把前端的容器换掉就行。很多学校真实情况是这个平台跑了一两年,教务处突然说想要个小程序版,如果你的接口是Restful风格且严格按照资源维度设计的,那这个扩展成本非常低。
数据库设计上,核心表有这么几张:
- sys_user:用户表,通过role字段区分管理员、教师、学生,也可以拆成三张表。我倾向于一张用户表加角色字段,因为登录逻辑统一,减少联表查询。
- teacher_info:教师信息表,关联user_id、学院ID、职称、研究方向。
- student_info:学生信息表,关联user_id、学号、学院ID、专业ID、年级。
- college_info:学院表。
- major_info:专业表,关联college_id。
- competition_info:竞赛信息表,包含竞赛名称、级别(A/B/C类)、主办方、报名开始/结束时间、竞赛状态。
- competition_signup:报名表,关联竞赛ID、学生ID(或团队ID)、指导教师ID、报名状态。
- award_info:获奖情况表,关联竞赛ID、学生/团队ID、获奖等级、获奖时间、指导教师ID。
这里值得提一下的是sys_user表的设计。很多初学者会把学生、教师、管理员设计成三张完全独立的用户表,然后登录的时候去三张表里分别查。这种设计最致命的问题是:一个账号如果既是教师又是管理员,那它要以哪种身份登录?我在系统里给用户表加了一个role字段,用数值枚举区分角色(1管理员、2教师、3学生),登录接口根据账号去sys_user表查到用户后,返回一个角色标识和一张更详细的个人信息表。这样即使用户同时有多重身份,也可以用一套登录逻辑覆盖。考虑到有的学校想把一个账号绑定多个角色,再进一步可以加一个user_role关系表,做成标准RBAC模型。对大部分学校场景来说,单一角色字段其实已经够了,但如果想要更好的扩展性,直接做用户-角色-权限三层关系表会走得更远。
3. 五大核心模块逐个拆解:从表设计到接口实现
这部分的代码逻辑是核心,我按模块来讲,每个模块都包含需求拆解、表设计要点和接口实现思路。代码层面的内容我会尽量贴出关键逻辑而不是全部堆上来,否则篇幅会失控。
3.1 教师管理模块:别把教师的业务字段塞进用户表
教师管理的需求,表面上看起来就是"增删改查一个教师列表",但做的时候有几个坑要避开。
第一个坑是用户表与教师业务表混在一起。用户表应该只负责账号、密码、手机号、邮箱、状态这些与身份认证相关的字段。职称、所属学院、研究方向、导师类型这些是教师业务属性,应该放在teacher_info表里,通过user_id关联。为什么这么拆?因为学生、管理员也需要登录账号,他们的业务属性和教师完全不同,如果把教师特有字段塞进用户表,这三类角色的字段会互相污染,表的维护会越来越难受。
第二个坑是教师的账号创建方式。我见过很多系统要求管理员手动录入每一位教师的账号密码,这个体验极其糟糕。合理的做法是:管理员在教师管理页面上录入教师基本信息时,系统根据工号自动生成初始账号(比如工号直接作为用户名,初始密码做一个统一规则比如身份证后六位),生成之后教师首次登录时强制修改初始密码。这样管理员的录入成本最低,安全性也更有保障。
接口设计上,教师管理模块核心接口如下:
- GET /api/admin/teachers:分页查询教师列表,支持按姓名、工号、学院筛选。
- POST /api/admin/teachers:新增教师,入参包含基本信息+账号信息。
- PUT /api/admin/teachers/{id}:编辑教师信息。
- DELETE /api/admin/teachers/{id}:删除教师(逻辑删除,保留历史数据)。
- PUT /api/admin/teachers/{id}/reset-password:重置密码。
- GET /api/admin/teachers/export:导出教师列表为Excel。
这里有个细节需要注意:删除教师时不能物理删除。一个教师如果早期带过竞赛团队或审核过报名,那么他的ID会被报名表、获奖表引用,物理删除会导致关联数据悬空。我用的方案是在user表加一个deleted字段,MyBatis-Plus的逻辑删除注解一加,所有查询自动带过滤条件,非常省事。
3.2 学生管理模块:批量导入是必须的
学生管理模块如果只支持手动单个录入,那这个项目做完发现根本没人在用。原因很简单:学校一年入学几千人,管理员不可能一个一个点新增。
所以学生管理模块的核心功能不是增删改查,而是批量导入。管理员下载一个Excel模板,按模板格式填好学生信息(学号、姓名、学院、专业、年级、手机号、邮箱),上传之后系统逐行解析并写入数据库。导入的时候还要考虑重复校验:学号是唯一键,如果同一批数据里有两行学号相同,或者数据库里已经有这个学号了,应该跳过该行并返回导入失败原因。
Excel导入的技术方案我用的是EasyExcel,阿里巴巴开源的。相比Apache POI,EasyExcel的API更简洁,内存占用更低,对大文件的支持更好。导入失败的原因列表可以存到一个全局变量或者Redis缓存里,前端在导入完成后轮询一次即可拿到错误报告。
学生管理接口:
- GET /api/admin/students:分页查询,支持按学号、姓名、学院、专业筛选。
- POST /api/admin/students/batch-import:批量导入学生数据。
- PUT /api/admin/students/{id}:编辑学生信息(改专业、改年级等)。
- DELETE /api/admin/students/{id}:逻辑删除学生。
- GET /api/admin/students/export:导出学生列表。
这里有一个业务细节很多人会忽略:学生转专业或者换学院之后,他参与过的竞赛、获奖记录还要不要归属到原来的学院?这个问题没有标准答案,每个学校的规定不一样。我一般会在学生信息编辑页面上加一个字段"历史归属学院",默认关联的是当前学院。如果学校做年度竞赛成果统计时要求按"参赛时所属学院"计算,那竞赛报名表里还要冗余一个"报名时所属学院"字段,否则转院之后数据就找不回来了。这个设计上的取舍一定要在开发前和教务处确认清楚,不然等数据录入了一两年之后发现口径不对,改起来会非常痛苦。
3.3 竞赛信息模块:状态机驱动流程
竞赛信息模块是整个平台业务最集中、也最体现系统设计水平的地方。一个竞赛从发布到结束,会经历多个状态,这些状态的流转构成了整个系统的核心业务流程。
我设计的竞赛状态流转大概是这样一个链路:
草稿 → 已发布(报名中) → 报名截止(评审中) → 已结束(归档)
当然有些竞赛还有 "报名暂停" 这种中间态,就是教务处发现报名人数爆满或者材料有问题,需要临时关闭报名入口。这个状态一般是可配置的,我建议用状态字段 + 修改接口而不是用单独的表去记录。
竞赛信息表的核心字段:
- competition_name:竞赛名称
- competition_level:竞赛级别(A类/B类/C类/校级/院级等)
- competition_type:竞赛类型(学科类/创新创业类/文体类等)
- organizer:主办方(某个学院、教务处或其他部门)
- signup_start_time / signup_end_time:报名起止时间
- competition_start_time / competition_end_time:竞赛举办时间
- max_team_members:单个团队最大人数
- allow_cross_major / allow_cross_college:是否允许跨专业/跨学院组队
- description:竞赛详情
- attachment_url:竞赛通知附件地址
- status:竞赛状态
在竞赛信息管理这个模块,发布审核是我额外加的一个小流程。管理员创建竞赛信息后,默认是草稿状态,需要审核通过之后才能在前台展示给学生。审核人默认是管理员自己,但也可以配置成学院教学秘书。这个小流程能避免一个尴尬情况:教务处还没最终确认的竞赛通知,学生这边已经看到了,报名报了三天,最后通知撤回重发。虽然这个流程增加了一步操作,但对于高校这种"通知要以红头文件为准"的文化环境来说,这一步省掉了大量后续解释工作。
学生端竞赛列表页的查询接口也很讲究。学生不需要看到"草稿"状态的竞赛,也不应该看到报名已经截止很久的竞赛。所以查询接口的最外层Service逻辑必须做状态过滤。我通常的做法是:前端传状态参数(如报名中、即将开始、已结束),后端根据参数进行过滤,保证学生端拿到的永远是可操作的竞赛数据。
3.4 学院专业模块:层级关系的建模
学院专业模块,表面上看就是两个树形下拉框的联动,实际做起来有一个很容易出问题的地方:学院和专业之间的关系到底是校园固定数据还是业务数据?
如果学院和专业是一张基础配置表,那更新频率极低;但如果是随着学校学科建设动态调整的,那就要考虑它的版本问题。我的处理方式是:学院表和专业表就这样平面化建模,专业表用college_id关联到学院表,传递出一个"专业属于学院"的层级关系。这样前端做联动下拉框非常自然:选中学院之后,根据学院ID去查专业列表。
但是这里有一个容易被忽视的点:一个学生报名竞赛时,他选的学院/专业应该用报名那一刻的学院/专业,还是用他当前最新的学院/专业?因为赛事奖项统计经常按学院来排名,如果你在获奖统计表里用"当前所属学院"去回溯历史数据,比如学生中途转去了另一个学院,历史获奖数据很可能就会错位到新学院名下。所以,我的建议是在报名表冗余一份报名时的学院ID和专业ID,这个值在报名动作发生时从学生表里复制过来。之后学生改了学院,历史报名记录和获奖记录的归属学院不会变,统计出来的数据才不会打架。
学院专业模块的接口相对简单,但树形级联接口写得好不好直接影响前端下拉框的体验。我提供两个接口配合使用:
- GET /api/common/colleges:查询所有学院列表(id, name)
- GET /api/common/colleges/{collegeId}/majors:根据学院ID查询专业列表
前端两个下拉框,一个绑定学院列表数据,一个监听学院下拉框的变化去动态加载专业列表。技术栈上如果用的是Element Plus,级联选择器(Cascader)可以直接绑二级树形数据,数据接口改成一次查出带children字段的树形结构更方便。两种方式都可行,看前端的组件习惯来决定。
3.5 获奖情况模块:统计报表是隐藏核心
获奖情况模块,是很多开发者容易低估的一个模块。程序员视角里,获奖情况就是一张表,存竞赛ID、学生ID、奖项等级、颁发时间,做几个增删改查就完事了。但真正落地的时候你会发现,这个模块的复杂度全部集中在统计报表和异构数据导入上。
第一块是获奖数据的录入方式。A类竞赛通常是大规模赛事,一个学校可能几十个团队参赛、拿了一堆奖回来,如果让管理员一个一个录,录入工作量大到难以想象。所以获奖情况模块必须支持按赛事实力的批量导入。导入模板的设计可以考虑:竞赛名称、获奖等级(国家级一等奖/国家级二等奖/省一等奖等)、获奖学生名单(同一个奖项下可以有多个人)、指导教师姓名。按"奖项"维度导入,导入时用事务保证数据一致性。
第二块是统计报表。学校教务处最常问的问题是这样的:"今年我们学校拿了多少个国家级一等奖?""软件学院去年在A类竞赛里拿了多少奖?""王老师今年带了几支队伍拿了省级以上的奖?"这些问题落到数据模型上就是多条件组合查询加分组统计。我会在获奖情况表上建立三个维度的索引:竞赛级别、获奖等级、获奖时间、获奖学院。统计接口的核心SQL用GROUP BY加COUNT函数,配合条件筛选,跑几十万条数据毫秒级返回,完全没有性能压力。
第三块是获奖证书的上传。每年评奖评优的时候,学生要去打印获奖证书的复印件,如果平台能直接提供一个证书上传功能,学生自行上传扫描件,管理员审核通过后存档,最后就能直接在系统里调出电子版证书。这个功能看似不起眼,却是实际使用反馈最好的功能之一。
这里有一个容易让后端崩溃的隐患:上传文件的存储位置。如果服务器磁盘不够大或者备份策略不到位,大量证书图片很容易把存储空间挤爆。建议做一个文件上传服务,文件存储在本地磁盘的某个专用目录或对象存储中,数据库里只存URL地址。部署时把这个目录挂载到独立的磁盘或外部存储,这样即使系统重装也不会丢文件。
4. 管理后台与网页端双端联动:角色权限控制的实践细节
权限控制是这种多角色项目中风险最高、最容易出安全漏洞的部分,也是面试和验收时最容易被问到的技术点。
4.1 基于RBAC的角色权限设计
权限模型我用的是经典RBAC(Role-Based Access Control,基于角色的访问控制)。用户表关联角色,角色关联权限,权限对应具体的接口或操作。在这个项目中,由于角色只有三种,其实可以简化成"用户表 + 角色字段 + 接口注解校验",不过为了后续可扩展,我会建议的标准做法是做成五张表:用户表、角色表、权限表、用户角色关系表、角色权限关系表。
在这个项目里权限矩阵可以这样梳理:
| 功能模块 | 管理员 | 教师 | 学生 |
|---|---|---|---|
| 管理后台登录 | 支持 | 支持 | 不支持 |
| 用户网页端登录 | 支持 | 支持 | 支持 |
| 教师管理 | 增删改查 | 只读 | 不可见 |
| 学生管理 | 增删改查 | 只读 | 不可见 |
| 竞赛信息发布 | 全部操作 | 可创建/可编辑 | 可浏览/可报名 |
| 报名信息审核 | 全部操作 | 可审核 | 可提交/可查看本人记录 |
| 获奖情况管理 | 全部操作 | 可录入/可查看 | 可查看本人获奖 |
| 数据统计 | 全维度 | 本人相关 | 本人相关 |
我实际编码时不建议每个接口都写死角色判断代码,这样代码量巨大且容易漏。用Spring Boot的拦截器加自定义注解,把角色校验做成一个通用的切面,代码会干净得多。核心逻辑是:
- 登录成功后返回包含用户ID和角色信息的JWT令牌。
- 前端每次请求在HTTP请求头里带上Token。
- 后端拦截器解析Token,取出用户ID和角色。
- 调用接口前,通过自定义注解(比如@RequireRole("ADMIN")或@RequireRole({"ADMIN","TEACHER"}))判断当前用户角色是否匹配。
- 不匹配时返回403状态码,前端统一跳转无权限页面。
这里要特别注意一个安全细节:不要把角色信息直接明文写在JWT里然后完全信任它。JWT虽然可以防止篡改,但用户改自己的浏览器存储或者用其他工具伪造Token时,服务端如果没有正确验签,角色信息是可以被伪造的。所以服务端必须用固定密钥对JWT做签名验证,并且每次请求除了验签之外,最好再从Redis缓存或数据库里校验这个用户的当前角色是否仍在有效期内(比如管理员被降权了,旧Token不应该继续有效)。
4.2 双端的会话保持和路由守卫
管理后台和用户网页端虽然是两个前端工程,但共用一个后端服务和同一个Auth接口。登录成功后,后端返回令牌和角色信息,前端拿到后存到本地存储或内存状态管理器里,然后根据角色做路由守卫。
管理后台的路由守卫逻辑是:访问任何页面之前,先判断本地是否存有有效令牌;没有令牌直接跳登录页;有令牌但角色不在当前页面的允许列表中,跳转无权限页面。用户网页端的逻辑类似,但学生可以访问竞赛列表、报名、个人中心;教师和管理员也可以进用户端查看前台效果,但他们的操作入口还是引导到后台管理页面。
前端路由守卫的一个坑是:如果只做前端路由跳转判断,用户完全可以绕过页面按钮直接调用后端接口。所以前端路由守卫只是改善用户体验,真正的安全底线在后端接口的权限校验。我在做验收评审时经常演示的操作就是:打开浏览器控制台,手动调用一个管理接口,看后端是否会拒绝。如果你只做了前端隐藏按钮没做后端校验,这一下就穿帮了。
4.3 用户网页端的核心流程:报名状态机
学生端最核心的流程是竞赛报名。一个报名记录的状态,我自己设计了这样一个状态机:
待提交 → 待审核 → 已通过/已驳回 → 已取消
学生在竞赛详情页点击"报名"后,系统创建一个报名记录,状态是待提交。学生填写团队信息、上传材料、确认无误后点击"提交审核",状态变为待审核。教师(通常是指定竞赛的指导教师)在管理后台看到待审核列表,点开详情,检查材料是否齐全,然后选择通过或驳回。如果驳回,必须填写驳回原因,学生端可以看到原因并修改后重新提交。
为什么要有"待提交"这个状态而不是直接创建就变成待审核?因为我见过很多学生是填了一半就退出的,如果直接进入待审核,教师看到一堆半成品材料会非常恼火。表单做成草稿机制,学生填不完可以先存着,下次再进来接着填,这个体验对用户来说太重要了。
报名记录的表结构要注意设计好唯一约束:一个学生(或一个团队)同一个竞赛只能有一条有效报名记录。数据库层面用竞赛ID+学生ID做联合唯一索引,防止并发情况下重复提交。
5. 从零搭建到部署运行的完整复现:可以直接照抄的步骤和代码
这套系统如果让你从零开始搭,最稳的路线是什么?我按实际操作顺序把关键步骤列出来,每一步都标注了容易踩坑的地方。
5.1 后端工程初始化
先用Spring Initializr创建一个Spring Boot 3.x项目,添加依赖时勾选:Spring Web、MySQL Driver、Spring Security(可选,如果你用的JWT自定义拦截器,也可以不勾)、Lombok、Spring Validation。然后手动引入MyBatis-Plus和JWT相关依赖。
一个标准的pom.xml核心依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>这里最容易踩的坑是MyBatis-Plus版本和Spring Boot版本的兼容性。MyBatis-Plus 3.5.5及以上建议配合Spring Boot 2.7+或3.x使用。如果你用的是Spring Boot 3.0以下版本,注意javax包名和jakarta包名的差异,这会导致启动直接报ClassNotFoundException。
application.yml的配置模板:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/competition?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword servlet: multipart: max-file-size: 20MB max-request-size: 100MB mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0逻辑删除的配置是我每次都强调的,不配置的话MyBatis-Plus的@TableLogic注解不会生效,删除操作还是物理删除。
5.2 用户认证:JWT工具的封装
JWT工具类我建议包装成一个单独的组件,包含生成Token、解析Token、验证Token三个方法。核心逻辑如下:
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire-hours}") private Long expireHours; public String generateToken(Integer userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expireHours * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } public boolean validateToken(String token) { try { parseToken(token); return true; } catch (Exception e) { return false; } } }对于jwt.secret,建议设置一个至少32位的随机字符串,不要用默认值。这个密钥一旦泄露,任何人都可以伪造Token,后果很严重。
拦截器实现权限校验时,我把角色校验的逻辑写在了一个自定义注解上:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); }然后在拦截器的preHandle方法里,根据请求路径找到对应Controller方法上的注解,判断当前用户角色是否在允许列表中。
5.3 前端工程的搭建
前端拆成两个工程:admin-web(管理后台)和user-web(用户网页端)。为了减少重复工作,两个工程共用一套登录组件和HTTP请求封装,但页面和路由完全独立。
这里我建议用Vite而不是Webpack来构建,因为Vite在开发环境下的冷启动速度比Webpack快一个数量级,调试体验好太多。项目创建命令:
npm create vite@latest admin-web -- --template vue npm create vite@latest user-web -- --template vue然后安装Element Plus和Vue Router:
npm install element-plus npm install vue-router@4Element Plus的引入方式,我推荐按需自动导入。配一个unplugin-auto-import和unplugin-vue-components插件,用哪个组件自动引入哪个,避免打包体积过大。
前端项目里,HTTP请求封装我一般用Axios。核心配置包括:基础URL(开发环境代理到后端8080端口)、请求拦截器(自动附加Token)、响应拦截器(统一处理401跳登录、403跳无权限、500弹错误提示)。这些配置一次写好,所有模块只需要关心业务接口调用,不用重复处理错误状态。
5.4 核心业务实现示例:报名接口
以用户报名为例,我给出完整的业务实现思路。报名接口的Controller:
@RestController @RequestMapping("/api/signup") public class SignupController { @Autowired private SignupService signupService; @PostMapping("/submit") public Result submit(@RequestBody @Validated SignupRequest request, @RequestAttribute("userId") Integer userId) { signupService.submitSignup(request, userId); return Result.success(); } }Service的核心逻辑:
- 根据userId查出学生信息,得到studentId和所属学院、专业。
- 检查竞赛是否存在、状态是否处于"报名中"。
- 检查是否已经提交过报名记录(联合唯一索引兜底)。
- 创建报名记录,设置状态为"待提交",同时将当前学院ID、专业ID冗余保存。
- 返回报名ID给前端。
这个流程里有几个细节值得注意:
- 竞赛状态判断非常关键。如果竞赛已经截止,接口必须拒绝新报名,这个逻辑不能只靠前端按钮禁用。
- 创建报名记录时回填学院、专业信息,是为了后面统计不出错。
- 报名的时候并不要求团队人员全部确定,可以先把队长的信息和竞赛名称留下,后续再编辑补充队员。如果一次填完所有信息才能提交,学生的放弃率会很高。
5.5 部署上线:从jar包到服务器
最终部署,我建议用很简单的方式:后端打成一个可执行jar包,前端两个工程分别构建出静态文件,交给Nginx托管。服务器上只要装一个JDK 17和一个Nginx就够了,不需要额外装Tomcat这些中间件。
后端打包:
mvn clean package -DskipTests java -jar competition-system.jar --spring.profiles.active=prod前端构建:
npm run build构建完之后会生成dist目录。Nginx配置如下:
server { listen 80; server_name yourdomain.com; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /admin/ { alias /opt/admin-web/dist/; index index.html; try_files $uri $uri/ /admin/index.html; } location / { root /opt/user-web/dist/; index index.html; try_files $uri $uri/ /index.html; } client_max_body_size 50m; }这个Nginx配置同时解决了三个问题:API反向代理到后端服务,管理后台和用户端的静态文件分别服务,以及前端路由的history模式刷新404问题。client_max_body_size用来放宽上传文件的大小限制。
6. 项目实测过程中的踩坑记录:这些问题你大概率也会遇到
这部分是我在这个项目开发和后期维护中实际遇到过的问题,挑几个代表性强的分享,每个问题后面我都会说明排查思路,而不是只给结论。
6.1 跨域问题:前后端分离的第一个拦路虎
开发环境下,前端跑在localhost的某个端口(比如5173),后端跑在8080,两者端口不一致,浏览器默认就会拦截跨域请求。页面表现就是前端发请求后控制台报错"CORS policy"。
解决方案在后端加一个全局跨域配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }需要注意,如果项目使用了Spring Security,这个CORS配置可能不会生效,因为Spring Security有自己的CORS过滤器优先级。你需要额外配置SecurityFilterChain的cors()方法。这也是我为什么倾向于不用Spring Security而是用拦截器做权限校验,配置少、出问题概率也低。
6.2 逻辑删除和唯一索引的冲突:一个很隐蔽的Bug
前文提到学生表用学号做唯一索引,同时开启了逻辑删除。这个组合有一个非常经典的问题:如果一个学生被删除后再重新录入,数据库里已经有了学号A的记录(deleted=1)和新插入的学号A记录(deleted=0),但因为唯一索引把两个deleted都算进去了,第二行插入会直接报Duplicate entry。
解决思路有两种:一是去掉学号的唯一索引,在应用层自己校验;二是把deleted字段改成可以区分真实删除次数的数值,比如每次逻辑删除时deleted字段设置为当前时间戳,而不是固定为1,这样唯一索引就天然区分开了。MyBatis-Plus默认逻辑删除值必须统一,所以如果没有特殊配置,我建议直接在应用层做唯一性校验,牺牲一点性能换可维护性,实际使用中完全够用。
6.3 时间字段的时区问题:早上8点变成了凌晨4点
因为MySQL的serverTimezone配置原因,前后端展示的报名时间经常会出现8小时偏差。解决方法是三方统一:在数据库连接串上加上serverTimezone=Asia/Shanghai,在JVM启动参数里设置-Duser.timezone=Asia/Shanghai,前端显示层统一用后端返回的时间字符串,不要在前端做时区转换。这个问题很小,但一旦出现,用户反馈会异常激烈,因为报名截止时间错乱会直接影响参赛资格。
6.4 Excel导入中的并发问题
如果管理员同时上传两个Excel文件,两个文件里又有同一个学号,就会出现在导入过程中并发写入冲突的问题。我的处理方式是在导入接口里加了synchronized锁,保证同一时间只有一个导入任务在执行。对于多个管理员同时操作这种场景,虽然Sync锁是单机级别,但对这个系统的并发量来说完全够用。如果你后续想更稳妥,可以把导入任务改成异步队列,前端轮询进度,不过这会增加不少复杂度,非必要不推荐。
6.5 文件上传的磁盘爆满
获奖证书、竞赛附件、头像,这些文件堆积起来很容易把系统磁盘占满。我后期给这个项目加了一个简单的定时任务:每周检查一次上传目录的磁盘占用,超过阈值时自动压缩最近3个月以前上传的附件,并清理临时文件。另外,给Nginx的client_max_body_size设置上限(比如50m)也能防止大文件暴力占满磁盘。
7. 验收与交付:功能之外的东西往往决定项目生死
代码写完了,功能跑通了,但项目真正交付到学校手里,还有很多细节需要打磨。这部分我称之为"让系统活下来的关键"。
第一是数据初始化。空系统是没法用的。你需要根据目标学校的实际数据,提前录入学院、专业、重点教师、部分竞赛信息。更理想的情况是有一个数据导入组件,将学校已有的Excel数据一次性导入。这个环节做得是否到位,直接影响用户第一印象。
第二是权限初始化。管理员账号必须提前创建好,并检查角色权限是否配置正确。最好再创建一个演示用的教师账号和学生账号,方便验收的时候给不同角色演示不同流程。
第三是操作手册。虽然现在大家都不爱看书,但一份简明扼要的用户操作手册还是很有必要的。不需要很长,重点写清楚三个角色的核心操作流程,配截图。手册的读者是教务处的老师和参赛学生,不是程序员,所以一定要用大白话写。
第四是部署文档。这个文档是给未来接手这个系统的人看的。服务器IP、数据库账号密码、Nginx配置位置、jar包启动命令、备份策略,所有这些信息必须写成文档,放在项目仓库里。不然过个半年,你或者接手的人要花一整天时间才能还原系统是怎么跑起来的。
还有一个我认为很重要的点:用户反馈通道。在管理后台右上角放一个"意见反馈"入口,学生和教师在使用过程中可以把问题和建议直接提交到管理员的待办列表里。这看起来只是一个小功能,但它让系统变成了一个能自我迭代的活体,而不是交付完成就死掉的展览品。一个能根据用户反馈不断改进的平台,才能真正扎根在学校的日常运作里,这也恰恰是这类项目最有价值的地方。
最后再分享一个小经验:做这类系统的时候,多去和实际使用者聊一聊,特别是负责竞赛组织工作的教学秘书。他们能告诉你Excel表里那些隐藏的空格、特定的命名习惯、统计报表里哪些列是必须的。你的技术能力决定了系统的上限,对业务的理解深度决定了系统的下限。把业务吃透,才是这类项目做成的分水岭。
本文还有配套的精品资源,点击获取