☰
SpringBoot+Vue+MyBatis+MySQL从零搭建研究生汇报管理系统
2026/10/1 3:37:22 网站建设 项目流程

前阵子实验室负责人找到我,说组里的研究生每周汇报还是靠微信群发文档、Excel统计人数,材料散落一地,导师想看历史记录得翻聊天记录。我听完第一反应是这需求太典型了——但凡一个有二十人以上的课题组,都逃不过“汇报管理”这摊子事。于是我用 SpringBoot + Vue + MyBatis + MySQL 这套组合,花了大概两周业余时间,把“研究生知识分享组织汇报管理系统”从零到一搭了出来。今天这篇文章不聊虚的,直接把项目拆开讲:为什么这么选型、表怎么设计、前后端核心代码怎么写、实际调试踩了哪些坑,全部是能直接拿去改的需求级经验。

先说这系统到底解决什么问题。研究生知识分享组织(说白了就是课题组或者学生社团)平时有两类核心活动:一类是周期性汇报,比如组会上的文献分享、进度汇报;另一类是知识沉淀,比如某位同学整理的学习笔记、开源项目推荐、工具教程。传统做法是拉个群,接龙汇报名单,PPT发群里被聊天记录淹没,知识文档各自存各自网盘。这套系统要做的就是把“人、信息、行为”集中到一条线上:谁在什么时间做了汇报、汇报内容是什么、导师给了什么评价、哪些知识被大家访问最多,全部可查可统计。适合谁来参考?在校研究生、课题组的管理员、想练手 Java 全栈开发的初学者,还有正在做类似课程设计的同学,这篇都能给你省不少事。

1. 内容整体设计与思路拆解

1.1 先把业务流程图想明白,再动手写代码

我见过很多同学做管理系统,上来就建表,建完表就写增删改查,最后页面和需求对不上,返工好几次。这个项目的正确打开方式,是先画清楚三条业务线。

第一条线是汇报流程。完整链路是:管理员(或者导师)创建汇报计划 → 指定汇报人和汇报主题 → 系统生成待办通知 → 汇报人在截止前提交材料(文字总结、附件、PPT链接)→ 导师和其他成员在线查看,填写评语和评分 → 汇报结束后系统自动归档。这条线是系统的核心,也是“管理”二字的价值所在——导师最需要的不是看汇报内容,而是看“谁没交、谁没看、谁评了没”。

第二条线是知识分享流程。任何成员都可以发布知识条目,分类可以是文献笔记、工具推荐、踩坑记录、开源项目。发布后进入共享池,其他成员可以浏览、搜索、收藏。这里的关键点是统计浏览量和收藏数,因为有这两个指标,管理者才知道哪些知识真正被用上了,后续做资源倾斜就有依据。

第三条线是权限与数据隔离。课题组可能存在多个小组(比如信息安全组、大数据组),普通成员只能看到自己组的数据,组长能看全组,管理员能看全部。所以设计时不能把所有数据平铺在一张表里,成员表必须有 group_id 这个字段。

1.2 为什么坚持用传统单体架构,而不是微服务或前后端不分离

选题的时候有人跟我建议,现在流行微服务,不如拆成认证服务、汇报服务、知识服务。我直接否了。原因很简单:这个系统的并发量和数据量,单体架构完全扛得住,微服务只会把开发和部署成本拉高两三倍。对一个实验室内部系统来说,最合理的架构就是 SpringBoot 单体应用 + Vue 前后端分离,一个人开发、一台服务器部署、MySQL 单库解决。

前后端分离的好处是明显的。前端和后端可以独立开发互不阻塞,而且以后想接移动端,直接复用后端 API 就行。我们前端用的是 Vue 生态,后端就是纯粹的 REST API,接口返回统一格式的 JSON。部署时后端打 jar 包跑在服务器上,前端打包成静态文件扔给 Nginx 托管,再配个反向代理把 /api 开头的请求转发到后端端口就行。这套模式我跑过好几个项目,稳得一批。

2. 技术选型解析:为什么锁定 SpringBoot + Vue + MyBatis + MySQL

2.1 SpringBoot 版本的选定与理由

