
前阵子实验室负责人找到我说组里的研究生每周汇报还是靠微信群发文档、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 MySQL2.1 SpringBoot 版本的选定与理由SpringBoot 我选的是 2.7.x。为什么不选 3.x因为 3.x 要求 JDK 17而且 javax 换成了 jakarta 命名空间很多老教程的代码直接粘贴过来会报错。实验室服务器的 JDK 环境也还是 8升级成本和风险都不划算。2.7.x 是 2.x 系列的最后稳定版本既能用上大部分新特性又能兼容 JDK 8生态也最成熟。核心依赖清单大致是这样parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.20/version /dependency /dependencies2.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.StdOutImplmap-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 vitelatest report-web -- --template vue cd report-web npm install npm install element-plus axios vue-router4 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?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalse不写 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 idselectReportPlanList resultTypecom.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 testgroupId ! null AND p.group_id #{groupId} /if if teststatus ! 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 ResultVoid handleDuplicateKey(DuplicateKeyException e) { return Result.error(400, 数据重复请检查后重新提交); } ExceptionHandler(Exception.class) public ResultVoid handleException(Exception e) { log.error(系统异常, e); return Result.error(500, 系统繁忙请稍后再试); }4.5 文件上传汇报材料的存储方案汇报系统绕不开文件上传PPT、PDF、Word 都得能传。我的方案是后端接收 MultipartFile按日期分目录存储到服务器本地磁盘然后把文件访问路径存到数据库。上传接口大概长这样PostMapping(/upload) public ResultString 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 requestbaseURL 设置为/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 认证方式导致连接串加allowPublicKeyRetrievaltrue或改用 mysql_native_password 认证连接报 SSL 相关错误连接串未显式禁用 SSL加useSSLfalse即可中文乱码连接串缺 characterEncoding 或表不是 utf8mb4统一改成characterEncodingutf8mb4建表时确认 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.timezoneAsia/Shanghai前端是否已 build 打包Nginx 是否配置好/api代理和 history 回退上传目录是否创建且有写权限Nginx 的/files/静态映射是否生效防火墙是否放行了 80 和 8080 端口用 systemctl 配置后端 jar 开机自启避免服务器重启后系统不可用按这份清单走一遍基本能保证一次部署成功。我个人在实际操作中最大的体会是这套系统的难点从来不在某个单独的技术点上SpringBoot、Vue、MyBatis、MySQL 任何一个拿出来都有大量教程可以参考。真正的门槛是怎么把“研究生每周汇报”这个真实的线下场景抽象成数据模型和交互流程以及怎么在设计时预判导师和管理员真正关心什么——他们关心的是谁没交、谁没评、整体趋势怎么样而不是一个功能花哨的页面。以上这些设计和实现经验是我跑完整个项目之后觉得最值得沉淀下来的部分照着这个思路做类似的内部管理系统你也能少绕很多弯路。