先说说标题里这个“管理管理系统”,有没有发现多了个“管理”?我拿到这个项目的时候第一反应也是这个,不过点进源码一看,其实就是一个标准的学科竞赛管理系统。前后端分离,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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/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_name,Java 属性是驼峰命名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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;密码字段用的是加密后的密文,登录时通过工具类进行比对。这里要提醒一下,如果你拿到的源码里密码是明文存的,那建议至少改成一个简单的 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;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里统一配置serverTimezone=Asia/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 或 11,Vue2 对应 Node 14 或 16,版本差太多会导致编译失败。我的建议是严格按照项目文档里的版本号安装环境,不要盲目用最新版本。
第三件事是重置并导入数据库,建一个独立的数据库名,避免和你已有的项目共用一个库。导入成功后直接查询核心表,确认有初始数据,再启动后端,最后启动前端。这个顺序能帮你把问题范围缩小,而不是所有问题一起堆在眼前。
7.3 我个人的几个实操经验
最后说点我自己的体会。做这种全栈管理系统,最大的难点通常不是写代码,而是把“业务逻辑”转化为“代码逻辑”。比如报名状态流转,看似很简单,但你得在写代码前把规则全部列出来:谁可以报名、什么时间能报名、能不能重复报名、审核通过后能不能修改、成绩发布后能不能再调整。如果没有这些规则,代码写到最后一定会越来越乱。
另外,数据库字段的命名规范也值得养成习惯。全部用下划线命名并在注释里写清楚含义,能让你在写 SQL 时少想很多。Java 实体类用 Lombok 的@Data注解,能省掉一堆 getter/setter,代码看着清爽很多。前端每个页面给路由加上对应的菜单权限标识,后续加权限控制时不用回头翻整个项目。
这个项目整体不复杂,但胜在完整。你完全可以把它当成一个全栈开发的基础模板,在这个基础上继续往里面加自己的思路。真正把代码跑通、看懂、改出来之后,你对 SpringBoot、Vue、MyBatis 这套组合的理解一定会比看文档来得深。