SpringBoot 我选的是 2.7.x。为什么不选 3.x?因为 3.x 要求 JDK 17,而且 javax 换成了 jakarta 命名空间,很多老教程的代码直接粘贴过来会报错。实验室服务器的 JDK 环境也还是 8,升级成本和风险都不划算。2.7.x 是 2.x 系列的最后稳定版本,既能用上大部分新特性,又能兼容 JDK 8,生态也最成熟。

核心依赖清单大致是这样:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.20</version> </dependency> </dependencies>

2.2 MyBatis 和 MyBatis-Plus 的取舍

这个选择我想重点说说。现在网上教程十个里有八个推荐 MyBatis-Plus,因为它带内置的 BaseMapper,单表 CRUD 都不用写 SQL。但为什么我这个项目反而用了原生 MyBatis?因为本项目里的核心查询全部是多表关联,比如“查询某次汇报的所有材料并带上成员姓名和组名”,这种场景你用 MyBatis-Plus 的 LambdaQueryWrapper 写出来极其别扭,最后还是得回到 XML 里写 join。既然逃不掉 XML,那还不如一开始就用原生 MyBatis,把 SQL 主动权牢牢握在自己手里。另外,如果你面试或者答辩,能解释清楚 MyBatis 的一二级缓存、TypeHandler 的扩展机制,比只说“我用过 Plus 很爽”有说服力得多。

MyBatis 的配置我放在 application.yml 里,有几项是必填的:

mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.lab.report.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

map-underscore-to-camel-case 必须开,否则表里的 create_time 映射不到 Java 的 createTime 字段上。log-impl 建议在开发期打开,这样每次执行的 SQL 和参数都能在控制台看到,排查问题能省一半时间。

2.3 Vue 前端:Vue 3 + Vite + Element Plus

前端技术栈我选了 Vue 3 + Vite + Element Plus。如果你还停留在 Vue 2 + Webpack,我建议直接上手 Vue 3。Vite 的启动速度是 Webpack 比不了的,实验室项目规模不大,Vite 冷启动几百毫秒,热更新也是毫秒级,开发体验完全是降维打击。Element Plus 是 Vue 3 生态最成熟的组件库,表格、表单、弹窗这些后台管理系统的常客都有现成组件,我们的核心页面基本就是靠 el-table + el-form + el-dialog 拼出来的。

前端项目初始化用 Vite 的官方模板:

npm create vite@latest report-web -- --template vue cd report-web npm install npm install element-plus axios vue-router@4 pinia

这里要提醒一句,Vue Router 装的时候一定要指定 @4,否则默认可能装到 Vue Router 3,那个是配合 Vue 2 用的,版本不对直接报错。

2.4 MySQL 版本与字符集设置

数据库我用的 MySQL 8.0。8.0 相比 5.7 的明显好处是原生支持窗口函数,比如我们要统计“每个成员的汇报次数排名”,一条 SQL 就能搞定。建库时字符集务必用 utf8mb4,因为 utf8 在 MySQL 里最多支持 3 字节,遇到 emoji 或者某些生僻汉字直接报错。排序规则用 utf8mb4_general_ci 或者 utf8mb4_unicode_ci 都行,前者性能略好,后者排序更符合 Unicode 语义。

连接字符串里有个坑,必须显式指定时区:

jdbc:mysql://localhost:3306/report_system?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false

不写 serverTimezone 的话,MySQL 8 默认时区是 UTC,你插入一条中国时间的数据,查出来发现比实际时间早了 8 个小时,新手很容易在这卡住。

3. 核心功能模块与数据库表结构设计

3.1 七张核心表,把系统地基打牢

我把整个系统拆成七张表:用户表、小组表、汇报计划表、汇报提交表、知识分享表、评论表、公告表。表之间尽量少关联、多冗余,因为内网系统数据量不大,宁可多存一个冗余字段换取查询简单,也不要搞七八层 join。

先看最核心的用户表设计:

CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `username` varchar(50) NOT NULL COMMENT '登录账号', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `real_name` varchar(50) NOT NULL COMMENT '真实姓名', `role` tinyint(4) NOT NULL DEFAULT '3' COMMENT '角色:1管理员 2组长 3成员', `group_id` bigint(20) DEFAULT NULL COMMENT '所属小组ID', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `avatar` varchar(255) DEFAULT NULL COMMENT '头像地址', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用 0禁用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

