1. 项目概述与整体设计思路
做高校信息化这些年,我接手过不少竞赛管理需求。说实话,很多学校的现状就是“Excel收集报名信息 + 微信群反复确认 + 管理员熬夜审核”,数据分散、版本混乱、稍有批量操作失误就要全部重来。一套能够把竞赛全流程搬到线上、让管理员和师生在同一个系统里完成各自操作的平台,就成了刚需。
这套基于SpringBoot + Vue的大学生竞赛管理系统,是我在多个真实项目中沉淀下来的方案。系统的核心价值在于覆盖了竞赛管理的完整闭环:从竞赛发布、网上报名、材料提交、后台审核,到成绩录入、证书管理、数据统计。管理员、指导教师、学生三类核心角色各有门户,互不干扰。对正在做毕设、准备接高校外包项目,或者想把学校竞赛组织工作从线下搬到线上的朋友来说,这套系统的架构思路、表结构设计和关键代码实现都有很直接的参考价值。
3.1 功能模块划分
整个系统按用户角色拆成三大端,每一端的功能边界非常清晰。
- 管理员端:竞赛信息发布与维护、报名审核、竞赛类别管理、院系管理、用户管理、成绩管理、证书信息登记、统计报表。
- 教师端:指导学生报名、查看名下学生参赛情况、录入竞赛成绩、审核自己负责赛项的报名材料。
- 学生端:浏览竞赛列表、在线报名、上传作品材料、查看审核结果、查看成绩与证书。
这种角色的划分方式不是随便写的,而是从实际业务流里提炼出来的。竞赛管理最怕的不是功能少,而是职责混乱。比如“谁能审核”这件事如果不做权限隔离,光靠管理员一个人去处理几千条报名数据,系统上线第一天就会变成事故现场。所以我建议你在设计任何管理类系统时,先把角色和操作权限边界画清楚,再开始写代码。
3.2 为什么选SpringBoot + Vue这套组合
选型这件事,我一直强调要“看场景”而不是“追热门”。对于高校内部管理系统,SpringBoot + Vue在我眼里几乎是当前最稳妥的组合,理由有几点。
后端用SpringBoot,主要看中它的生态成熟度和开发效率。Spring Boot的自动配置机制把过去SSH时代一堆繁琐的XML配置全部干掉,一个依赖、一个注解就能快速起服务。而且国内高校信息部门普遍对Java技术栈的接受度最高,后续维护、交接、扩展都很方便。对于竞赛管理系统这类业务逻辑以CRUD为核心的系统,SpringBoot加上MyBatis-Plus之后,单表操作几乎不需要手写SQL。
前端选Vue,看中的是它的渐进式设计和中后台生态。Vue对新手友好、模板语法直观,配合Element Plus这类组件库,几天就能搭出一套界面像样的管理系统界面。而且Vue的组件化开发模式,正好契合竞赛管理系统这种“列表页 + 表单页 + 详情页”重复度高的项目,把通用逻辑抽成组件之后,后续加新模块的边际成本很低。
这套组合还有一个实际优势:前后端分离之后,接口可以完全复用。学校如果将来要把系统能力开放给微信公众号或者小程序,后端接口稍做适配就能继续用,不需要重新写一套业务逻辑。
2. 数据库设计与核心表结构
很多初学者拿到项目先从Controller开始写,这是我一直反对的做法。数据库表结构是整套系统的地基,表设计错了,后面怎么写都别扭。我在设计竞赛管理系统时,遵循的核心思路是“用表结构还原业务流程”。先理清业务会经过哪些环节,每个环节产生什么数据,再反推需要哪些表。
2.1 核心数据表总览
- sys_user(用户表):存放三类用户的统一登录信息,通过role字段区分角色。
- sys_role(角色表):角色定义,与用户表是多对一关系。
- competition_category(竞赛类别表):用于给不同竞赛分类,例如“学科竞赛”“创新创业”“技能大赛”等。
- competition_info(竞赛信息表):竞赛本身的基本信息和报名时间窗口。
- competition_enrollment(报名记录表):学生报名记录,是整个系统最核心的表。
- review_record(审核记录表):记录报名材料审核的每一次操作痕迹。
- competition_schedule(竞赛日程表):记录初赛、决赛等赛事节点。
- competition_score(成绩表):记录学生或团队在各赛项中的成绩。
- certificate_info(证书信息表):记录获奖证书发放情况。
除了常规字段,所有业务表都必须包含create_time、update_time、deleted三个字段。create_time和update_time我建议让数据库自动填充,业务代码里不需要手动set,能少写很多重复代码。deleted字段是做逻辑删除用的,竞赛相关的历史数据有很强的回溯价值,物理删除风险太大,我从来都是逻辑删。
2.2 竞赛信息表的关键设计
竞赛信息表是这个系统的“内容源头”,竞赛能不能成功组织,信息字段设计占一半。下面这个表结构经过多个项目验证,复制到项目里基本可以直接用。
CREATE TABLE `competition_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `competition_name` varchar(200) NOT NULL COMMENT '竞赛名称', `category_id` bigint(20) DEFAULT NULL COMMENT '竞赛类别ID', `competition_level` tinyint(1) DEFAULT NULL COMMENT '竞赛级别:1国家级 2省级 3校级', `organizer` varchar(200) DEFAULT NULL COMMENT '主办单位', `enroll_start_time` datetime DEFAULT NULL COMMENT '报名开始时间', `enroll_end_time` datetime DEFAULT NULL COMMENT '报名截止时间', `competition_start_time` datetime DEFAULT NULL COMMENT '竞赛开始时间', `competition_end_time` datetime DEFAULT NULL COMMENT '竞赛结束时间', `max_team_members` int(11) DEFAULT '1' COMMENT '团队最大人数', `max_teacher_count` int(11) DEFAULT '1' COMMENT '指导教师最大人数', `enrollment_limit` int(11) DEFAULT '0' COMMENT '报名人数上限,0为不限', `status` tinyint(1) DEFAULT '0' COMMENT '状态:0未开始 1报名中 2进行中 3已结束', `description` text COMMENT '竞赛详细说明', `cover_image` varchar(500) DEFAULT NULL COMMENT '竞赛封面图片URL', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除 0未删除 1已删除', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='竞赛信息表';这里我重点强调几个容易踩坑的字段。
报名时间窗口(enroll_start_time和enroll_end_time)和状态字段(status)之间要有一套联动逻辑。状态不应该只靠管理员手动改,而应该在后端提供一个定时任务或状态计算服务:当当前时间达到enroll_end_time后,状态自动从“报名中”切到“进行中”。否则就会出现竞赛都开赛了,学生还在提交报名材料的情况。我的做法是写一个Spring的@Scheduled定时方法,每分钟扫一次竞赛表,自动更新状态。虽然也可以用数据库事件,但放在应用层更可控,便于写日志。
2.3 报名与审核流程的表结构支撑
报名记录表是整个系统里业务逻辑最复杂的表,因为它要解决“一个人能不能报多个团队”“一个团队几个人”“材料怎么关联”这几个核心问题。我的设计思路是这样的:
CREATE TABLE `competition_enrollment` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `competition_id` bigint(20) NOT NULL COMMENT '竞赛ID', `student_id` bigint(20) NOT NULL COMMENT '学生用户ID(队长)', `team_name` varchar(200) DEFAULT NULL COMMENT '团队名称', `member_names` varchar(1000) DEFAULT NULL COMMENT '团队成员姓名,JSON数组格式存储', `teacher_names` varchar(500) DEFAULT NULL COMMENT '指导教师姓名,多个用逗号分隔', `contact_phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `materials` varchar(2000) DEFAULT NULL COMMENT '报名材料URL列表,JSON数组', `status` tinyint(1) DEFAULT '0' COMMENT '审核状态:0待审核 1通过 2驳回', `review_comment` varchar(500) DEFAULT NULL COMMENT '审核意见', `review_time` datetime DEFAULT NULL COMMENT '审核时间', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '报名时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='竞赛报名记录表';报名表设计里有三个细节值得记住。
一是团队成员用JSON数组存储,而不是单独拆一张团队成员表。很多人看到“一群人”就往关系表方向去想,但对于竞赛报名这个场景,成员信息提交后基本不需要单独维护,拆表反而让提交和查询复杂化。只有当团队成员需要独立登录系统、独立提交材料时,才值得拆关系表。
二是审核状态用整数而不是字符串。status字段用0、1、2来映射“待审核、通过、驳回”,比直接存中文更省空间,索引效率更高。开发时在枚举类里做映射,可阅读性也不差。
三是审核记录单独拆表。报名表里虽然有审核意见和审核时间,但这只是“当前状态”。如果学校有审计要求,需要查“谁在什么时候改了什么”,就必须有一张review_record表来记录历史轨迹。这个功能在竞赛复查时非常有用,但很多系统都忽略了。
3. 后端关键实现深度拆解
后端工程我建议按标准的SpringBoot多模块或单一分层结构组织。对于竞赛管理系统这个体量,单一工程分controller、service、mapper、entity、common五个包就够,不要把简单项目过度工程化。
3.1 项目结构与Maven依赖配置
<dependencies> <!-- Web 支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- Spring Security + JWT --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <!-- MyBatis-Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- Redis,用于JWT令牌黑名单或缓存 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>重点说一下两个依赖选择。
JWT库我选了jjwt 0.9.1,虽然版本不算新,但胜在稳定可靠、文档多、网上解决方案多。0.11以上的新版API改动很大,用起来反而容易卡壳。如果项目对安全要求更高,建议换成java-jwt或自己封装,不过对竞赛管理这种内部系统,jjwt完全够用。
Redis在系统里的角色比较微妙。JWT本身是无状态的,遇到用户退出登录、管理员禁用账号这类场景就不好处理。我的方案是引入Redis维护一个JWT黑名单,token在退出时写入黑名单并设置剩余有效期。这样既保留了JWT的优势,又弥补了它“不可撤回”的短板。如果你不想引入额外中间件,也可以不做这一步,但对系统的安全性会有影响。
3.2 基于Spring Security + JWT的权限认证
竞赛管理系统存在三类角色,权限认证是后端安全的基石。网上很多教程喜欢写自定义拦截器去判断token,我在这里明确建议:能用Spring Security就别自己造轮子,尤其是涉及到多角色鉴权场景。
我在项目中把Spring Security的配置拆成了几个清晰的部分。WebSecurityConfig负责定义哪些路径放行,哪些需要认证,哪些只有特定角色能访问。JwtAuthenticationFilter负责在每次请求时从请求头中提取token、校验签名、把用户信息塞进SecurityContext。UnauthorizedHandler和AccessDeniedHandler统一处理未登录和被拒绝访问时的JSON返回格式。
@Configuration @EnableWebSecurity public class SecurityConfig { @Resource private JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/api/competition/list").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/teacher/**").hasAnyRole("ADMIN", "TEACHER") .requestMatchers("/api/student/**").hasAnyRole("ADMIN", "TEACHER", "STUDENT") .anyRequest().authenticated() ) .exceptionHandling(handler -> handler .authenticationEntryPoint(unauthorizedHandler) .accessDeniedHandler(accessDeniedHandler) ); http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }这段配置最关键的一行是requestMatchers("/api/competition/list").permitAll()。竞赛列表是学生浏览的入口,未登录也应该能看赛事信息,这样更符合实际使用场景。很多新手会把全部接口都锁上,结果学生打开系统先被弹到登录页,体验非常糟糕。
JWT过滤器实现的中文解释是“从请求头拿到Authorization字段,去掉Bearer前缀拿到token,解析token得到用户ID和角色,再把认证信息放进Spring Security上下文”。这里有一个初学者容易忽略的细节:解析token失败时不要直接抛异常,应该让请求继续走下去,让后面的认证入口处理器统一处理,否则会出现过滤器内部异常导致响应格式混乱的问题。
3.3 竞赛报名与审核的业务实现
报名接口是业务逻辑最重的一个接口,需要考虑的因素非常多。我在Service层写核心逻辑时,严格按照“查询校验 → 业务校验 → 写入数据”三步走。
第一步查询要查出竞赛信息和当前登录用户信息。第二步业务校验要逐项检查:竞赛是否存在、当前是否处于报名时间段内、是否已报满、当前用户是否已经报过名、团队人数是否超过上限。第三步再执行insert操作。
@Service @RequiredArgsConstructor public class EnrollmentServiceImpl implements EnrollmentService { private final CompetitionInfoMapper competitionInfoMapper; private final CompetitionEnrollmentMapper enrollmentMapper; @Override @Transactional(rollbackFor = Exception.class) public void enroll(EnrollmentRequest request) { // 1. 查询竞赛 CompetitionInfo competition = competitionInfoMapper.selectById(request.getCompetitionId()); if (competition == null || competition.getDeleted()) { throw new BusinessException("竞赛不存在或已下架"); } // 2. 校验报名时间 LocalDateTime now = LocalDateTime.now(); if (now.isBefore(competition.getEnrollStartTime())) { throw new BusinessException("报名尚未开始"); } if (now.isAfter(competition.getEnrollEndTime())) { throw new BusinessException("报名已截止"); } // 3. 校验是否重复报名 Long userId = SecurityUtils.getCurrentUserId(); LambdaQueryWrapper<CompetitionEnrollment> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(CompetitionEnrollment::getCompetitionId, request.getCompetitionId()) .eq(CompetitionEnrollment::getStudentId, userId) .eq(CompetitionEnrollment::getDeleted, false); if (enrollmentMapper.selectCount(wrapper) > 0) { throw new BusinessException("您已报名该竞赛,请勿重复提交"); } // 4. 校验团队人数 if (request.getMemberNames().size() > competition.getMaxTeamMembers()) { throw new BusinessException("团队成员数量超过限制,最多允许" + competition.getMaxTeamMembers() + "人"); } // 5. 执行插入 CompetitionEnrollment enrollment = new CompetitionEnrollment(); BeanUtils.copyProperties(request, enrollment); enrollment.setStudentId(userId); enrollment.setStatus(EnrollmentStatus.PENDING.getCode()); enrollmentMapper.insert(enrollment); } }在校验逻辑中,有一个顺序问题值得注意:先查竞赛和先做参数校验的顺序会影响代码的可读性和异常信息的准确性。我的习惯是先快速做“是否存在”这类基础校验,再做“时间窗口”“名额”等业务校验,最后做“重复性”校验,因为重复性校验的SQL开销相对更大。这样按成本递增的顺序安排检查项,大部分请求能尽早失败,减少无用查询。
还有一个很关键的事务问题——报名的insert操作必须加@Transactional(rollbackFor = Exception.class)。如果后续要同时写入审核记录、扣减竞赛名额等多个操作,任何一个失败都应该让全部操作回滚,否则会出现“报名记录不存在但名额被扣了”这种极其诡异的数据不一致问题。
审核接口的逻辑相对简单,但也要注意“状态流转”的不可逆性。我的处理方式是:审核操作只允许在“待审核”状态执行,审核后状态变为“通过”或“驳回”,不允许重复审核。代码里用UPDATE ... WHERE status = 0这种方式做乐观更新,更新行数为0说明已经处理过,直接返回“该记录已审核”。这个模式在并发情况下能有效防止双人同时审核同一记录导致的问题。
3.4 文件上传与Excel导入导出
竞赛报名场景里,学生需要上传作品文档、项目计划书、演示视频等材料。这类文件上传功能我建议统一封装一个FileController,使用OSS存储或本地存储均可。
文件上传的坑主要在大小限制和类型校验。SpringBoot默认的单个文件上传上限只有1MB,对竞赛材料来说明显不够。我一般会把全局配置调到50MB甚至100MB,然后针对不同业务场景做差异化限制。更严谨的做法是在上传前校验文件扩展名和MIME类型,防止上传可执行文件。高校内部系统也不能放松这一项,安全永远不嫌多。
spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MBExcel导入导出功能用在“批量导入学生信息”“导出竞赛报名汇总表”这两个场景里,非常提效。我用的是EasyExcel库,相比Apache POI原生API,EasyExcel的内存占用更低,处理万级数据的报名汇总表毫无压力。核心代码只有几行:
@PostMapping("/import") public void importExcel(MultipartFile file) { EasyExcel.read(file.getInputStream(), StudentImportModel.class, new StudentImportListener(studentService)) .sheet() .doRead(); } @GetMapping("/export") public void exportExcel(HttpServletResponse response) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String fileName = URLEncoder.encode("竞赛报名汇总", "UTF-8"); response.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx"); EasyExcel.write(response.getOutputStream(), EnrollmentExportModel.class) .sheet("报名信息") .doWrite(enrollmentService.getEnrollmentList()); }使用EasyExcel还有一个隐藏好处:它提供了Listener机制,可以在读取每一行数据时做校验和处理,真正实现“边读边处理”,而不是全部读进内存后再处理。对于高校的几千名学生数据来说,这个特性让导入过程又快又稳。
4. 前端Vue部分的高质量工程化实现
前端工程我一直强调“工程化”三个字,因为竞赛管理系统功能多、页面多,如果不做规范化设计,代码写到最后自己都理不清。Vue 3 + Vite + Element Plus + Pinia + Vue Router是我目前最推荐的组合,比Vue 2 + Webpack那套老方案启动速度快一个量级。
4.1 Vue项目结构与路由设计
我习惯把前端项目按modules思想组织,而不是按文件类型堆在一起。每个业务模块的页面、API请求、状态管理放到同一个目录下,后续维护时定位代码非常方便。
src/ ├── api/ # API请求封装 │ ├── request.js # Axios实例封装 │ ├── competition.js # 竞赛相关接口 │ ├── enrollment.js # 报名相关接口 │ └── user.js # 用户相关接口 ├── assets/ # 静态资源 ├── components/ # 通用组件 │ ├── Pagination.vue │ ├── FileUpload.vue │ └── StatusTag.vue ├── layout/ # 布局组件 │ ├── AdminLayout.vue │ └── StudentLayout.vue ├── router/ # 路由配置 ├── store/ # Pinia 状态管理 ├── views/ # 页面视图 │ ├── admin/ │ │ ├── competition/ │ │ ├── enrollment/ │ │ └── user/ │ ├── student/ │ │ ├── competition/ │ │ └── profile/ │ └── teacher/ │ └── review/ └── utils/ # 工具函数路由设计的核心是权限控制。我采用动态路由方案:用户登录后,前端根据返回的角色信息,动态添加对应角色的路由表。这样学生访问不到管理后台的地址,即使F12改路由也会被前端路由守卫拦截。
// router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/login', component: () => import('@/views/login/index.vue') }, { path: '/', component: () => import('@/layout/BasicLayout.vue'), redirect: '/dashboard', children: [] } ] const router = createRouter({ history: createWebHistory(), routes }) // 动态路由映射表 const roleRouteMap = { STUDENT: [ { path: '/student/competition', name: 'StudentCompetition' }, { path: '/student/enrollment', name: 'MyEnrollment' } ], TEACHER: [ { path: '/teacher/competition', name: 'TeacherCompetition' }, { path: '/teacher/review', name: 'TeacherReview' } ], ADMIN: [ { path: '/admin/competition', name: 'AdminCompetition' }, { path: '/admin/user', name: 'AdminUser' }, { path: '/admin/review', name: 'AdminReview' } ] } export function addDynamicRoutes(role) { roleRouteMap[role].forEach(item => { router.addRoute('/', item) }) }这里有一个实战细节:动态添加路由后,如果用户退出登录并换个角色登录,前面加进去的路由并不会自动移除。必须在退出时调用router.removeRoute()或重新初始化路由实例,否则会出现角色切换后路由混乱的问题。我在项目里就是重置整个router实例,简单粗暴但最可靠。
4.2 Axios封装与状态管理
Axios封装是前端工程质量的分水岭。好的封装应该做到:统一处理token注入、统一处理响应状态码、统一进行错误提示、自动跳转登录页。我提供一个我用了很多项目的封装思路。
// api/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' import { useUserStore } from '@/store/user' const request = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器:注入JWT request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) // 响应拦截器:统一处理业务码 request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') if (res.code === 401) { userStore.resetToken() router.push('/login') } return Promise.reject(new Error(res.message)) } return res }, error => { ElMessage.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } )状态管理我选择Pinia,因为它在Vue 3时代的API更简洁、类型支持更好,没有Vuex那么多样板代码。核心状态只需要维护用户信息、token、角色。竞赛列表数据该不该放进全局状态?我的答案是不需要。竞赛列表属于“页面局部数据”,通过接口拉取就够了。全局状态应该只放“多个页面都要改且要保持一致”的数据,滥用状态管理会让代码更难追踪。
4.3 核心页面组件实现
竞赛列表页面是最常见的列表+搜索+分页模式。使用Element Plus的el-table、el-form、el-pagination组合时,有一个规范我比较坚持:搜索条件和分页参数必须绑定到响应式对象上,并且在重新搜索时重置当前页码为1。
<template> <el-card> <el-form :model="queryParams" inline> <el-form-item label="竞赛名称"> <el-input v-model="queryParams.name" placeholder="请输入竞赛名称" clearable /> </el-form-item> <el-form-item label="竞赛级别"> <el-select v-model="queryParams.level" placeholder="请选择"> <el-option label="国家级" :value="1" /> <el-option label="省级" :value="2" /> <el-option label="校级" :value="3" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="handleSearch">查询</el-button> <el-button @click="resetSearch">重置</el-button> </el-form-item> </el-form> <el-table :data="tableData" v-loading="loading"> <el-table-column prop="competitionName" label="竞赛名称" min-width="180" /> <el-table-column prop="level" label="级别"> <template #default="{ row }"> <el-tag>{{ levelText(row.level) }}</el-tag> </template> </el-table-column> <el-table-column prop="enrollEndTime" label="报名截止" width="160" /> <el-table-column label="操作" width="200" fixed="right"> <template #default="{ row }"> <el-button link type="primary" @click="handleDetail(row)">详情</el-button> <el-button link type="success" @click="handleEnroll(row)">报名</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="queryParams.pageNum" v-model:page-size="queryParams.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="handleSearch" @current-change="handleSearch" /> </el-card> </template> <script setup> const queryParams = reactive({ name: '', level: null, pageNum: 1, pageSize: 10 }) const handleSearch = () => { queryParams.pageNum = 1 // 搜索时强制回第1页 loadData() } </script>这段模板代码里,el-tag状态标签是一个高频复用逻辑。我把“报名中”“已截止”“未开始”这类状态显示做成了单独的StatusTag组件,通过传入的status值自动映射颜色和文字,不同页面直接复用。这种小组件虽然简单,但对统一界面风格和维护效率的作用很大。
4.4 前端权限控制细节
前面提到路由层面做了动态控制,但页面内部的“按钮级权限”也需要注意。比如学生的报名页面,未登录时应该隐藏“报名”按钮;管理员页面,非管理员看不到“删除”按钮。只用路由权限控制是挡不住的,因为同一个页面可能同时被多种角色访问。
我的解决方案是用自定义指令v-permission,在组件上直接标注所需角色,然后在全局注册这个指令。
// directives/permission.js export const permission = { mounted(el, binding) { const { value } = binding const userStore = useUserStore() const userRole = userStore.role if (value && !value.includes(userRole)) { el.parentNode?.removeChild(el) } } }这个方案的优点是简单直接、侵入性小。缺陷是如果角色在运行时变化,已经渲染的按钮不会自动更新。但在竞赛管理系统里,用户的角色在登录后基本不会改变,所以这个缺陷可以接受。如果将来做权限更动态的系统,建议改用统一的指令扫描和响应式校验方案。
5. 部署配置与安全加固经验
这一部分是我在给学生项目做上线支持时,被问得最多、也最容易出问题的地方。
5.1 环境准备与配置要点
开发环境的配置有个反直觉的经验:SpringBoot的版本不要追新。版本太高有时会遇到依赖兼容性的坑,对很多初学者来说很难排查。我用得最顺的是SpringBoot 2.7.x系列。这套版本对还继续用javax包还是jakarta包的问题不会踩雷,而且大量网上的解决方案都是基于这个版本做的。
MySQL配置里,我建议显式设置时区参数,并且在JDBC URL中加上serverTimezone=Asia/Shanghai。这个问题第一次部署时经常导致时间差8小时的诡异问题。
spring: datasource: url: jdbc:mysql://localhost:3306/competition_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8前端环境配置的关键在vite.config.js,需要配置开发代理来避免跨域问题。前后端分离模式下,前端默认端口是5173,后端接口是8080,直接请求肯定跨域。
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })5.2 打包与部署
后端打包用Maven执行mvn clean package -DskipTests,生成jar包后通过nohup java -jar competition-system.jar &启动。前端执行npm run build后生成dist目录,这是一个纯静态文件目录,我通常直接放进Nginx的html目录下。
前后端分离部署的经典方案是:Nginx负责托管前端静态文件,同时把/api开头的请求反向代理到后端服务。这样线上环境同样没有跨域问题。
server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html/dist; index index.html; try_files $uri $uri/ /index.html; # 解决Vue路由的history模式刷新404问题 } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这一行try_files $uri $uri/ /index.html几乎是Vue项目部署的必背口诀。如果不加,用户刷新一个非根路径的页面(比如/admin/competition)就会遇到404。这是前后端分离部署中最常见的坑,没有之一。
5.3 安全加固清单
系统上线前,我一般会对照下面的清单做一遍安全自检。
- 数据库密码、Redis密码不要明文写在application.yml里,使用环境变量注入。
- 生产环境关闭SpringBoot的/devtools热部署和Swagger接口文档。
- 对管理员接口做操作日志记录,记录操作人、操作时间、请求参数。
- JWT密钥长度至少256位,定期更换。
- 上传文件要做类型校验、大小限制,文件名要重命名,防止路径穿越攻击。
- 登录接口增加验证码和失败次数限制,防止暴力破解。
- 定期备份数据库,至少保留最近30天的备份副本。
安全这块我的态度是“宁可过度,不可缺失”,尤其竞赛管理系统里躺着学生的姓名、电话、身份证号等敏感信息,一旦泄露影响极坏。
6. 常见问题与排查技巧实录
整理几个项目上线过程中经常碰到的问题,很多都是文档里查不到的实战经验。
6.1 后端常见问题与解决方式
数据库连接时区报错。报错信息会出现The server time zone value '�й���ʱ��' is unrecognized。解决办法有两个:一是重写JDBC URL加上serverTimezone=Asia/Shanghai,二是在MySQL执行set global time_zone = '+8:00'。我推荐两个都做,一劳永逸。
SpringBoot启动后Jackson递归序列化导致栈溢出。竞赛信息里经常有“团队列表”“参赛者名单”这类关联关系,如果实体类直接加@OneToMany这类注解,Jackson序列化时就容易陷入无限循环。我的建议是:在VO层处理关联数据,不在实体层暴露关系注解。实体类保持“数据库表的直接映射”,需要展示的聚合数据通过查询或组装成VO返回。
MyBatis-Plus分页查询失效。分页插件需要显式注入PaginationInnerInterceptor配置类,很多人只配置了application.yml里的分页配置就以为可以了。少配置这一个类,分页查询返回的total永远是0,翻页也失效,非常迷惑。
6.2 前端常见问题与解决方式
Vite启动缓慢。老项目用webpack启动可能需要几十秒,换成Vite就是秒开。如果Vite启动仍然慢,先排查是不是依赖安装不完整或node_modules里混入了太多无用依赖。可以执行npm prune清理多余包,再尝试升级Vite版本。
跨域请求报错。浏览器报CORS错误的处理思路一定是从“代理配置缺失”和“后端允许跨域缺失”两个方向排查。开发环境优先用Vite代理解决问题,生产环境用Nginx反向代理。不要动不动就写一个@CrossOrigin注解,这样生产环境反而会埋下隐患。
Vue路由history模式刷新404。这个坑在5.2节已经有解决方案,就是Nginx的try_files配置。如果你用的是Apache部署,对应配置是FallbackResource /index.html,原理相同。
6.3 开发协作与版本管理
如果这个项目是团队协作开发,我强烈建议从一开始就养成规范的Git提交习惯。我的项目一般约定:
- main分支始终稳定,不直接提交代码。
- dev分支是集成开发分支,每天同步合并。
- 功能分支命名使用feature/xxx,Bug修复分支使用fix/xxx。
- 提交信息必须写清楚“做了什么、为什么这么做”。
接口联调阶段前后端进度不同步怎么办?我建议后端接口先定义出统一的返回格式,前端开发时可以先用Mock数据或本地MockServer模拟响应。等后端接口就绪后再切换真实调用,这样两边的开发可以并行推进,效率不止翻一倍。
6.4 代码调试与排查技巧
后端接口报错时,我一般不用Debug断点去慢慢追。项目里几乎每个业务Service都加了操作日志的@Slf4j记录,关键分支都打了logger.info或logger.warn。线上环境直接通过日志文件的调用堆栈快速定位问题。初学者最大的误区就是只知道Debug,不知道看日志。生产环境没有断点可用,日志就是唯一的线索来源。
另外一个排查技巧也不得不提:前端报错时要先打开浏览器开发者工具,先看Network面板响应状态和响应体,再看Console的错误信息。很多新手一报错就贴代码问人,其实错误信息里已经明确告诉你了是404还是500,是哪行代码出错。把错误信息完整读一遍,80%的问题自己就解决了。
7. 从经验出发谈扩展与复用
竞赛管理系统这个项目看起来平平无奇,但它是很好的“管理类系统标杆”。如果你能把这个系统的CRUD、权限、文件上传、审批流、报表导出这一整套逻辑真正吃透,那么完全可以以此为模板扩展出很多其他系统。
如果学校或者团队后续有更多需要,我可以分享几条实际验证过的扩展路径。
第一个扩展方向是增加“在线评审”模块。竞赛评分环节目前多数学校还用线下打分表,但给每份作品分配评委、评委在线打分、系统自动汇总排名这个需求很常见,而且实现起来并不难。核心只需新增一个score表加一个评审权限等级,代码逻辑与报名审核高度相似。第二个方向是做“消息通知中心”。把报名通过、审核驳回、竞赛即将截止这些事件,通过邮件或微信公众号模板消息推送给对应学生。接入微信模板消息时,后端只需写一个通用的消息发送接口,前端在报名成功或审核状态变化处调用即可。
第三,建议把文件存储从本地磁盘迁移到云对象存储。本地存储虽然简单,但服务器磁盘扩容、备份恢复都很麻烦。竞赛材料的文件数一旦上来,很容易把磁盘打满,用云OSS可以有效规避这类运维问题。
最后是统计分析大屏。现在很多学院领导对“教学成果数据可视化”有直观需求。在现有系统数据基础上,做一个展示学校历年竞赛参与人数、获奖数量、获奖层级分布的大屏页面,用ECharts或DataV实现,数据的完整性和准确性是现成的,开发成本远低于从零开始搭一个数据平台。
我在实际项目中形成的心得是:做系统开发不要把“做完一个项目”当成终点,而是把系统的骨架、数据模型、技术选型当成面向未来的投资。把基础打扎实了,每一次小需求过来,都是往成熟平台上加砖添瓦,而不是又一次从零开始搭建。就拿这套竞赛管理系统来说,我在一个学校上线之后,第二年他们又要做“创新创业项目管理系统”,我直接把用户权限、文件上传、审核流这些模块平移过去了,数据库改一改,业务字段换一换,两周就交付了新系统,成本低到超出对方预期。
以上是所有模块的核心内容。最后再分享一个实操中比较容易被忽略的小细节:做管理员端批量审核时,接口一定要支持“通过一批、驳回一批”,而不是只能一条一条处理。这个需求是我在客户现场被逼着迭代出来的,当时他们面对八百多条报名记录,一条一条点审核,再耐心的人也受不了。加上批量接口之后,整个审核流程从两天缩短到一上午。这类“大工作量场景下的体验优化”,才是系统真正能否被用起来的关键。