
先说说标题里这个“管理管理系统”有没有发现多了个“管理”我拿到这个项目的时候第一反应也是这个不过点进源码一看其实就是一个标准的学科竞赛管理系统。前后端分离SpringBoot MyBatis 做后端服务Vue 做管理界面MySQL 存数据从学生报名、教师审核、管理员发布竞赛到成绩归档一条线都串起来了。这种项目适合拿来写毕业设计、课程设计也适合想练手全栈开发的人。系统本身不复杂但胜在完整。很多网上源码要么只有后端没有前端要么数据库脚本写得让人看不懂要么跑起来直接报一堆依赖错误。这套源码我在本地完整跑了一遍又把常见启动问题整理了一遍所以这篇文章不只是讲“有哪些功能”而会把设计思路、数据库表结构、核心接口实现、前后端联调细节和那些启动时踩过的坑都拆开给你看。无论你是打算直接复用还是想二次开发都可以少走不少弯路。1. 项目定位与功能设计思路1.1 学科竞赛管理到底在管什么学科竞赛管理并不是简单的“发一个报名表收一个 Excel”。实际业务里至少包含这样几个环节管理员或老师发布竞赛通知、设置报名时间、维护参赛队伍、记录作品和材料、安排评审、录入成绩、最后还要做数据统计。如果一个学校每年有几十个竞赛项目参赛学生上千人靠微信群和表格管理很快就会乱。这个系统的核心业务闭环就是发布竞赛、学生报名、教师审核、管理员管理、成绩归档。每个环节都有对应的状态字段比如竞赛有“未开始、报名中、评审中、已结束”四个状态报名记录有“待审核、已通过、已驳回”三个状态。这样设计的好处是所有业务操作都可以通过状态流转来约束不会出现学生报完名但管理员完全不知道的情况。从源码里看系统的用户模型分成三类学生、教师、管理员。学生登录后能看见正在报名中的竞赛可以报名并上传相关材料教师可以审核报名、录入成绩管理员负责维护用户、发布竞赛、管理所有数据。权限在接口层面就做了隔离不是单纯前端隐藏按钮这一点对真实项目来说非常重要。1.2 三类角色的业务闭环把三类角色串在一起看整个业务流程就非常清晰了。管理员创建一个竞赛填写竞赛名称、类型、面向专业、报名开始时间和结束时间、竞赛说明然后系统状态自动进入“报名中”。学生看到竞赛列表后选择自己参赛的赛道或组别填写队伍信息、成员信息上传报名附件生成一条报名记录。教师端看到待审核的报名记录后可以逐条通过或驳回。驳回时必须填写原因学生端会看到明确反馈。报名截止后管理员把竞赛状态改成“评审中”教师开始录入各队伍的评分和排名。成绩录入完成管理员发布成绩学生端就能查到自己的获奖等级。这个闭环看着简单但如果没有状态机的概念很容易做成“所有接口都能调数据越改越乱”。源码里没有引入复杂的工作流引擎而是在每个 Service 方法里去校验当前状态是否允许执行某一步操作。我认为这种设计在中小型系统里反而最合适简单直接排查问题也容易。1.3 为什么是 SpringBoot Vue 这套组合选 SpringBoot Vue 不是因为它新而是因为它稳。SpringBoot 帮我们省去了 SpringMVC 那套繁琐的 XML 配置内嵌 Tomcat打包成 jar 就能跑。MyBatis 对 SQL 的控制力强尤其是这种以 CRUD 为主的管理系统写 SQL 反而比 JPA 自动生成更直观也更容易调优。Vue 负责前端管理界面配合 Element UI 这种组件库做表格、表单、弹窗、分页的效率很高。前后端分离开发前端通过 Axios 调后端的 RESTful 接口。这种方式在课程设计和毕业设计里最常用因为面试时也容易讲清楚前端怎么发起请求后端怎么接收参数MyBatis 怎么映射 SQL数据库表怎么设计。其实你要问我为什么不用更火的 Redis、RabbitMQ、微服务那套我的回答是没必要。学科竞赛管理系统核心业务是事务型的 CRUD并发量有限用单机 MySQL 完全够。硬上一堆中间件只会增加部署难度和代码复杂度。项目选型的第一原则永远是匹配业务规模这一点在做完整源码时一定要想明白。2. 从零开始的技术选型与项目结构拆解2.1 后端分层Controller、Service、Mapper 的职责划分我拿到源码之后第一件事就是看包结构。后端模块用的是标准的四层结构Controller 接收请求、Service 处理业务逻辑、Mapper 负责数据库操作、Entity 对应数据库表。没有过度设计每个类的命名也基本能看出用途。一个典型的 UserController 代码如下RestController RequestMapping(/api/user) public class UserController { Autowired private UserService userService; GetMapping(/info) public Result getUserInfo(RequestParam Integer userId) { return Result.success(userService.getUserById(userId)); } PostMapping(/update) public Result updateUser(RequestBody User user) { return Result.success(userService.updateUser(user)); } }Controller 层很薄只做参数接收和结果包装。真正复杂的逻辑全部在 Service 层比如报名时判断竞赛状态、判断是否重复报名、判断报名时间是否截止都是在 Service 里完成的。Mapper 层只写接口和 SQL 绑定没有业务逻辑。这种分层的好处是当你需要把系统从单体改造拆分成接口服务时Service 层可以直接复用Controller 层只需要换成对应的路由注解。坏处是简单的接口也会多写几个类但考虑到代码可维护性这个代价完全值得。2.2 前端管理端Vue2 Element UI 和 Vue3 怎么选源码里前端使用的是 Vue2 Element UI而不是 Vue3 Element Plus很多人会问是不是版本太老。我的看法是在毕业设计和课程设计场景里Vue2 的成熟度最高网上文档最多遇到的坑基本都能搜到解决方案尤其是 Element UI 的表格和表单组件用起来非常顺手。Vue3 当然是趋势但如果只是做管理后台Vue2 完全够用。而且很多高校的实验环境、课程代码模板、往年学长留下的资料都是基于 Vue2 的接手成本低。如果你实在想换 Vue3最大的工作量其实是组件库替换业务代码的改动并不大因为 Vue2 和 Vue3 的核心语法差异在简单页面中并不明显。前端代码的结构也很清晰src/api下放接口请求封装src/router放路由配置src/views放页面组件src/store管理用户登录状态。登录后根据用户角色动态生成菜单学生看到的菜单和管理员看到的是不同的。2.3 完整源码的目录拆解与关键配置文件拿到完整源码后最核心的除了代码还有两个文件一个是数据库脚本.sql一个是后端配置文件application.yml。我当时第一次启动时遇到的最大问题就是数据库账号密码没改导致启动直接报连接失败。application.yml中关键配置如下server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/competition?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 50MB max-request-size: 50MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case这个配置很容易被忽略。数据库字段是下划线命名user_nameJava 属性是驼峰命名userName如果没有开启这个配置MyBatis 查询结果映射就会出问题导致前端拿不到用户名。3. 数据库设计与核心表结构3.1 用户、角色、权限三张核心表数据库是这套系统最容易理解的部分同时也是最容易出问题的地方。源码里的脚本一共十几张表核心的几张我来逐个拆解。用户表sys_user存了基本的登录账号和用户信息CREATE TABLE sys_user ( id int NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(255) NOT NULL, real_name varchar(50) DEFAULT NULL, role_id int DEFAULT NULL, phone varchar(20) DEFAULT NULL, email varchar(100) DEFAULT NULL, status int DEFAULT 1, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码字段用的是加密后的密文登录时通过工具类进行比对。这里要提醒一下如果你拿到的源码里密码是明文存的那建议至少改成一个简单的 MD5 加盐或者 BCrypt 加密不然答辩或者上线都不好交代。角色表sys_role和权限表sys_menu属于基础数据目前系统是直接在代码里硬编码了权限控制。如果你要做更灵活的系统可以把角色和菜单的关联表sys_role_menu也利用起来后端通过用户的角色 ID 查询对应菜单再生成前端路由。3.2 竞赛业务表竞赛信息、报名记录、评审成绩竞赛主表competition是系统的业务核心字段包括竞赛名称、竞赛类型、报名开始时间、报名结束时间、竞赛开始时间、竞赛状态、赛程说明、创建人等。这里有一个字段我特别提一下registration_deadline报名截止时间前端在提交报名申请时会进行一次校验后端 Service 层再次校验避免绕过前端直接调用接口。报名表signup记录了谁在什么时间参加了哪个竞赛CREATE TABLE signup ( id int NOT NULL AUTO_INCREMENT, competition_id int NOT NULL, user_id int NOT NULL, team_name varchar(100) DEFAULT NULL, member_names varchar(255) DEFAULT NULL, attachment_url varchar(255) DEFAULT NULL, status tinyint DEFAULT 0, audit_comment varchar(255) DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_competition_id (competition_id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;status字段用0表示待审核1表示通过2表示驳回。很多初学者喜欢把状态字段设计成字符串比如pending、approved实际上用tinyint加注释会更节省空间查询也更快。当然前提是写清楚注释不然过一个月你根本不知道2是什么意思。成绩表score则是记录每个队伍的评审结果评委用户 ID、竞赛 ID、报名 ID、得分、排名、获奖等级、评审意见。评审结果可能由多个评委打分所以这里有一条成绩记录对应一个评委、一个队伍的建模思路。如果要做平均分可以在 Service 层计算不需要为每一种统计都建一张表。3.3 设计时的外键、索引与冗余字段取舍看完整份数据库脚本我发现设计者刻意没有使用外键约束。这不是缺陷反而很常见。外键能保证数据一致性但在实际业务里会带来两个问题删除数据时容易被外键限制卡住高并发写入时有额外的约束检查开销。中小型系统里用“逻辑外键”就够了也就是只在 Java 代码里维护关联关系数据库层面不设约束。但索引不能省。凡是经常作为查询条件、关联条件的字段比如competition_id、user_id、status都应该建索引。这个小细节在数据量少的时候看不出来一旦表里有个几万条记录全表扫描就会明显拖慢接口响应。冗余字段也要适当使用。比如报名表里的team_name本可以通过关联查询得到但把它冗余在报名表里后面打印报名表、导出 Excel 时就不需要反复 JOIN代码会简洁很多。一切设计都是为业务服务的不能为了“规范”而牺牲实际开发效率。4. 后端核心功能与接口实现4.1 登录认证与请求拦截登录功能看起来简单但它是所有接口的入口做不好整个系统都别想正常用。这个系统的登录流程是前端把用户名和密码提交到/api/login后端校验账号、密码、用户状态成功后生成一个 token 返回给前端。我看到源码中用的是拦截器加 token 的方式。第一次登录成功后后续请求在 Header 里带上 token拦截器统一从 Redis 或本地缓存中获取用户信息。如果源码用的是本地 Map 存储 token那要注意重启后用户登录状态会丢失这在实际部署时可以换成 Redis。拦截器实现大致如下public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(token); if (token null || !TokenStore.isValid(token)) { response.setStatus(401); return false; } return true; } }这里有个特别容易踩的坑前后端分离时如果前端请求没带 Header、或者设置了自定义 Header 但没在拦截器里放行 OPTIONS 预检请求就会出现“明明接口能访问却一直报跨域”的情况。后面我会专门讲这个问题。4.2 竞赛报名与状态流转的 Service 实现报名业务是整个系统里逻辑最复杂的部分至少包含三个校验竞赛是否存在、报名时间是否在有效期内、当前用户是否已经报名过。这些校验如果放在 Controller 层代码会膨胀得很厉害放在 Service 层则能保证无论谁调用这个接口业务规则都不会被绕过。简化后的报名 Service 代码public boolean signUp(SignupDTO dto) { Competition competition competitionMapper.selectById(dto.getCompetitionId()); if (competition null) { throw new BusinessException(竞赛不存在); } Date now new Date(); if (now.before(competition.getStartTime()) || now.after(competition.getEndTime())) { throw new BusinessException(不在报名时间内); } int count signupMapper.countByUserIdAndCompetitionId(dto.getUserId(), dto.getCompetitionId()); if (count 0) { throw new BusinessException(请勿重复报名); } Signup signup new Signup(); BeanUtils.copyProperties(dto, signup); signup.setStatus(0); return signupMapper.insert(signup) 0; }这段代码里有几个点值得学习。第一业务异常用自定义的BusinessException而不是返回false让上层猜原因。第二状态设置为0也就是待审核后续教师审核时只接受status 0的报名记录。第三时间判断用的是数据库时间和服务器时间而不是前端传过来的时间避免用户改本地时间绕过限制。我在实际测试中发现如果跨天提交报名时服务器时区不同可能出现截止时间判断错误。解决方法是application.yml里统一配置serverTimezoneAsia/Shanghai同时前端也用同一时区的时间戳。时区问题在联调时不容易发现上线前一定要测一次“过了截止时间后还能不能报名”。4.3 统计报表与数据导出的处理思路管理后台最常用的功能除了 CRUD就是统计报表。源码里提供了一个竞赛报名人数统计接口返回的是按竞赛类型分组的数量。这种统计 SQL 里一个GROUP BY就能解决SELECT c.type, COUNT(s.id) AS signup_count FROM competition c LEFT JOIN signup s ON c.id s.competition_id GROUP BY c.type;用LEFT JOIN而不是INNER JOIN是为了把报名人数为 0 的竞赛也查出来。统计报表的业务重点不是 SQL 多复杂而是别把逻辑写乱。数据导出我建议用 Apache POI 或者 EasyExcel。EasyExcel 对内存占用控制得更好几万条数据导出也不会 OOM。如果你在源码里看到的是用 Apache POI 一行一行写 Excel那也够用但要留意大数据量时的内存问题。导出功能本质上是“查询数据 写入文件”难点在于列头固定、字段映射、时间格式转换这些用 EasyExcel 的注解一行搞定。5. 前端页面与交互实现5.1 动态菜单与登录状态管理前端入口是登录页登录成功之后把用户信息和角色信息存到sessionStorage或Pinia/Vuex中。路由守卫判断是否登录未登录一律跳转到登录页。动态菜单的管理方式是前端根据用户角色决定渲染哪些菜单项。管理员能看到“用户管理”“竞赛管理”“成绩管理”等学生只能看到“竞赛报名”“我的报名”“我的成绩”。这样的设计比把所有路由都写在配置里更合理用户不会看到不属于自己角色的页面入口。但是要注意前端菜单隐藏不等于后端接口安全。真正的权限控制一定在后端接口层前端只是提升用户体验。我不止一次看到有人觉得“菜单里没有这个按钮所以功能不会被人调用”等到系统上线被越权访问时才发现问题。5.2 报名表单、校验和文件上传竞赛报名页是学生使用频率最高的页面。表单字段包括队伍名称、队长联系方式、队员姓名学号、上传作品附件、备注说明等。Element UI 的el-form自带校验规则可以在rules里配置必填项、手机号格式、文件大小。文件上传我提醒一句不要直接把文件存到数据库表里应该上传到服务器本地磁盘或者对象存储数据库里只保存访问路径。源码里在application.yml中配置了multipart.max-file-size说明设计者已经考虑到了文件大小限制。如果把上传文件直接转成 Base64 塞进 MySQL表会变得非常大而且查询速度直线下降。上传接口的返回应该带上完整可访问的 URL因为 Vue 前端展示附件下载链接时需要一个真实能访问的地址。如果上传到本地磁盘需要配置静态资源映射把某个磁盘路径映射到/files/**这样的 URL。5.3 管理后台的表格操作与状态联动管理员端最核心的页面是竞赛管理页。列表用el-table渲染每一行后面跟着“编辑、删除、查看报名、发布成绩”等操作按钮。这里最需要注意的就是按钮的显示逻辑必须依赖当前行的状态。比如竞赛状态为“报名中”时应该显示“截止报名”按钮而不应该显示“发布成绩”按钮。状态为“评审中”时应该显示“录入成绩”和“发布成绩”按钮。如果在表格里把所有按钮都渲染出来只通过点击事件去判断用户非常容易误操作而且代码也不优雅。我建议使用计算属性或者方法来判断按钮可见性例如canPublishScore(row) { return row.status REVIEWING; }这样表格渲染时代码清晰后续要调整按钮策略只需要改一个方法。实际开发中我发现状态联动最复杂的地方是操作之后前端要重新刷新当前页数据否则会出现“按钮点了没反应”的假象。6. 前后端联调、测试与部署上线6.1 接口联调的环境准备前后端分离项目在本地联调时第一步是确认后端接口能通过 Postman 调用成功第二步才是前端对接。如果顺序反了前端报错时你会分不清是前端代码问题、后端接口问题、还是跨域问题。本地联调常见的做法是给 Vue 配置开发服务器代理。在vue.config.js里这样配置module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } };这样前端请求/api/xxx时会自动代理到后端地址浏览器看到的还是同源请求跨域问题迎刃而解。如果后端直接开启了 CORS那也可以不配代理但我更推荐开发环境用代理生产环境再让 Nginx 统一转发。6.2 常见的坑与排查技巧我在启动这套源码时遇到的最典型问题有三个基本上也是 SpringBoot Vue 项目的通病。第一个是数据库版本兼容问题。MySQL 8.x 和 MySQL 5.7 的驱动类不一样MySQL 8 需要用com.mysql.cj.jdbc.Driver并且连接串里必须加serverTimezone参数否则启动报时区错误。如果源码里写的是com.mysql.jdbc.Driver在 MySQL 8 下会直接警告甚至报错。第二个是前端启动后页面空白。这个大概率是因为 Node 版本太高和旧版的 webpack 不兼容。我可以告诉你一个简单粗暴的解法把 Node 切换到 14 或 16 版本再删掉node_modules重新npm install很多 Vue2 老旧项目都能救回来。我的经验是不要一上来就换 Vue 版本那样改动量太大。第三个是后端端口被占用。启动 SpringBoot 报Port 8080 was already in use时很多人第一反应是改端口但修改之后前端代理地址也要同步修改容易漏掉。更好的办法是先找到占用进程处理掉再启动。如果实在要改就把application.yml和前端代理的 target 一起改。6.3 打包部署与服务器环境配置本地跑通和真正部署上线是两码事。前端打包执行npm run build生成dist目录后端打包执行mvn clean package生成jar文件。比较省事的方式是直接把dist里的静态文件放到 Nginx 的html目录然后 Nginx 配置反向代理把/api开头的请求转发到后端服务。一个最简单的 Nginx 配置片段server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html;这行非常关键。Vue 是单页应用刷新非首页路由时如果缺少这行配置Nginx 会返回 404。我敢说一半的前端部署问题都出在这里。后端部署时建议先跑java -jar xxx.jar验证一下能不能启动再通过nohup或者 systemd 常驻运行。还需要确认 MySQL 和 jar 包在同一台机器或内网互通不然数据库连接失败让你摸不着头脑。7. 完整源码的二次开发建议与心得7.1 项目扩展方向这个系统的定位是中小型学科竞赛管理如果你想扩充功能我建议首先考虑这几个方向。第一增加消息通知模块。现在系统中的驳回原因虽然会回显但如果用户没主动刷新页面可能意识不到审核结果。引入站内信或者邮件提醒体验会有质的提升。第二增加数据可视化面板。管理员首页可以放一个报名趋势折线图、竞赛类型饼图、学院参与人数柱状图用 ECharts 结合现有统计接口就能实现性价比很高。第三增加导出专家评审表功能。教师评审环节可以按单个竞赛导出所有报名队伍的汇总表方便线下评审使用。这几个方向都不会破坏原有数据表结构属于增量开发。给课程设计或毕设做亮点时选其中一个方向做到完整比做一堆半成品更有说服力。7.2 接手这套代码前建议先做的事如果你也是从网上下载的源码第一件事不是急着跑起来而是先把 README 和数据库脚本看一遍。很多问题比如表名不一致、字段对不上、前后端接口路径不匹配都是因为下载的版本不对。第二件事是检查 Java 和前端依赖版本。SpringBoot 2.x 对应 Java 8 或 11Vue2 对应 Node 14 或 16版本差太多会导致编译失败。我的建议是严格按照项目文档里的版本号安装环境不要盲目用最新版本。第三件事是重置并导入数据库建一个独立的数据库名避免和你已有的项目共用一个库。导入成功后直接查询核心表确认有初始数据再启动后端最后启动前端。这个顺序能帮你把问题范围缩小而不是所有问题一起堆在眼前。7.3 我个人的几个实操经验最后说点我自己的体会。做这种全栈管理系统最大的难点通常不是写代码而是把“业务逻辑”转化为“代码逻辑”。比如报名状态流转看似很简单但你得在写代码前把规则全部列出来谁可以报名、什么时间能报名、能不能重复报名、审核通过后能不能修改、成绩发布后能不能再调整。如果没有这些规则代码写到最后一定会越来越乱。另外数据库字段的命名规范也值得养成习惯。全部用下划线命名并在注释里写清楚含义能让你在写 SQL 时少想很多。Java 实体类用 Lombok 的Data注解能省掉一堆 getter/setter代码看着清爽很多。前端每个页面给路由加上对应的菜单权限标识后续加权限控制时不用回头翻整个项目。这个项目整体不复杂但胜在完整。你完全可以把它当成一个全栈开发的基础模板在这个基础上继续往里面加自己的思路。真正把代码跑通、看懂、改出来之后你对 SpringBoot、Vue、MyBatis 这套组合的理解一定会比看文档来得深。