☰
SpringBoot+Vue双创竞赛管理系统实战:状态机权限与部署避坑指南
2026/10/22 4:44:35 网站建设 项目流程

2025年接了个高校的双创竞赛管理系统,SpringBoot + Vue + MyBatis + MySQL这套组合,从立项到上线跑了完整流程。这篇文章不聊虚的,直接把我踩过的坑、设计时怎么想的、关键代码怎么落地的,全部分享出来。无论你是准备做毕业设计,还是导师突然丢来个项目要你顶上去,看完这篇应该能少走不少弯路。

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

双创竞赛(大学生创新创业竞赛)的申报和路演管理,本质上是一个带审批流程、带评分环节、带进度跟踪的业务系统。很多同学一上来就想着把页面做漂亮,实际上这类系统真正难的点在于流程状态的流转和多角色权限边界。

1.1 核心需求解析

我拿到需求的时候,学校这边的管理员提了一堆要求,总结下来核心就三类角色加一条主线:

  • 学生:注册登录、组建团队、填写项目申报书、上传附件、查看审核进度、报名路演。
  • 指导教师/评委:指导教师负责审核自己学生的项目,评委负责路演现场打分。
  • 管理员:配置竞赛批次、管理申报时间窗口、分配评委、管理路演场次、汇总成绩、导出数据。

主线其实就一条:学生申报 → 指导教师初审 → 管理员复审 → 立项通过 → 路演报名 → 现场评分 → 成绩公示。

这里有个很容易踩的坑:如果你把这条流程做成固定的死流程(比如必须教师初审完才能到管理员),大概率会被实际使用场景打脸。有的学院项目多,老师审不过来;有的项目是跨校合作,指导老师不一定在本校系统里。所以我最终采用的是状态机 + 灵活审批策略,而不是简单的一张流程表。

1.2 技术选型为什么是这套组合

SpringBoot + Vue + MyBatis + MySQL,这几年几乎成了中小型管理系统的事实标准。我选这套方案不是因为“大家都在用”,而是基于实际约束条件:

  • 开发周期紧:SpringBoot 的自动配置和 Starter 机制能大幅减少配置代码,我大概两天就搭好了基础框架。
  • 前后端分离便于并行:我这边写后端接口的同时,前端同学可以基于 Vue 的组件库(我用的是 Element Plus)同步开发页面,不用互相等。
  • MyBatis 更适合这种多表复杂查询:竞赛系统里有大量动态条件查询(按状态查、按学院查、按批次查、按关键字搜),MyBatis 的 XML 里写 SQL 非常灵活,比 JPA 那种自动生成的查询方式更可控。
  • MySQL 完全够用:又不是高并发电商系统,一个高校的竞赛系统,巅峰时期可能也就几百人同时操作,MySQL 配上合理的索引设计完全没问题。

注意:如果你的系统未来要承载多所高校联合大赛,建议把 MySQL 换成 PostgreSQL 或 MySQL 集群方案,但单校场景下没必要过度设计。

1.3 数据库设计的几个关键决策

数据库设计是这类系统最“坑”的地方,因为一旦上线后要改表结构,代价非常大。我分享一下核心表的设计思路:

用户表(user)

字段类型说明
idbigint主键
usernamevarchar(32)登录账号(学号/工号)
passwordvarchar(100)BCrypt加密后的密码
roletinyint1-学生,2-教师,3-评委,4-管理员
real_namevarchar(32)真实姓名
college_idint所属学院外键

项目表(project)核心字段:项目名称、项目类别(创新类/创业类)、项目简介、指导老师ID、负责人ID、当前状态、申报批次ID。状态我用的是status字段,0-草稿,1-待教师审核,2-待管理员复审,3-已立项,4-已驳回,5-已结束。

路演表(roadshow)核心字段:项目ID、路演批次、场地、开始时间、状态。这里要注意,一个项目可以参加多个路演批次,所以要设计成独立表,不能挂在项目表下面。

评分表(score)这表的设计容易犯迷糊。我用了roadshow_project_id关联路演和项目,再加judge_id和score_detail(JSON格式存储各维度得分)。

2. 核心功能模块拆解与实操要点

2.1 认证与权限:Spring Security + JWT 的正确用法

