一个学科竞赛管理系统表面看就是几张表、几个页面真正把它当企业级项目做从角色权限到评审流程从数据一致性到上线部署每一环都有门道。这套基于SpringBootVueMyBatisMySQL的完整源码是我在实际项目中逐步沉淀下来的可运行版本。后端用SpringBoot扛业务、MyBatis管SQL前端用Vue做交互数据统一落到MySQL。无论你是正在做课程设计、毕业设计还是第一次接触前后端分离项目的开发者照着这套源码梳理一遍技术选型、表结构、接口设计和部署细节都能少吃很多亏。1. 项目整体设计与技术选型思路1.1 学科竞赛管理的真实痛点我接触过不少高校和企业的学科竞赛组织工作最典型的场景是通知靠群发、报名靠Excel、材料收齐靠网盘、评审打分靠人工汇总。一个完整的竞赛周期下来组织者一半时间在核对数据评委的分数要一个一个抄进表格最后发榜时动不动就发现漏了人、录错分。这个系统要解决的就是把“竞赛发布、报名填报、材料提交、评委评审、成绩公布”整条链路搬到线上让每个环节有状态、可追踪、能留痕。对管理员来说系统提供的是流程管控和统计能力对评委来说系统提供的是评审工作台对学生和教师来说系统提供的是统一的报名入口和结果查询。所以它不是一个简单的CRUD项目而是带角色、带流程、带权限的完整业务系统。1.2 为什么是SpringBootVueMyBatisMySQL选这套组合不是因为它最潮而是因为它最稳而且国内企业存量项目里这套架构的占比极高。SpringBoot最大的价值是自动化配置和内嵌Tomcat。以前用SSH写项目光配置文件就要折腾大半天SpringBoot把XML配置省到几乎没有一个jar直接跑开发效率提升非常明显。对新手来说SpringBoot的学习曲线也比传统Spring要平缓得多。Vue选它的原因是组件化开发对后台管理系统特别友好。表格、表单、弹窗、路由各自独立多人协作不打架数据驱动视图的模式让页面逻辑很清晰。Vue在中文社区的资料极其丰富遇到问题一搜就有解决方案这是它能快速落地的重要原因。MyBatis在这套架构里的定位是“SQL可控”。竞赛管理里的多条件筛选、多表联查、报表统计很复杂用全自动ORM容易被对象映射限制住改SQL还要看框架的底层逻辑。MyBatis把SQL握在自己手里配合动态SQL处理不确定的查询条件性能和灵活性都能兼顾这也是它多年下来依然没被淘汰的根本原因。MySQL负责最终的数据落盘。作为开源数据库里生态最成熟的选手在并发量不大但事务要求不低的内部管理系统中MySQL的性价比是最高的。InnoDB的事务引擎、utf8mb4字符集、简单易用的索引机制足以支撑竞赛管理这种规模的数据量。2. 数据库设计先把表结构想明白2.1 角色权限模型怎么设计系统里有四类人管理员、教师、学生、评委。我用的方案是一张sys_user表加上一个role字段不引入复杂的多表RBAC。为什么这么做因为学科竞赛系统的权限边界非常清晰管理员管审核和发布评委管打分学生管报名并不需要细粒度的菜单级权限控制用角色字段配合后端拦截器已经完全够用。表里的关键字段包括id、username、password、real_name、role、phone、email、status、avatar。其中password必须用BCrypt加密存储绝不能出现明文密码role用整数存储1是管理员、2是教师、3是学生、4是评委status控制账号的启用和禁用。username一定要加唯一索引防止重复注册。2.2 竞赛与报名核心表竞赛主表competition存储竞赛本身的信息title、description、type、organizer_id、apply_start、apply_end、start_time、end_time、status、cover、attachment。其中status用整数表示0是草稿、1是报名中、2是评审中、3是已结束。报名表registration关联竞赛和用户核心字段有competition_id、user_id、status、material_url、remark、create_time。这里的status字段同样重要0代表待审核、1代表通过、2代表驳回。因为审核是管理员的人工动作没有状态字段整个报名流程就无法闭环管理。很多人设计表的时候喜欢省掉状态字段后面写业务逻辑时才发现流程根本推不动这是一个很常见的教训。2.3 评审与成绩表评审是整个系统最容易写烂的部分。我设计的score表遵循一条原则一条记录代表一次打分。字段包括project_id、judge_id、score_value、innovation_score、tech_score、comment。一个项目被多个评委打分就是表里存在多条记录最终成绩在发布环节按平均分或去除最高最低后的平均分计算不落冗余字段。project表保存参赛作品信息competition_id、team_name、leader_id、members、project_name、summary、file_url。这里有一个取舍members字段我用逗号分隔的字符串或JSON字符串存储。这样做的好处是报名时一次写入查询简单代价是如果后期团队支持修改成员就会变得麻烦。如果项目要支持成员变更建议拆出team_member表这是更规范的做法。2.4 表设计里我坚持的几个习惯第一个习惯每张表都带id主键、create_time、update_time。id可以用自增也可以用雪花算法。单机项目自增就够了但要注意Long类型传到前端会有精度丢失问题我在实体类里把id配置成JSON字符串序列化避免详情查询时对不上。第二个习惯经常做条件查询的字段一定要加索引比如competition表的statusregistration表的competition_id和user_idscore表的project_id和judge_id。第三个习惯字典状态字段用tinyint不用varchar节省空间也方便在代码里定义常量比如CompetitionStatusEnum。3. SpringBootMyBatis后端是怎么搭起来的3.1 工程分层与目录后端目录结构我习惯这样组织src/main/java/com/example/contest ├── controller ├── service ├── mapper ├── entity ├── dto ├── config ├── commoncontroller层只负责接收参数和返回结果service层负责业务逻辑mapper层只做数据库操作。这个三层结构看起来很普通但它是分工最清晰的方案。刚入门的人最容易犯的错误是把SQL写在controller里或者把业务判断全部堆在service里一个方法几百行。代码不只是给机器执行的更是给三个月后的自己看的分层清晰在排查问题时能节省大量时间。3.2 统一返回结构和全局异常处理我定义了一个Result类结构是code、message、data三个字段提供success()和error()两个静态方法。前端在axios拦截器里统一判断code不等于200就直接弹出错误提示。这个方案避免了每个接口返回散装Map、前端每个页面各写一套判断逻辑的混乱局面。异常处理采用RestControllerAdvice全局捕获业务异常、参数校验异常和兜底Exception。业务代码里只需要手动抛出异常throw new BusinessException(该竞赛已截止报名)前端拿到的message就是这这句话。尤其在竞赛管理里“状态不合法”的情况非常多统一抛异常的方式比每个接口返回false再层层判断要清晰得多。3.3 登录认证与权限拦截权限这块我没有引入Shiro或Spring Security而是用JWT加一个拦截器解决。登录成功后生成token里面包含userId和role前端每次请求把token放在Authorization头里后端拦截器解析token并把用户信息放入ThreadLocal。代码量小对这个系统的权限场景已经足够。同时我定义了一个RequireRole自定义注解标注在controller方法上拦截器读取注解校验角色。比如评审打分接口只允许评委访问后台发布接口只允许管理员访问。用注解的方式比在方法里手写if判断可读性好很多。如果后续项目要求菜单级权限直接换Spring Security也不难因为业务代码已经按角色做了接口隔离。3.4 MyBatis的动态SQL和分页MyBatis的XML动态SQL是这个项目的核心。以竞赛列表的多条件查询为例按标题模糊查、按类型查、按状态查、按时间范围查用where加if标签组合有匹配条件就动态拼接没有匹配条件就查全部。分页我用的是PageHelper。在查询前调用PageHelper.startPage(pageNum, pageSize)后面紧跟的那条查询语句会被自动改写成分页SQL同时返回PageInfo包含总数、总页数等数据。这里有三个必须注意的点第一startPage必须紧跟在查询语句之前中间不能插入其他数据库操作第二一次请求只能调用一次startPage否则会抛出异常第三返回结果最好封装成PageInfo因为它会帮你算好总页数、当前页码这些前端需要的数据。select idsearchCompetition resultTypecom.example.contest.entity.Competition SELECT * FROM competition where if testtitle ! null and title ! AND title LIKE CONCAT(%, #{title}, %) /if if teststatus ! null AND status #{status} /if if testtype ! null and type ! AND type #{type} /if /where ORDER BY create_time DESC /select3.5 几条关键业务逻辑的实现细节竞赛发布流程管理员创建竞赛后status0点击发布后status1。报名时间到了系统自动开放报名。我并没有做定时任务而是在报名接口里判断当前时间与apply_start、apply_end的关系时间没到或已过期就抛异常。这样避免了引入额外的任务调度组件逻辑也更直观。报名接口的核心逻辑必须加Transactional事务校验用户已登录、校验竞赛状态是报名中、校验名额未满、插入registration记录、更新competition剩余名额。这个过程涉及多个数据库操作事务保证任何一步失败都能回滚。否则高并发下可能出现名额超卖的问题。评审打分接口评委选择一个项目输入各项分数和评语后端先校验该评委是否被分配了该竞赛的评审任务再通过score表上的唯一索引(project_id, judge_id)防止重复打分。如果已打分就更新原记录而不是新增保证一个评委对一个项目只有一条打分记录。3.6 文件上传的处理竞赛报名通常要提交材料我做了统一的上传接口/api/upload。文件保存到本地磁盘指定的目录文件名用UUID重命名避免中文名和重名问题。后端配置里把spring.servlet.multipart.max-file-size设置为10MB。这里有个注意点如果后续用Nginx部署Nginx的client_max_body_size也要同步调整否则后端改了大小限制Nginx一层还会直接拒绝大文件。4. Vue前端从工程初始化到页面落地4.1 项目初始化与目录结构我用vue-cli创建项目选上Vue Router和Vuex。目录里views放页面、components放公共组件、api放接口请求、router放路由配置、utils放axios封装和工具函数。这样分工明确初学者也能快速找到对应的代码。npm install这一步建议先配好npm镜像源不然依赖下载能卡到怀疑人生。装完依赖先跑一次npm run serve确认基础环境没问题再写业务代码。我见过不少同学一上来就写代码最后发现环境起不来前面的代码全都白写。4.2 路由与权限控制路由是前端的第一道关卡。我在路由配置里给每个页面加了meta.roles在全局前置守卫里做判断没有token跳登录页有token但角色不在meta.roles里就跳403页面。侧边栏菜单根据角色动态渲染管理员看到“审核管理”和“系统管理”评委看到“评审打分”学生看到“我的报名”。导航守卫有一个高频问题token存在localStorage但页面刷新后Vuex里的用户信息会丢失。我加了一个init动作在全局守卫中每次进入路由前从localStorage恢复用户信息到Vuex保证刷新后用户还处于登录状态。这个细节不处理用户一刷新页面就会发现被强制踢到登录页体验极差。4.3 核心页面实战拆解竞赛列表页用的是el-table加el-pagination查询条件区放了标题输入框、状态下拉框、搜索和重置按钮。请求接口时把query参数拼好传给后端后端返回rows和total前端重新渲染表格。分页组件要注意页码变化时要重新请求数据搜索条件变化时要把页码重置为1否则会出现搜索后不在第一页的尴尬情况。报名页设计成三步表单向导第一步选竞赛第二步填团队和项目信息第三步上传材料。上传用el-upload组件action指向后端的/api/upload接口on-success回调里把返回的文件地址填入表单的material_url字段。el-upload默认会把文件自动上传这里要确认上传成功后再允许提交避免用户还没传完就点了提交按钮。评审打分页是评委的核心工作台。左侧展示分配给当前评委的项目列表右侧是打分表单。分数输入用el-input-number限制min0、max100评语用textarea。提交前先做表单校验确认各项分数和非空后再调接口。这个页面交互不复杂但信息密度高字段校验一定要做全否则评委提交了空数据后台还要再校验一遍费时费力。4.4 axios封装与跨域处理axios封装写在utils/request.js里baseURL设为/api。请求拦截器里把token加到Authorization头响应拦截器统一处理code不为200的情况遇到401就删除token并跳登录页。这样每个页面调用接口时代码干净得只剩业务逻辑。本地开发时前端跑在8080端口后端跑在8888端口必然有跨域问题。我在vue.config.js里配置了devServer.proxy把/api开头的请求代理到后端地址并设置了changeOrigin: truemodule.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8888, changeOrigin: true } } } }这样做的好处是前端代码里所有请求都写成相对路径不写完整后端地址。开发环境通过vue的代理转发生产环境通过Nginx转发前端代码完全不用改。5. 数据库初始化与项目部署5.1 MySQL准备数据库我用的MySQL 8.0用5.7也完全兼容。创建库时要显式指定字符集CREATE DATABASE contest DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;utf8mb4必须用因为要兼容生僻字和emoji表情utf8在MySQL里其实不是真正的全量字符集遇到不规范字符容易报错。初始化数据至少要包含一个管理员账号密码必须是BCrypt加密后的值。这里有个很常见的坑有人直接往表里插了一条明文密码然后登录永远提示密码错误因为后端登录校验用的是BCrypt.matches()方法它要求数据库存的值必须是加密后的哈希串。5.2 SpringBoot配置文件application.yml是后端入口几个关键配置server: port: 8888 spring: datasource: url: jdbc:mysql://localhost:3306/contest?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB max-request-size: 10MB mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: trueserverTimezoneAsia/Shanghai必须写否则MySQL 8会报时区错误。map-underscore-to-camel-case必须开启否则数据库的user_name映射不到实体的userName属性查出来全是null。这两个配置是新手最容易漏的漏了之后表现形式很隐蔽不仔细看日志根本找不到原因。5.3 前端打包与Nginx部署生产环境一般前后端分开部署。后端打成jar包用nohup java -jar contest.jar log.log 21 启动并放到后台日志单独记录方便排查线上问题。前端npm run build生成dist目录部署到Nginx。Nginx的关键配置是把/api开头的请求反向代理到后端服务server { listen 80; server_name yourdomain.com; root /var/www/contest; index index.html; location /api/ { proxy_pass http://127.0.0.1:8888; 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 / { try_files $uri $uri/ /index.html; } }try_files那行配置必须要写作用是让Vue Router的History模式在刷新非首页路由时能把请求回退到index.html否则用户刷新一个二级页面会直接404。前端路由如果改成hash模式则不需要这个配置但History模式更优雅Nginx这行配置能解决问题。5.4 部署后的验证清单部署完不能直接交差我每次上线都要过一遍检查清单打开首页看能否正常加载资源登录后token是否正确写入localStorage刷新页面用户信息是否还在发起请求看Nginx是否成功代理到后端在数据库里手动改几条数据看前端页面能否正确刷新。这套流程跑一遍基本能排除90%的部署问题。6. 常见问题与排查技巧6.1 启动阶段的坑启动报错是最常遇到的情况归纳起来无非几类端口占用8080或8888被其他进程占了换端口或结束占用进程数据库连接失败检查MySQL服务是否启动、账号密码是否正确、URL里是否带了时区参数驱动类找不到MySQL 8必须用com.mysql.cj.jdbc.Driver不能沿用旧的com.mysql.jdbc.Driver。排查方法很简单只看控制台第一段异常堆栈的最顶部不要被整屏报错吓到。SpringBoot的报错信息其实很克制“Port already in use”“Access denied for user”“Unknown database”这些关键词一出现基本就知道问题在哪了。6.2 MyBatis相关的高频报错查询结果全是null先检查map-underscore-to-camel-case是否为true再检查实体类属性名和数据库列名是否对应。还有一个高频问题XML里的SQL没有生效检查XML的namespace是否完全匹配对应的mapper接口全限定名检查mapper-locations是否扫到了XML文件路径。分页插件失效是个非常经典的问题。我犯过的错误是PageHelper.startPage()调用之后在查询语句之前加了一条日志打印而那个日志方法内部触发了一次MyBatis查询结果分页参数作用到了那条查询上业务查询完全没分页。所以分页插件要求startPage后紧跟自己真实的查询语句中间任何数据库操作都不能插入。6.3 Vue项目的常见问题跨域报错分两种情况本地开发时确认vue.config.js的proxy路径配置正确生产环境重点检查Nginx的location /api/和proxy_pass是否写对尤其注意末尾斜杠这里是最容易出细节错误的地方。token失效时响应拦截器要处理401状态清除本地token并跳转登录页否则用户会停留在一个看起来正常但所有请求都在报错的页面上。6.4 文件上传的各种限制上传失败先看后端multipart.max-file-size配置默认只有1MB要调大到实际需要的值。再看Nginx的client_max_body_size它默认也是1MB必须同步调整。我遇到过好几次后端改了大小限制但生产环境上传还是失败的案例最后都定位到Nginx一层没有放开大小限制。6.5 时间与精度的细节问题竞赛时间字段前后端显示差8小时是因为MySQL连接串没有指定serverTimezoneAsia/Shanghai或者SpringBoot的jackson没有配置统一时区。Long类型的主键传到前端会精度丢失导致小数的末尾几位变成0最后弹窗详情查不到数据。解决方式是在实体类id字段上使用JsonSerialize(using ToStringSerializer.class)让Long序列化为字符串。7. 从能跑到能用几个扩展方向与个人心得7.1 权限做强引入Spring Security如果业务要求更细的权限控制比如不同管理员只能看到不同竞赛、不同评委只能看到特定分组可以在现有基础上引入Spring Security配合现有的RequireRole注解升级为基于注解的方法级权限控制。数据库层面可以增加sys_menu、sys_role_menu、sys_user_role三张表做成标准RBAC模型。改造时原有业务代码基本不用大改主要是把拦截器逻辑换成Security的过滤器链。7.2 数据可视化与报表竞赛管理天然适合做统计报表。可以加一个数据看板页面用ECharts展示各学院报名人数、各竞赛类型分布、评委打分分布图。后端接口只需要把报名表和竞赛表聚合一下返回给前端几个统计数组即可。比如按学院统计报名人数的SQL就是用GROUP BY dept_id配合COUNT(*)多花半天时间就能做出一版让管理者眼前一亮的看板。7.3 消息通知与自动提醒报名状态审核之后目前是用户自己刷新页面才能看到结果。体验更完整的方式是接入通知机制审核通过后给用户发送系统消息报名截止前给未报名学生发送提醒。轻量级做法是在审核接口里往message表插入一条记录用户登录后在首页右上角有小红点提示重一点的可以接入邮件或微信模板消息但那种方案需要额外申请服务不建议在课设或小型项目里做。7.4 我个人的几个实操习惯写了多年Java项目有几个习惯是反复踩坑之后才养成的。第一个改动数据库表结构后一定要同步更新初始化SQL脚本并标注版本。有一次我改了表结构忘了改脚本结果另一个同事重新部署启动直接报字段不存在排查了很久。第二个接口文档随手写。哪怕只是用Swagger加几个注解也能让联调时少很多沟通成本。第三个前端和后端的枚举状态值一定要保持一致最好在前后端各维护一份常量文件注释里写清楚每个数字的含义。第四个提交代码之前自己把主流程跑一遍这种自测习惯能挡住大部分低级错误。这套系统本身不复杂难的是把每个环节都想清楚表字段为什么这么设计、接口为什么返回这个结构、Nginx为什么要配try_files。把这些细节吃透之后你会发现自己不仅能做完这个项目还能讲清楚每个设计决策的理由这比单纯把代码跑起来值钱得多。
阅读完成 · 觉得有帮助?