ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+Vue大学生竞赛管理系统:从数据库设计到权限认证实战

SpringBoot+Vue大学生竞赛管理系统:从数据库设计到权限认证实战 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT竞赛报名记录表;报名表设计里有三个细节值得记住。一是团队成员用JSON数组存储而不是单独拆一张团队成员表。很多人看到“一群人”就往关系表方向去想但对于竞赛报名这个场景成员信息提交后基本不需要单独维护拆表反而让提交和查询复杂化。只有当团队成员需要独立登录系统、独立提交材料时才值得拆关系表。二是审核状态用整数而不是字符串。status字段用0、1、2来映射“待审核、通过、驳回”比直接存中文更省空间索引效率更高。开发时在枚举类里做映射可阅读性也不差。三是审核记录单独拆表。报名表里虽然有审核意见和审核时间但这只是“当前状态”。如果学校有审计要求需要查“谁在什么时候改了什么”就必须有一张review_record表来记录历史轨迹。这个功能在竞赛复查时非常有用但很多系统都忽略了。3. 后端关键实现深度拆解后端工程我建议按标准的SpringBoot多模块或单一分层结构组织。对于竞赛管理系统这个体量单一工程分controller、service、mapper、entity、common五个包就够不要把简单项目过度工程化。3.1 项目结构与Maven依赖配置dependencies !-- Web 支持 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 参数校验 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- Spring Security JWT -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency !-- MyBatis-Plus -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- Redis用于JWT令牌黑名单或缓存 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- Lombok -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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(); LambdaQueryWrapperCompetitionEnrollment 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原生APIEasyExcel的内存占用更低处理万级数据的报名汇总表毫无压力。核心代码只有几行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 :modelqueryParams inline el-form-item label竞赛名称 el-input v-modelqueryParams.name placeholder请输入竞赛名称 clearable / /el-form-item el-form-item label竞赛级别 el-select v-modelqueryParams.level placeholder请选择 el-option label国家级 :value1 / el-option label省级 :value2 / el-option label校级 :value3 / /el-select /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button el-button clickresetSearch重置/el-button /el-form-item /el-form el-table :datatableData v-loadingloading el-table-column propcompetitionName label竞赛名称 min-width180 / el-table-column proplevel label级别 template #default{ row } el-tag{{ levelText(row.level) }}/el-tag /template /el-table-column el-table-column propenrollEndTime label报名截止 width160 / el-table-column label操作 width200 fixedright template #default{ row } el-button link typeprimary clickhandleDetail(row)详情/el-button el-button link typesuccess clickhandleEnroll(row)报名/el-button /template /el-table-column /el-table el-pagination v-model:current-pagequeryParams.pageNum v-model:page-sizequeryParams.pageSize :totaltotal :page-sizes[10, 20, 50, 100] layouttotal, sizes, prev, pager, next, jumper size-changehandleSearch current-changehandleSearch / /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中加上serverTimezoneAsia/Shanghai。这个问题第一次部署时经常导致时间差8小时的诡异问题。spring: datasource: url: jdbc:mysql://localhost:3306/competition_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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: GMT8前端环境配置的关键在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加上serverTimezoneAsia/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/xxxBug修复分支使用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实现数据的完整性和准确性是现成的开发成本远低于从零开始搭一个数据平台。我在实际项目中形成的心得是做系统开发不要把“做完一个项目”当成终点而是把系统的骨架、数据模型、技术选型当成面向未来的投资。把基础打扎实了每一次小需求过来都是往成熟平台上加砖添瓦而不是又一次从零开始搭建。就拿这套竞赛管理系统来说我在一个学校上线之后第二年他们又要做“创新创业项目管理系统”我直接把用户权限、文件上传、审核流这些模块平移过去了数据库改一改业务字段换一换两周就交付了新系统成本低到超出对方预期。以上是所有模块的核心内容。最后再分享一个实操中比较容易被忽略的小细节做管理员端批量审核时接口一定要支持“通过一批、驳回一批”而不是只能一条一条处理。这个需求是我在客户现场被逼着迭代出来的当时他们面对八百多条报名记录一条一条点审核再耐心的人也受不了。加上批量接口之后整个审核流程从两天缩短到一上午。这类“大工作量场景下的体验优化”才是系统真正能否被用起来的关键。
返回列表