这个系统的权限需求很典型:不同的角色能访问的接口完全不同。我采用了 Spring Security + JWT 的方案,而不是传统的 Session 方案,因为前端是分离架构,JWT 无状态特性更适合接口调用。

后端配置要点:

  • 放行登录接口、注册接口,其他接口全部拦截。
  • 自定义OncePerRequestFilter,解析请求头中的 Token,把用户信息塞进SecurityContextHolder。
  • 方法级别用@PreAuthorize("hasRole('ADMIN')")控制访问权限。

JWT 生成时,我把userId和role放了进去。但有个安全提醒:不要把密码等敏感信息放进 Token。一般我会用 jjwt 库,生成代码大概是这样:

String token = Jwts.builder() .setSubject(userId.toString()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 2)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();

前端部分,我在 Axios 请求拦截器里统一带上Authorization: Bearer xxx,响应拦截器判断 401 状态码就跳转登录页。这里有个实际体验问题:Token 过期时间设多长?我试过 1 小时太短(学生填个申报书填一半就被踢下线了),最终设为 2 小时,同时在用户操作时做滑动续期。

2.2 项目申报模块:这个模块最考验细节

项目申报是整个系统的门面,也是学生最常用、最容易出问题的功能。

表单设计:我用的是el-form配合动态校验规则。项目申报涉及的信息很多:项目名称、项目类型、项目周期、经费预算、团队成员、指导教师、项目申请书附件、商业计划书附件等。

这里有一个容易忽略的点:附件上传。我用的是本地存储方案(配置一个upload.path目录),上传成功后把文件路径存数据库,下载时通过接口做权限校验后返回流。如果你要做的更规范一些,可以改造成对接对象存储(比如阿里云 OSS 或者 MinIO),但校内小规模系统本地磁盘就够用。

草稿功能:我强烈建议加上“暂存草稿”功能。学生填申报书往往不是一口气填完的,中途可能要去问导师、查资料。我设计的是所有表单字段绑定form对象,点击“暂存”按钮就调一次接口把数据存数据库status=0(草稿状态)。等到确认没问题再点“提交”,状态才流转出去。

有一次学生反馈说“提交后老师看不到”,排查后发现是事务问题——提交操作里同时更新了项目状态和生成了审核记录,没有加@Transactional注解,导致部分操作没生效。这类问题好在容易定位,但留给用户的体验很不好。

2.3 审批链路的实现:状态机思维

状态流转是整个系统的核心逻辑。我最开始用一个if-else写审批逻辑,后面发现代码越来越难维护,而且容易漏掉状态变更时的副作用(通知、日志等)。

改造成状态机模式后,逻辑变得非常清晰:

public enum ProjectStatus { DRAFT(0, "草稿"), PENDING_TEACHER(1, "待指导老师审核"), PENDING_ADMIN(2, "待管理员复审"), APPROVED(3, "已立项"), REJECTED(4, "已驳回"), CLOSED(5, "已结束"); private final int code; private final String desc; }

状态变更统一走一个服务方法,里面做三件事:

  1. 校验当前状态是否能转移到目标状态。
  2. 更新项目状态。
  3. 记录操作日志并通知相关人。

这样改完之后,后面加“撤回”“退回修改”等功能就非常容易,只是往状态表里加转移规则。

实操心得:不要把状态用字符串硬编码到业务代码里,比如到处写if(project.getStatus() == 3),一旦需求调整要改的点位多得想骂人。用枚举集中管理,爽。

2.4 路演管理:场地分配和时间线是难点

路演管理功能,需求是管理员创建路演批次(一场路演活动),然后学生报名,管理员分配场地和时间段,评委到场打分。

我的设计思路:

  • roadshow_batch表保存批次信息(名称、报名截止时间、开始时间、结束时间、地点)。
  • roadshow_registration表保存学生报名记录。
  • roadshow_session表按时间段切分,每个时间段绑定场地和项目。

前端这块我做了三个视图:管理员看到的是日历视图,学生看到的是可报名的列表,评委看到的是当天路演安排。

日历视图我用的是fullcalendar组件,这一块调试花了不少时间。要注意,后端返回给前端的时间格式一定要统一,我后端用LocalDateTime,前端都转成yyyy-MM-dd HH:mm:ss处理,有效避免了时区和新旧日期 API 混用导致的显示错误。