密码不能用明文,这个必须强调。Spring Security 自带的 BCryptPasswordEncoder 可以直接用,加密后的字符串长这样:$2a$10$7EqJtq98hPqEX7fNZaFWoOhi1KnQeQn2JmhONFhC2VxFfYhY6dBuS,每次加密结果都不一样,但校验方法能正确匹配。

汇报计划表和汇报提交表是核心业务表。我的设计思路是“计划与提交分离”:计划是管理员发布的框架,提交是成员填写的实体。这样在页面上就可以清晰区分“未开始、待提交、已提交、已完成”四种状态。

CREATE TABLE `report_plan` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `group_id` bigint(20) NOT NULL COMMENT '所属小组', `title` varchar(100) NOT NULL COMMENT '报告主题', `content` text COMMENT '任务描述', `report_date` date NOT NULL COMMENT '汇报日期', `deadline` datetime NOT NULL COMMENT '提交截止时间', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0未开始 1进行中 2已结束', `creator_id` bigint(20) NOT NULL COMMENT '创建人ID', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='汇报计划表'; CREATE TABLE `report_submit` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `plan_id` bigint(20) NOT NULL COMMENT '关联汇报计划ID', `user_id` bigint(20) NOT NULL COMMENT '汇报人ID', `summary` text COMMENT '汇报摘要', `file_url` varchar(255) DEFAULT NULL COMMENT '汇报材料附件路径', `submit_time` datetime DEFAULT NULL COMMENT '实际提交时间', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0未提交 1已提交 2已评价', PRIMARY KEY (`id`), UNIQUE KEY `uk_plan_user` (`plan_id`, `user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='汇报提交表';

在汇报提交表上加了联合唯一索引 (plan_id, user_id),这有几个好处:首先从数据层面保证了同一个计划下一个人只能提交一次,前端再怎么重复点击都不会插进去两条;其次这个索引正好被“查询某个计划下所有成员的提交情况”这个高频查询命中,不需要额外的回表操作。

知识分享表设计时要考虑好分类和标签,分类用 varchar 存固定枚举值(文献笔记/工具教程/踩坑记录/开源推荐),标签用逗号分隔的字符串。这样设计牺牲了一点点规范化,但换来的好处是查询简单——想找某个标签下的所有内容,直接 like 匹配就行,对于知识量在几千条之内的系统完全够用。

3.2 表关系复盘:哪些关联是必要的

梳理完表结构,我画了一下实体关系。用户表与小组表是多对一,用户与汇报计划是多对多,中间通过汇报提交表联系;用户与知识分享是一对多;用户与评论是一对多;汇报计划与评论也是一对多。

这张关系图让我在写查询时不必纠结:查“某个组所有成员的历史汇报次数”,就是 user join report_submit join report_plan,再加一个 group_id 条件;查“某个用户的全部知识贡献”,就是直接对 share_item 按 user_id 过滤。所有高频查询最多三张表 join,不会有性能瓶颈。

4. 后端核心实现:SpringBoot + MyBatis 的落地实践

4.1 项目目录结构与分层规范

后端项目的一级结构如下:

report-server/ ├── src/main/java/com/lab/report/ │ ├── controller/ # 控制层,只做参数接收和结果返回 │ ├── service/ # 业务层,写业务流程和事务控制 │ │ └── impl/ │ ├── mapper/ # MyBatis mapper接口 │ ├── entity/ # 实体类,对应数据库表 │ ├── dto/ # 数据传输对象,接收前端参数 │ ├── vo/ # 视图对象,返回前端数据 │ ├── config/ # 配置类 │ ├── common/ # 通用类:返回结果封装、异常处理 │ └── util/ # 工具类:JWT工具、文件工具

很多教程分层只写 Controller、Service、Mapper,导致实体类既用来接参数又用来返回结果,数据库某个字段不想暴露给前端都做不到。我把入参和出参分开定义,虽然多写几个类,但接口边界清晰,后续维护方便得多。比如用户注册接口,前端传的是 RegisterDTO(用户名/密码/真实姓名),后端返回的是 UserVO(用户ID/姓名/角色/所属组名),两者字段完全不同,硬用一个 User 实体去装就会很别扭。

4.2 JWT 登录鉴权:没有 Spring Security 的轻量方案

这个项目我没有引入 Spring Security,因为它的过滤器链和配置复杂度对这个体量的系统来说太重了。我用的方案是 JWT + 拦截器,总共不到 200 行代码。

登录流程是这样的:用户提交账号密码 → 后端用 BCrypt 校验密码 → 校验通过后用 JWT 生成 token → token 里只放 userId 和 role 两个关键信息 → 返回给前端 → 前端存到 localStorage,每次请求在请求头带上Authorization: Bearer token→ 后端拦截器解析 token,把 userId 塞进请求上下文。

JWT 工具类的核心代码:

public class JwtUtil { private static final String SECRET = "lab-report-system-secret-key"; private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000; // 24小时 public static String generateToken(Long userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }

拦截器里要注意的一个细节:预检请求 OPTIONS 一定要直接放行,否则前端发跨域请求时会先发一个 OPTIONS,被拦截器拦了,真实请求根本到不了 Controller。我见过不下五次因为这个原因导致的“前端死活调不通接口”问题。

4.3 MyBatis XML 与动态 SQL 的实战写法

项目里最复杂的查询是“汇报管理列表”。需求是这样的:管理员进入汇报管理页,能看到所有汇报计划,每个计划下面带上已提交人数、总人数、已评价人数。这个查询我用了动态 SQL 加子查询的方式:

<select id="selectReportPlanList" resultType="com.lab.report.vo.ReportPlanVO"> SELECT p.id, p.title, p.report_date, p.deadline, p.status, COUNT(s.id) AS total_count, SUM(CASE WHEN s.status = 1 THEN 1 ELSE 0 END) AS submitted_count, SUM(CASE WHEN s.status = 2 THEN 1 ELSE 0 END) AS evaluated_count FROM report_plan p LEFT JOIN report_submit s ON p.id = s.plan_id <where> <if test="groupId != null"> AND p.group_id = #{groupId} </if> <if test="status != null"> AND p.status = #{status} </if> </where> GROUP BY p.id, p.title, p.report_date, p.deadline, p.status ORDER BY p.report_date DESC </select>

这段 SQL 里有几个要点值得展开。首先是 LEFT JOIN 而不是 INNER JOIN,因为我们要统计“未提交”的人数,未提交的人在 report_submit 里根本没有记录,只有 LEFT JOIN 才能保留 plan 的所有行。其次是 GROUP BY 的字段必须和 SELECT 的非聚合字段严格一致,否则在 ONLY_FULL_GROUP_BY 模式下直接报错。最后,WHERE 和 if 标签的组合一定要用<where>标签而不是手动写 WHERE 加 AND,否则第一个条件不满足时 SQL 会变成WHERE AND p.status = 1,直接语法错误。动态 SQL 的 if 条件多了以后,这种错误排查起来非常头疼,用<where>标签能从根源上规避。

4.4 统一返回格式与全局异常处理

前后端分离的项目,接口一定要有统一的数据格式。我设计的最简格式是:

{ "code": 200, "message": "success", "data": { } }

code 用 200 表示成功,400 表示参数错误,401 表示未登录或登录过期,500 表示服务器异常。前端 axios 响应拦截器里判断 code,不是 200 就直接弹出错误信息。这个格式看着简单,但它把业务状态码和 HTTP 状态码解耦了——即使后端业务逻辑出错,HTTP 状态码还是 200,避免一些奇怪的网络层问题。

全局异常处理用 @RestControllerAdvice + @ExceptionHandler 完成。我觉得最有用的一个场景是:数据库唯一约束冲突时,MySQL 会抛 DuplicateKeyException,默认堆栈信息一长串,用户根本看不懂。我统一捕获后转成“该数据已存在,请勿重复提交”,体验立刻不一样:

@ExceptionHandler(DuplicateKeyException.class) public Result<Void> handleDuplicateKey(DuplicateKeyException e) { return Result.error(400, "数据重复,请检查后重新提交"); } @ExceptionHandler(Exception.class) public Result<Void> handleException(Exception e) { log.error("系统异常", e); return Result.error(500, "系统繁忙,请稍后再试"); }

4.5 文件上传:汇报材料的存储方案

汇报系统绕不开文件上传,PPT、PDF、Word 都得能传。我的方案是:后端接收 MultipartFile,按日期分目录存储到服务器本地磁盘,然后把文件访问路径存到数据库。上传接口大概长这样:

@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error(400, "上传文件不能为空"); } String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = UUID.randomUUID().toString().replace("-", "") + suffix; String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date()); String dirPath = uploadDir + "/" + datePath; File dir = new File(dirPath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dirPath + "/" + fileName)); return Result.success("/files/" + datePath + "/" + fileName); }

文件名的处理是重点,绝对不能直接用原始文件名存磁盘,一方面中文名可能产生编码问题,另一方面用户传一个../../xxx.jsp这种带路径的恶意文件名,直接拼接路径可能造成安全问题。用 UUID 重命名就完全规避了这两类问题。Nginx 里再配置一个/files/的静态资源映射,就能直接通过 URL 访问上传的附件。

5. 前端核心实现:Vue 3 功能落地细节

5.1 路由设计:动态路由还是静态路由

这个系统有三种角色,前端路由我一开始想过做动态路由——登录后根据后端返回的权限列表动态生成菜单。后来我放弃了,因为系统只有十来个页面,角色差异只是“按钮能不能点”的级别,不是“页面存不存在”的级别。用静态路由 + 路由守卫里做角色校验就足够了。

路由表大概这样:

const routes = [ { path: '/login', component: Login }, { path: '/', component: Layout, redirect: '/dashboard', children: [ { path: 'dashboard', name: 'Dashboard', component: Dashboard, meta: { title: '数据总览' } }, { path: 'report/list', name: 'ReportList', component: ReportList, meta: { title: '汇报管理' } }, { path: 'report/plan', name: 'ReportPlan', component: ReportPlan, meta: { title: '汇报计划', roles: ['admin', 'leader'] } }, { path: 'knowledge/list', name: 'KnowledgeList', component: KnowledgeList, meta: { title: '知识分享' } }, { path: 'member/list', name: 'MemberList', component: MemberList, meta: { title: '成员管理', roles: ['admin'] } } ] } ]

路由守卫里要做两件事:一是未登录的直接踢到 /login,二是访问了带 roles 限制的页面但角色不匹配的,提示无权限并重定向回首页。

5.2 axios 封装:请求拦截与响应处理

所有接口请求统一走封装好的 request 工具。拦截器里做三件事:请求头带上 token、响应 code 判断、401 时自动跳转登录页。代码不长但非常实用:

import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 15000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res.data } if (res.code === 401) { localStorage.removeItem('token') router.push('/login') ElMessage.error('登录已过期,请重新登录') return Promise.reject(new Error('unauthorized')) } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } ) export default request

baseURL 设置为/api,开发环境通过 Vite 的 proxy 把/api转发到后端端口,生产环境靠 Nginx 的同名反向代理。这样前端代码里就永远不会出现具体的后端 IP 和端口,环境迁移的时候只需要改部署配置,不用改代码。

5.3 核心页面拆解:汇报提交页和汇报总览页

汇报提交页是我花心思最多的地方。功能点包括:展示当前登录人被分配到的未完成汇报计划、提交摘要、上传附件、实时显示截止日期剩余时间。页面上有一个醒目的倒计时组件,这个不起眼的功能对提高提交率很有用——人都是拖延的,看到还剩两小时心里自然就紧张了。

汇报总览页是面向导师和管理员的,我用 Element Plus 的 el-table 做分组展示。最上面是筛选区(按小组、按状态、按日期范围),中间是统计卡片(本月汇报总数、待评价数、平均按时率),下面是表格。表格里每一行是一个汇报计划,展开行里显示该计划下所有成员的提交状态、汇报摘要、附件链接和评分。这个页面把“管理”二字体现得最充分——导师打开这个页面,整个组的运转情况一目了然。

5.4 状态管理与数据刷新策略

前端状态管理我用了 Pinia,但这个项目里它更多是辅助,主要用来存储登录用户信息和一些跨页面共享的配置项。真正的高频数据(比如汇报列表)我每次进入页面都重新从后端拉取,不做全局缓存。原因很简单:这是管理系统,数据实时性要求高于性能体验,全缓存反而会让用户看到过期数据。

还有一个细节值得说:提交成功或删除操作后,不要直接在本地把数据改了,那样容易出现展示不一致。我统一的做法是操作成功提示后,调用列表的加载函数重新拉取最新数据。多了一次请求,但保证了数据一致性,对体量小的系统完全值得。

6. 数据统计模块:让系统“活了”的加分项

6.1 管理首页的数据总览

系统做完了基础功能后,我加了一页数据总览,让导师和管理员打开系统第一眼就能看到整体运行情况。首页由三部分构成:顶部是数字卡片(成员总数、本月汇报次数、知识分享总数、待办事项数),中间是折线图(近六个月的汇报数量走势),下面是一个表格(成员的汇报排行榜)。

排行榜这个查询让我第一次感受到了 MySQL 8 窗口函数的香:

SELECT u.real_name AS name, g.name AS group_name, COUNT(rs.id) AS report_count, RANK() OVER (ORDER BY COUNT(rs.id) DESC) AS rank_no FROM user u LEFT JOIN report_submit rs ON u.id = rs.user_id LEFT JOIN `group` g ON u.group_id = g.id GROUP BY u.id, u.real_name, g.name ORDER BY report_count DESC LIMIT 10

窗口函数 RANK() 直接给出了排名,而且空口无凭,这个榜单一出来,组里谁在认真做汇报谁在划水,数据说话。我没把它当成什么厉害的东西,但实际给导师演示的时候,他对这块的兴趣明显高于其他页面。

6.2 按小组维度的横向对比

除了成员排行,我还做了一张小组维度对比表,统计每个小组的成员数、汇报总数、平均每次汇报得分、按时提交率。按时提交率这个指标是我后来加上去的,因为纯粹看汇报次数不够,得看质量。计算公式是:按时提交次数除以总应提交次数。这个数据能反映一个小组的时间管理能力和执行力,而且它不能靠一句话总结,必须从报表里算出来,这就是系统存在的意义。

7. 常见问题与排查技巧实录

7.1 MySQL 连接相关的坑

这个列表我整理了一下,基本都是实操中踩过的,每个都有对应的解决方案。

现象原因解决办法
连接报Access denied for user密码错误或账号没有远程访问权限检查密码,用GRANT ALL PRIVILEGES ON report_system.* TO 'root'@'%';授权
连接报Public Key Retrieval is not allowed8.0 默认的 caching_sha2_password 认证方式导致连接串加allowPublicKeyRetrieval=true,或改用 mysql_native_password 认证
连接报 SSL 相关错误连接串未显式禁用 SSL加useSSL=false即可
中文乱码连接串缺 characterEncoding 或表不是 utf8mb4统一改成characterEncoding=utf8mb4,建表时确认 CHARSET
报Unknown database数据库没建先执行建库语句:CREATE DATABASE report_system DEFAULT CHARACTER SET utf8mb4;

7.2 MyBatis 映射的一堆问题

MyBatis 报错里最烦人的是结果映射错误。我经历过一个案例:查出来的 userName 是 null,排查半天发现是数据库字段 user_name 和实体属性 userName 对不上,而 map-underscore-to-camel-case 没开。开了之后问题立刻解决。另外一个小坑是<if>条件判断里,字符串类型判空要同时判断!= null and != '',数字类型只要判断!= null就行,不要画蛇添足加个!= 0,否则业务上想查 status 为 0 的时候条件直接被过滤掉了。

7.3 前端联调时的跨域问题

开发环境下,前端跑在 5173 端口,后端跑在 8080 端口,跨域避免不了。我的解决方案是 Vite 的 proxy 配置:

// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, // 可选:后端接口没有 /api 前缀,就需要 rewrite rewrite: path => path.replace(/^\/api/, '') } } } })

注意看注释那句,如果你的后端接口路径本来就带/api,那 rewrite 这行去掉;如果后端路径不带/api,必须 rewrite 掉。这个方法是我实测很稳的,比后端开 @CrossOrigin 靠谱,后端只管接口逻辑,跨域交给前端代理来解决,更符合职责分离的原则。

7.4 前端打包部署后页面空白

第一次部署时,我把前端 build 出来的 dist 目录扔给 Nginx,结果打开是白屏。查了之后发现是 Vue Router 用的是 history 模式,刷新某个子路由时 Nginx 找不到对应的静态文件。解决方法是加一个 try_files 配置:

location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }

这样所有请求都先尝试匹配静态文件,匹配不到就回退到 index.html,由前端路由接管。部署完再没出过白屏。

7.5 服务器磁盘空间被上传文件占满

这个算是我事后补救的经验。项目跑了一个月后,磁盘告警,查了半天发现是/files目录下堆积了大量上传的 PPT 和 PDF。当时没有做定时清理,系统也不会自动删除计划相关的过期附件。后来我加了一个简单的定时任务,每晚三点扫描超过 60 天没被访问的文件并删除。代码不多,但省心很多:

@Component public class FileCleanTask { @Scheduled(cron = "0 0 3 * * ?") public void cleanOldFiles() { // 扫描上传目录,删除最后修改时间超过60天的文件 // 同时从数据库对应记录中移除file_url } }

这里要提醒,定时清理前最好加一个确认逻辑,比如数据库里该附件对应的汇报状态如果是“已完成”且时间很久,才允许清理,避免误删还在评审期的材料。

8. 开发管理经验与建议:少走弯路的几个原则

8.1 先做核心闭环,再做边缘功能

我的开发顺序是:登录 → 用户管理 → 汇报计划发布 → 汇报提交 → 汇报查看与评价 → 知识分享 → 统计首页。前三个功能只是骨架,汇报提交和查看构成了核心闭环,统计首页是点缀。这样做的好处是早早就有一个“能跑通全流程”的版本,中期给导师演示时他提的意见可以直接加在闭环上,而不是推翻重做。

很多同学做课设时喜欢从用户注册页面开始写,结果注册页做了三天,核心业务还没碰。正确做法是最简登录 + 一个能看列表的后台页面先跑起来,再逐步加模块。

8.2 数据库字段设计宁可多留余地

我这版项目里有个字段叫 remarks,几乎每张表都加了。最开始觉得没用,后来有一次导师要加一个“汇报延期原因”的说明,我直接复用 remarks 字段就实现了,不用改表结构。主动留出可扩展字段是一个低成本高收益的习惯。

8.3 答辩演示时的数据准备

如果你做这个系统是为了答辩或毕业设计,一定要提前准备好演示数据。我准备了二十个假成员、四个月的汇报记录、十五篇知识分享,还有一张精心挑选的排行榜数据,让第一名比第三名多一倍次数。演示时往上翻报告列表、展开查看详情、切图表页,效果很流畅。空数据库演示系统,每个页面都空空如也,再好的功能也显不出来。

8.4 代码提交与版本管理习惯

虽然是一个人写,我也坚持用 Git 管理,每次做完一个功能点就提交一次,提交信息写清楚改了什么。有次我准备加“导出Excel”功能,改坏了延时统计的接口,直接回滚上一条提交就恢复了,不用对着代码一行一行找问题。建议在项目开始第一天就git init,不要等项目写了一半再补,那时你已经说不清哪些文件是新加的。

8.5 部署上线前的环境检查清单

最后整理一份部署前的检查清单,都是我一次一次趟出来的经验:

  • MySQL 数据库字符集是否为 utf8mb4,账号权限是否够用
  • 后端 application.yml 里的数据库密码是否正确,druid 连接池配置是否符合服务器内存
  • 服务器时区是否正确,Java 进程启动时加-Duser.timezone=Asia/Shanghai
  • 前端是否已 build 打包,Nginx 是否配置好/api代理和 history 回退
  • 上传目录是否创建且有写权限,Nginx 的/files/静态映射是否生效
  • 防火墙是否放行了 80 和 8080 端口
  • 用 systemctl 配置后端 jar 开机自启,避免服务器重启后系统不可用

按这份清单走一遍,基本能保证一次部署成功。

我个人在实际操作中最大的体会是,这套系统的难点从来不在某个单独的技术点上,SpringBoot、Vue、MyBatis、MySQL 任何一个拿出来都有大量教程可以参考。真正的门槛是怎么把“研究生每周汇报”这个真实的线下场景抽象成数据模型和交互流程,以及怎么在设计时预判导师和管理员真正关心什么——他们关心的是谁没交、谁没评、整体趋势怎么样,而不是一个功能花哨的页面。以上这些设计和实现经验,是我跑完整个项目之后觉得最值得沉淀下来的部分,照着这个思路做类似的内部管理系统,你也能少绕很多弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询