路演打分环节,评委端是个平板页面,列了今天自己要评审的项目列表,点进去给每个维度打分(创新性 30 分、可行性 25 分、团队实力 25 分、现场表现 20 分),总分 100 分,最后可以填写评语。这一块要注意的是防止重复提交,我做了两层防护:提交按钮 loading 置灰 + 后端校验该评委该项目的评分记录是否已存在。

2.5 数据统计与导出:给管理员的“面子”功能

管理员很看重数据统计和导出。这个模块虽然不难,但工作量不小,而且容易出格式问题。

统计图表:用 ECharts 做了几个关键视图:各学院申报数量对比(柱状图)、项目状态分布(饼图)、申报量随日期趋势(折线图)。后端只要提供聚合查询接口就行。

导出 Excel:用 EasyExcel 导出项目申报列表、成绩汇总表。这里有一个实践技巧:不要用 POI 直接写 Excel,太繁琐。EasyExcel 配合注解,一行代码就能导出复杂表头:

@ExcelProperty("项目名称") private String projectName;

导出的时候加上了“是否已立项”筛选条件,管理员可以一键导出全部,也可以按批次导出。

3. 实操过程与核心代码实现

3.1 项目初始化和基础配置

新建 SpringBoot 项目,核心依赖只需要引入:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

数据库连接配置我用的是application.yml,注意要开启驼峰映射,否则数据库字段project_name没法自动映射到 JavaBean 的projectName:

mybatis: configuration: map-underscore-to-camel-case: true

这一点太重要了,我见过不少新手在这里踩坑后到处给字段加别名,完全没有必要。

3.2 多角色动态查询的 SQL 写法

系统里涉及大量不同角色的数据查询,比如学生只看自己的项目、指导老师看带的学生项目、管理员看全部。我利用 MyBatis 的参数动态判断来复用 SQL:

<select id="selectProjectList" resultType="com.xxx.entity.Project"> SELECT p.*, u.real_name AS leader_name FROM project p LEFT JOIN user u ON p.leader_id = u.id <where> <if test="role == 1"> AND p.leader_id = #{userId} </if> <if test="role == 2"> AND p.teacher_id = #{userId} </if> <if test="status != null"> AND p.status = #{status} </if> <if test="keyword != null and keyword != ''"> AND (p.project_name LIKE CONCAT('%', #{keyword}, '%') OR p.project_type LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY p.create_time DESC </select>

这个查询同时服务了学生端、教师端和管理员端的列表页,只是传入的 role 和 userId 不同。这里有个小提醒:LIKE查询在数据量大的情况下会走全表扫描,可以考虑在project_name上加索引,但如果真的全表扫描了,数据量少时反而比索引查询快,我实测几千条数据完全没压力。

3.3 评分模块的事务控制

评分入库是必须严格保证原子性的。一个评分操作涉及:新增评分记录、更新路演报名表状态、更新项目的平均分冗余字段。

@Transactional(rollbackFor = Exception.class) public void submitScore(ScoreSubmitDTO dto) { // 1. 校验是否重复评分 ScoreRecord exists = scoreMapper.findByRoadshowProjectAndJudge( dto.getRoadshowProjectId(), dto.getJudgeId()); if (exists != null) { throw new BusinessException("该评委已对此项目评分"); } // 2. 插入评分记录 // 3. 更新关联状态 // 4. 重算平均分 }

@Transactional一定要写rollbackFor = Exception.class,默认情况下它只回滚 RuntimeException。如果里面有校验逻辑抛了自定义业务异常(我一般继承RuntimeException),不设置正确的话就会出现“评分存了,状态没更新”的诡异问题。

3.4 前端路由和权限控制

前端我用 Vue Router,路由配置里加 meta 角色限制:

{ path: '/admin/project-list', name: 'AdminProjectList', component: () => import('@/views/admin/ProjectList.vue'), meta: { roles: ['ADMIN'] } }

路由守卫里做角色判断:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); } else if (token) { const userInfo = JSON.parse(localStorage.getItem('userInfo')); if (to.meta.roles && !to.meta.roles.includes(userInfo.role)) { next('/403'); } else { next(); } } else { next(); } });

不过要说明白,前端路由守卫只是用户体验层面的控制,真正安全还是要靠后端的接口权限校验,前端隐藏了菜单并不代表别人不能直接调接口。

3.5 联调时的一个典型问题

前后端联调最容易出问题的就是接口字段命名不一致。比如后端定义projectType,前端写成project_type,结果页面一直报 undefined。

我的做法是:在后端统一返回Result封装对象(code、message、data 三个字段),所有接口都遵循这个格式。前端也在request.js里封装了一层拦截逻辑,只用关心业务数据,不用每个接口重复做错误处理。

如果前后端字段不一致,最坑的是假数据。可以把 Swagger(springdoc)配置上,前端模板页直接读取接口文档对照字段,别靠口头上互相传话。

4. 常见问题与排查技巧实录

4.1 附件上传失败:明明配置了路径,为什么还是报错?

这是我在实际部署中遇到的最多的问题。SpringBoot 默认上传文件大小限制是 1MB,稍微大一点的申请书 PDF 就会直接报错。一定要在配置里调大:

spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB

另外,本地存储路径一定要写绝对路径,不要用相对路径,因为如果在服务器上用 nohup 方式启动项目,当前目录跟项目根目录不一定一样,很容易出现文件存了但找不到的情况。我是这么处理的:

String uploadDir = System.getProperty("user.dir") + File.separator + "upload";

想再稳一点的话,可以把上传路径配置到 Nginx 能直接访问的目录(比如/data/upload),这样文件存进去之后,前端也能通过 Nginx 直接访问,而不用经过后端再读出来。

4.2 数据库时间比本地时间晚了 8 小时

这个问题遇到一次之后,我是直接当成必答题来预防了。原因是数据库连接串里没配时区参数,serverTimezone=Asia/Shanghai没有加,MySQL 默认用系统时区(通常是 UTC)处理。

检查一下连接串:

url: jdbc:mysql://localhost:3306/competition?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

另外在 MySQL 建表时,时间的默认值我统一用CURRENT_TIMESTAMP,尽量不用数据库时间做业务逻辑判断,而是由后端统一生成LocalDateTime.now()之后传入。这样少一层时区转换,排查思路也清晰。

4.3 部署之后前端页面样式丢失

这是 Nginx 配置的try_files问题。Vue 是 SPA 应用,路由是 history 模式,如果 Nginx 配置不对,刷新二级路由页面会返回 404,而且静态资源路径加不对会导致样式加载不出来。

正确做法是:

location / { root /opt/dist; try_files $uri $uri/ /index.html; }

把/opt/dist换成你实际部署前端静态文件的路径,刷新页面就不会出现白屏了。另外我一般给静态资源加个expires 7d缓存,减少服务器压力。

4.4 登录接口被频繁调用,怎么办?

系统上线后,我在服务器日志里看到有的 IP 频繁调用 login 接口,很明显是有人在尝试弱口令。这个要提前防:

一是引入简单的验证码机制,登录前先获取图形验证码,校验通过后才允许登录。我用的方案是后端生成图片验证码存 Redis 缓存,过期时间 5 分钟,前端把验证码图片渲染出来,登录时一并提交。

二是对密码做 BCrypt 加密。不要用 MD5,现在 MD5 加盐都能秒破解。Spring Security 自带的BCryptPasswordEncoder足够用,每次登录验证passwordEncoder.matches()即可。

三是接口加简单的限流,我用的是一个拦截器,针对同一 IP 一秒钟内超过一定次数就直接拒绝。这个不是并发系统,够用了。

4.5 数据库连接池耗尽导致系统卡死

这算是线上环境比较严重的问题。我排查时发现,某个批量导出接口的 SQL 写得很差,查询量太大且没有分页,导致数据库连接全部被占用,其他用户请求全部排队。

解决思路:

  • 配置 HikariCP 连接池最大连接数,不宜太大,MySQL 默认上限是 151 个连接,你设 200 也启动不了。
  • 所有列表接口必须分页,用 MyBatis 的 PageHelper,一行注解搞定:
PageHelper.startPage(pageNum, pageSize);
  • 批量操作(如导出全部数据)放到异步线程执行,不要占用请求线程连接。

其实这个问题的根子是 SQL 慢,排 SQL 用慢查询日志定位,开启slow_query_log和long_query_time=1,基本能找到罪魁祸首。

4.6 路演评分后成绩统计对不上

有一次管理员跟我说某个项目显示了两条评分记录,同一个评委评了两次。排查发现是前端“防重复提交”做的不够彻底,用户双击了按钮,两个请求几乎同时到达后端,后端的重复校验 SELECT 由于没有加锁,两个事务同时通过了校验。

解决很简单,两种方案任选:

  • 数据库加唯一约束,比如(roadshow_project_id, judge_id)唯一索引,这是最保险的兜底。
  • 后端代码里用分布式锁(单机环境用 synchronized 就够了)包住整个提交逻辑。

我后来是两条一起做,唯一约束兜底,代码里加synchronized(key 为roadshowProjectId + "_" + judgeId),彻底堵死了这个漏洞。

5. 一些你想知道的杂项细节

5.1 项目目录结构参考

我一直习惯用按功能拆包的方式,而不是按技术层拆包。按技术层拆包(controller/service/mapper放一起),项目一大了维护起来简直是灾难。我的目录结构是:

src/main/java/com/xxx/competition ├── config // 配置类(Security、MyBatis、CORS) ├── controller // 接口层 │ ├── admin // 管理员接口 │ ├── teacher // 教师接口 │ └── student // 学生接口 ├── service // 业务逻辑层 ├── mapper // MyBatis Mapper接口 ├── entity // 实体类 ├── dto // 入参DTO ├── vo // 出参VO └── common // 公共类(Result、异常、工具类)

按角色拆 controller 的好处是,前端的权限控制跟后端接口的对应关系一目了然,后面加权限时不会迷路。

5.2 有没有必要做 Redis 缓存?

我的答案是:单校场景,没必要,除非你要做验证码存储或 Token 黑名单之类。我这次只在登录验证码的场景用了 Redis,其他业务数据都没做缓存。原因是数据量小、变化频繁(用户申报状态经常变),不做缓存反而省心,加了缓存还要解决缓存一致性问题,不值当。

如果你的竞赛系统要承载多校联合赛事,几千人同时在线查项目列表,那确实要给热点查询加缓存,但这是另一个量级的系统了。

5.3 日志打印的一些建议

日志要打,但别乱打。我见过在 Service 每个方法里用System.out.println的,这在线上环境等于没打,日志文件里什么有效信息都捞不到。

统一用 Lombok 的@Slf4j,关键操作打 info 级别,参数校验失败打 warn 级别,异常打 error 级别(要带上堆栈),比如:

log.error("评分提交失败,roadshowProjectId={}, judgeId={}, errorMsg={}", dto.getRoadshowProjectId(), dto.getJudgeId(), e.getMessage(), e);

日志里尽量把所有关联的 id 都打出来,你排查时就明白,一个好日志能省半小时定位问题的时间。

5.4 前后端分离后,跨域问题怎么处理

开发环境下,前端跑在localhost:8081,后端跑在localhost:8080,必然有跨域问题。我试过在代码里配 CORS 全局过滤器,也用过反向代理(Vue CLI 的 devServer.proxy)来处理。推荐用后者,因为代码里不用写死允许的来源,生产环境也不用改代码。

开发环境:

// vue.config.js devServer: { host: 'localhost', port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

生产环境,前端 Nginx 配置里也做一个/api反向代理,指向后端服务,这样前端请求地址和后端地址永远同源,跨域问题从根子上就被规避了。

最后的一些实在话

做这类管理系统,技术难度其实不算高,真正考验一个人的是逻辑梳理能力和面对混乱需求时怎么拆解问题的能力。我接手这个双创竞赛系统的时候,需求方自己都说不清“路演评分到底是按项目评还是按团队评”,这些问题如果不在一开始问明白,后面返工成本高得离谱。

我自己的经验是:拿到这种多角色、带业务流程的系统,第一周不要写代码,先把角色、状态、页面清单画清楚。状态图画完、页面原型过一遍,后面开发速度反而会快很多。

这套 SpringBoot + Vue + MyBatis + MySQL 的组合,放到 2025 年的今天依然能打,学一遍不吃亏。真上了生产环境,多留意我上面提到的那些小细节——附件路径、时区问题、超时配置、重复提交,把这些搞定了,系统基本就能稳稳跑下去了。

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

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

立即咨询