学校学生管理系统,是前后端分离练手中很典型的一个题目。用 SpringBoot3 做后端、Vue.js3 做前端、MySQL 8 做数据库,做出来的东西不是单纯的“增删改查”,而是能把登录鉴权、分页查询、接口规范、跨域代理、前后端联调、部署配置这一整条链路都走一遍。对正在学 Java 全栈的人、准备做课程设计或毕业设计的人来说,这个项目非常适合作为第一个完整全栈项目。
这篇文章按实际开发顺序拆解。先说业务边界和表结构,再讲 SpringBoot3 后端怎么搭、JWT 登录怎么做,然后到 Vue3 前端页面如何对接接口,最后补上联调排错和几个加分功能。代码块里的内容偏向核心骨架,很多重复代码没有展开,但每一步的意图和判断标准会写清楚。
1. 先确认系统边界,再写表和接口
1.1 三个角色与六张核心表
做管理系统,第一个动作不是打开 IDE 写代码,而是把业务对象梳理清楚。学校学生管理系统虽然名字叫“学生管理”,实际需要管理的对象包括学生、班级、教师、课程、成绩、系统用户。
这里容易犯的错是:一开始就把功能想得太大,比如消息通知、排课、宿舍管理、考勤打卡。功能越多,表越多,改起来越痛苦。你需要的是一门课,不是一套 SAP。
从最小可用闭环出发,角色先分三种:
- 管理员:维护班级、教师、课程,管理学生账号,查看统计数据。
- 教师:查看自己授课班级的学生,录入成绩或导出成绩单。
- 学生:查看个人信息和选课成绩。
这三类角色的数据不需要建三张用户表,可以统一放到一张系统用户表sys_user,通过role字段区分。
业务数据建议拆成六张核心表:
- 系统用户表
sys_user:账号、密码、姓名、角色、状态。 - 班级表
clazz:班级名称、年级、班主任。 - 学生表
student:学号、姓名、性别、所属班级、手机号、入学日期。 - 教师表
teacher:工号、姓名、职称、所属院系。 - 课程表
course:课程名称、学分、授课教师。 - 成绩表
score:学生、课程、学期、分数。
设计时有一个关键判断:学生表要不要用物理外键关联班级表?我的建议是不要用数据库外键约束,只保留clazz_id作为逻辑外键。原因很简单,课设和真实项目都经常要调整班级数据,物理外键会限制删除和批量更新,出现问题后排查也比较麻烦。业务约束放到 Service 层做,比如删除班级前先检查是否还有学生引用,程序自己控制,比数据库硬约束更好维护。
1.2 功能模块按最小闭环划分
基础版建议只做五个模块:
- 登录认证。
- 首页统计看板。
- 学生管理,包含分页、条件查询、新增、编辑、删除。
- 班级管理,维护班级下拉数据。
- 课程与成绩管理,让老师可以添加成绩,学生可以查看成绩。
这里最核心的是登录加学生管理。如果时间紧张,先把学生管理整个闭环跑通,其他模块都是同一种写法的重复。班级管理本质上是“一个下拉框的数据源”,课程和成绩本质上是“多表联合分页查询”。不要每个模块都重新设计一套复杂的架构,统一风格即可。
还要提前定好权限粒度。基础版做到“三种角色能进入不同菜单”就够了,不一定要做按钮级别权限。如果用 SpringBoot3,快速方案是用拦截器解析 JWT,把userId和role放进请求上下文,前端根据角色控制菜单。这样后端不需要引入完整 Spring Security,学习成本降低很多。等以后要做到细粒度权限,再迁移到 Spring Security 也来得及。
2. 技术版本和环境要提前对齐
2.1 SpringBoot3 不是 SpringBoot2 的小升级
搜索这个问题时,你大概率会看到大量旧教程,但 SpringBoot3 底层已经变了。最明显的是下面三点:
- 基于 Jakarta EE,很多包名从
javax.变成了jakarta.。 - 要求 JDK17 及以上,JDK8 无法直接跑 SpringBoot3。
- 部分第三方框架必须使用适配 SpringBoot3 的新版本。
所以不要直接复制网上 SpringBoot2 项目里的配置。下载项目时优先用 Spring Initializr 选好 Java 版本和依赖,再手动引入 MyBatis-Plus 这类扩展。如果你机器上已经装好 JDK8,建议不要为了跑项目就学网上改成混乱的环境变量,而是两个 JDK 版本共存,通过JAVA_HOME切换,或者直接用 IDE 给项目指定 JDK17。
Vue3 也一样。Vue3 项目通常搭配 Vite,不再像 Vue2 时代默认走 webpack。Node 环境建议使用 18 以上,太旧的 Node 版本可能无法正常创建项目或安装新依赖。
前端安装依赖时容易卡住,建议先把 npm 镜像切到国内源,否则npm install element-plus vue-router@4 pinia axios可能要等很久,甚至报网络错误。
2.2 项目目录结构和环境验证
建议用两个单独的目录放前后端,根目录里再放数据库脚本。比如这样:
school-project/ school-server/ SpringBoot3 后端 school-web/ Vue3 前端 sql/ 初始化表和数据后端项目里按包分层:
com.example.school common 统一返回、异常处理 config 跨域、拦截器配置 controller 接口层 entity 数据库实体 mapper 数据访问层 service 业务层前端项目里按功能分目录:
school-web/src api 每个模块的请求方法 router 路由配置 stores Pinia 状态管理 views 页面组件 layout 整体布局 utils 请求封装目录结构不是随便分的。前端接口调用方法统一放到api,组件里不直接写axios.get,后面接口变了只需要改一处。后端 Controller 里不直接写 SQL 业务逻辑,只接收参数、调 Service、返回结果,否则代码一多就会变成大杂烩。
环境装好后,不要急着写代码,先跑三组命令确认:
java -version mvn -v node -v如果java -version显示的是 JDK17,Maven 能打印版本,后端环境就算准备好。MySQL 安装后要确认服务已经启动,用客户端能连上 3306 端口。不要跳过这一步,很多学生管理系统写到最后才发现连接数据库失败,根本原因是最初的 MySQL 服务没有启动或者 root 密码记错。
2.3 接口规范:先约定返回结构
前后端分离项目最容易出的问题就是接口返回格式不统一。如果登录接口返回的是{ success: true },学生列表接口返回的是{ code: 200, data: [...] },成绩接口又返回了直接数组,前端每个请求都要单独处理,非常痛苦。
所以第一步要定义统一返回类,后端所有接口都使用同一种格式:
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "操作成功"; result.data = data; return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.code = 500; result.message = message; return result; } }登录、列表、新增、编辑、删除全部统一走这套结构。业务异常通过全局@RestControllerAdvice捕获后返回Result.error(message),前端拿到非 200 的code就弹错误提示。这里不需要每个接口都写 try-catch,但全局异常类必须做,否则数据库异常会直接暴露给页面,既不美观也不安全。
接口路径建议统一带/api前缀,比如/api/auth/login、/api/student/page。这样既方便后端区分接口资源,也方便前端在 Vite 代理里统一转发。
3. 后端从建表到登录鉴权的落地顺序
3.1 MySQL 初始化和建表
数据库脚本我通常放在sql/init.sql。建表时字符集统一使用utf8mb4,因为要支持中文和特殊符号,不要用默认的latin1。
以用户表和学生表为例,核心 SQL 可以这样写:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL COMMENT 'BCrypt加密后的密码', real_name VARCHAR(50) COMMENT '真实姓名', role VARCHAR(20) NOT NULL COMMENT 'ADMIN/TEACHER/STUDENT', status TINYINT DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表'; CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', name VARCHAR(50) NOT NULL, gender TINYINT COMMENT '1男 2女', clazz_id BIGINT NOT NULL COMMENT '所属班级ID', phone VARCHAR(20), admission_date DATE COMMENT '入学日期', status TINYINT DEFAULT 1 COMMENT '1在校 0离校/删除', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表';初始密码不建议直接写明文。你可以写一个DataInitializer,在项目第一次启动时检查管理员账号是否存在,不存在就用BCryptPasswordEncoder加密后写入admin123。这样比手动生成一段固定的 BCrypt 密文放在 SQL 里更清晰,也不会出现因为密文换行或转义导致无法登录的问题。
班级表和数据记录的细节可以先不展开,但这个阶段要明白一件事:表结构决定业务边界。学生表的字段如果是clazz_name,那么改班级名称时就必须同步改学生表,这会产生数据冗余又很难维护。正确的做法是学生表只存clazz_id,查询时通过 JOIN 或 VO 转换把班级名称带出来。
3.2 后端包结构和持久层选择
这里以 MyBatis-Plus 为例。如果你学校要求必须用原生 MyBatis 或 Spring Data JPA,思路也是一样的,只是 SQL 写法和实体映射方式不同。使用 MyBatis-Plus 可以减少日常 CRUD 代码,分页和条件查询也比较直接。
SpringBoot3 项目里引入 MyBatis-Plus 时要注意,不能直接抄 SpringBoot2 使用的mybatis-plus-boot-starter。需要找适配 SpringBoot3 的 starter,具体版本以后缀为准。如果你用 Maven,依赖版本建议先查官方最新稳定版,不要用一个不确定的旧版本。
后端核心分层做三件事:
- Controller 负责接收参数和返回值。
- Service 负责业务判断。
- Mapper 负责数据库访问。
下面是一个接口的分层示意:
@RestController @RequestMapping("/api/student") public class StudentController { @Resource private StudentService studentService; @GetMapping("/page") public Result<PageResult<StudentVO>> page( @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, String keyword, Long clazzId) { return Result.success(studentService.pageStudent(pageNum, pageSize, keyword, clazzId)); } }接口层不要出现LambdaQueryWrapper或 SQL,因为 Controller 如果知道太多数据访问细节,后面改动边界就模糊了。前端需要的可能是“学生列表”,但数据库里的实体Student没有班级名称。最稳妥的方法是新建一个StudentVO,包含学生基础字段和班级名称。查询时先用分页查学生,再根据clazz_id集合一次性查出班级列表,最后组装。不要在循环里逐条查询班级,数据量一大性能容易出问题。
3.3 JWT 登录和拦截器
登录流程要做到三件事:
- 校验用户名密码。
- 校验用户状态是否启用。
- 签发 JWT 返回给前端。
密码校验推荐使用BCryptPasswordEncoder。它的特点是每次加密结果不同,但可以用 matches 方法校验原始密码和密文是否一致。数据库里永远不要保存明文密码,这是一个基本底线。
JWT 生成代码可以用 jjwt,但版本 API 变化比较大。当前比较简洁的写法是这样的:
String token = Jwts.builder() .subject(String.valueOf(user.getId())) .claim("role", user.getRole()) .claim("realName", user.getRealName()) .expiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) .signWith(getSecretKey()) .compact();这个 token 是前端后续请求的身份凭证。前端每次请求在请求头里携带:
Authorization: Bearer <token>后端写一个拦截器,拦截除了登录接口之外的/api/**请求,解析 token,把用户信息放入 ThreadLocal。这样每个接口都能通过一个UserContext拿到当前用户是谁。解析失败就返回 401,前端收到 401 后清掉本地 token,跳转回登录页。
要注意一个边界:JWT 适合做“身份认证”,但不适合做“强制下线”。如果账户被管理员停用,只要旧 token 没过期,接口仍然可能被访问到。所以在查询用户状态时不能只看 token,每次访问数据库前还需要判断一下用户是否仍存在,或者把登录状态存到 Redis。基础版里可以在拦截器解析 token 后,再去sys_user表查一次状态。这个查询很轻量,但能解决停用账号仍然有效的问题。
4. 学生管理模块的前后端完整走一遍
4.1 后端学生管理接口要做哪些验证
学生管理模块,先说后端。
主要接口看这张表:
| 接口 | 方法 | 用途 |
|---|---|---|
/api/student/page | GET | 分页查询学生 |
/api/student/{id} | GET | 根据主键查学生详情 |
/api/student | POST | 新增学生 |
/api/student/{id} | PUT | 修改学生 |
/api/student/{id} | DELETE | 删除学生 |
/api/clazz/list | GET | 返回班级下拉框选项 |
写 Service 时最容易忽略的是“业务校验要放在哪一层”。我的顺序是:
- 新增时先判断学号是否为空。
- 再按
sno查库判断学号是否已经被占用。 - 然后判断
clazzId对应的班级是否存在。 - 最后执行插入。
修改时需要额外判断:当前要修改的学生是不是存在;如果学号变了,排除自己主键后再查重。删除前还需要看成绩表中有没有这个学生的成绩记录,如果有,就不能直接硬删,否则成绩明细表里的学生信息会全部断裂。课设阶段可以用逻辑删除方案,给student表加一个deleted字段,删除操作只更新状态,查询条件带上未删除标记。这样历史数据不会丢,成绩表也能继续查询。
不要只接收前端传来的所有字段直接执行更新。比如学生修改时如果传了createTime,要不要允许修改?正常情况下createTime应该由后端自己管理,更新只允许更新业务字段。实际开发中常出现学生误传用户 ID 或角色字段,所以新增和修改时要做字段白名单。基础系统可以不用 DTO,但至少要清楚 Controller 接收的参数不应该直接等于数据库实体。
分页查询的筛选条件一般有姓名、学号、班级。后端逻辑是:
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Student::getName, keyword) .or(StringUtils.hasText(keyword), w -> w.like(Student::getSno, keyword)); wrapper.eq(clazzId != null, Student::getClazzId, clazzId); wrapper.orderByDesc(Student::getCreateTime);这段代码要注意一个地方:keyword 为空时不要拼like条件,否则查出来会是空列表。StringUtils.hasText或类似工具可以先判断参数。MyBatis-Plus 的.or()如果配合前面的.eq()使用,要注意 SQL 条件括号会不会错乱。建议把 name 和 sno 的模糊查询用嵌套 wrapper 包起来,否则可能出现clazz_id = ? AND name LIKE ? OR sno LIKE ?,这个 SQL 结果是错的。这类问题看起来像是框架 bug,实际是条件构造顺序没处理好。
前端需要回显班级名称,所以查询结果的 VO 里建议给一个clazzName字段。如果你不想写 JOIN,可以先查学生分页数据,再通过clazzId集合批量查班级,然后用 Map 组装班级名称。
4.2 Vue3 项目初始化
Vue3 前端创建项目时,我习惯用 Vite。命令通常是:
npm create vite@latest school-web -- --template vue cd school-web npm install npm install vue-router@4 pinia axios element-plus安装完成后,浏览器打开开发地址能看到一个默认页面,就说明 Vite 和 Vue3 已经跑通了。
这里需要解释一下为什么要引入 pinia。它是一个状态管理库。登录接口返回的 token、用户基本信息都需要让很多页面共享。如果每个页面都从 localStorage 读 token,代码会散落得到处都是。更好的做法是统一通过 pinia 维护,token 持久化保存到 localStorage,页面需要时从 store 读取。
不过新手最容易踩的坑是:刷新页面后 pinia 数据丢失。所以 localStorage 依然有必要。一个简单的做法是登录成功后同时写入 pinia 和 localStorage;axios 请求拦截器固定从 localStorage 读 token,因为请求是异步的,如果 store 还没有恢复就会拿不到。这一点在网上会看到很多不同写法,最稳妥的其实是 token 只从 localStorage 读,用户信息再通过接口获取。
Element Plus 可以全局引入,也可以按需引入。课设项目直接全局引入更方便,首屏体积问题可以后面再优化,项目还没跑通前不用过度追求打包体积。
4.3 学生管理页面实现
页面整体结构可以拆成四块:
- 搜索表单区。
- 功能按钮区。
- 表格区。
- 分页区。
搜索表单的例子:
<el-form :inline="true" :model="query"> <el-form-item label="关键字"> <el-input v-model="query.keyword" placeholder="姓名或学号" clearable @keyup.enter="handleSearch" /> </el-form-item> <el-form-item label="班级"> <el-select v-model="query.clazzId" placeholder="请选择班级" clearable > <el-option v-for="item in clazzList" :key="item.id" :label="item.name" :value="item.id" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="handleSearch">查询</el-button> <el-button @click="handleReset">重置</el-button> </el-form-item> </el-form>表格要绑定列表数据和分页信息:
<el-table :data="studentList" v-loading="loading" border> <el-table-column prop="sno" label="学号" width="120" /> <el-table-column prop="name" label="姓名" width="120" /> <el-table-column label="性别" width="80"> <template #default="{ row }"> <span>{{ row.gender === 1 ? '男' : '女' }}</span> </template> </el-table-column> <el-table-column prop="clazzName" label="班级" /> <el-table-column prop="admissionDate" label="入学日期" /> <el-table-column label="操作" width="180" fixed="right"> <template #default="{ row }"> <el-button link type="primary" @click="openEdit(row)">编辑</el-button> <el-button link type="danger" @click="handleDelete(row)">删除</el-button> </template> </el-table-column> </el-table>分页组件和数据加载逻辑:
<el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadData" @current-change="loadData" />const loadData = async () => { loading.value = true try { const data = await getStudentPage(query.value) studentList.value = data.records total.value = data.total } finally { loading.value = false } }这里要注意“搜索和重置”的逻辑。搜索时如果不把pageNum重置为 1,当前页已经翻到第 5 页时,搜索的结果可能只有 1 页,就会出现“当前页没有数据”。正确的做法是在handleSearch里先把query.pageNum = 1,再调用loadData。
新增和编辑表单可以共用一个弹窗。弹窗打开时先判断是新增还是编辑,如果是编辑就根据 id 调详情接口回填表单,如果是新增就把表单清空。保存成功后关闭弹窗并重新加载列表。这种逻辑很像流水账,但也是所有管理系统都逃不掉的模式。
4.4 开发环境跨域与接口代理
前端代码运行时默认地址是http://localhost:5173,后端接口默认地址是http://localhost:8080。直接让前端通过浏览器向 8080 发起请求,会发生跨域问题,浏览器会拦截响应。
最简单的方案是在 Vite 配置里做代理,让前端发出的请求还是走 5173 自己的地址,Vite 开发服务器再把请求转发到 8080。
修改根目录的vite.config.js:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })同时前端 axios 的baseURL要写成/api,不要写成http://localhost:8080/api。这样请求类似/api/student/page,能被 Vite 代理转发,开发环境和生产环境都不会被硬编码的地址卡住。
也可以在后端加一个全局跨域配置,用@CrossOrigin或WebMvcConfigurer处理。但要注意,一旦把允许的域名设置为*,带 cookie 的请求就可能出问题。开发环境优先使用 Vite 代理,不要在后端把所有跨域请求全部放开。这个习惯越早养成越好,后面部署到服务器就不需要来回改代码。
5. 联调部署与常见问题排查
5.1 数据库连不上先按这个顺序查
很多学生管理系统看起来什么都写好了,最后卡在 MySQL 无法连接。这类问题的排查顺序我认为应该是:
- 先确认 MySQL 服务是否启动。
- 再确认使用的端口是不是 3306。
- 然后确认账号密码和连接地址是否正确。
- 接着看数据库名是否存在。
- 最后看 JDBC URL 参数是否满足当前 MySQL 版本。
MySQL 8 默认使用caching_sha2_password认证插件,某些旧版本驱动会出现连接报错。如果用 JDBC 直连,通常要在 URL 后面加上:
useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true这句配置只是开发环境的表现,生产环境要重新评估 SSL 策略,不能为了图省事无脑加。网络热词里搜“mysql 安装教程”“mysql 安装配置”的人很多,但数据库安装好后,先跑一个最简单的连接测试,比后面在项目里反复排查更省时间。可以用 Navicat 或命令行先连接一次,能连通再继续写项目。
如果提示Access denied for user 'root'@'localhost',先检查密码是否正确,不要急着改权限。如果服务能启动但连不上,再看端口有没有被其他进程占用。Linux 环境下如果是用 Docker 安装 MySQL,还要确认容器端口映射和容器内部的网络地址,不能用localhost直连容器内部端口。
5.2 接口返回异常时的排查链路
接口正常时,页面一般能加载出来。一旦页面空白、数据不出现,不要一上来就改代码。我通常建议按下面顺序看:
- 打开浏览器开发者工具的 Network 面板。
- 找到对应请求,看 HTTP 状态码。
- 看接口响应体里的
code和message。 - 看浏览器控制台有没有 JavaScript 报错。
- 最后再看后端控制台有没有异常堆栈。
HTTP 404,多数是路径不一致。前端请求/api/student/page,后端映射必须完整匹配,包括/api前缀。如果后端配置了server.servlet.context-path=/school,请求路径很可能变成/school/api/student/page,而 Vite 代理里的 target 如果不带/school,同样会 404。
HTTP 405,一般是请求方法不一致。前端用了POST,后端接口却是@GetMapping,就会 405。
HTTP 401,通常是 token 缺失或 token 过期。先看请求头里有没有Authorization,再看拦截器解析逻辑是否正常。常见错误是前端 token 存到了 pinia,刷新页面后 localStorage 里没有同步,导致发出请求时没带 token。我的建议是:请求拦截器从 localStorage 读取 token,不依赖 pinia。
HTTP 200 但前端一直没数据,要看后端返回的数据结构是什么。如果后端返回的是{ code: 200, data: { records: [], total: 0 } },前端就要取response.data.records;如果后端把 Page 的records放在了最外层,前端又要换一种写法。这里出现问题的原因不是后端或前端坏了,而是两边的字段约定不一致。改造时先统一返回结构,能够避免这类问题。
如果后端返回 500,先看后端控制台异常第一行。不要只把“请稍后重试”这五个字截图给同事,第一行报错信息才有价值。常见的 500 原因包括:数据库表字段不存在、MyBatis-Plus 映射失败、空指针、SQL 语法错误。
5.3 加分项:班级统计看板和成绩导出
基础增删改查做完后,建议把系统做成“看起来真的能用”的状态。
第一个加分项是首页统计看板。最简单的统计是“班级人数分布”。后端写一个聚合接口,SQL 类似:
SELECT c.id, c.name AS clazzName, COUNT(s.id) AS studentCount FROM clazz c LEFT JOIN student s ON c.id = s.clazz_id GROUP BY c.id, c.name ORDER BY c.id前端拿到这个数组后,用 ECharts 画一个柱状图或饼图。这里的关键是区分“统计接口”和“列表接口”。统计接口不用分页,返回的是汇总数组。前端可以放三个统计卡片,分别展示学生总数、班级总数、课程总数,再做一张图表展示班级人数,系统完整度立刻会上一个台阶。
第二个加分项是学生列表导出 Excel。推荐使用 EasyExcel。后端接口可以按查询条件导出当前筛选结果,不要把所有学生全部导出来。导出需求最容易出的坑是数据量很大时,直接在 HTTP 请求线程里生成文件导致请求超时。基础课设里数据量通常不大,可以先按同步导出写,然后把导出结果的表头、列顺序和查询列表保持一致。
导出时还要注意文件名的编码问题,涉及中文文件名时,HTTP 响应头里的Content-Disposition需要做 URL 编码,否则浏览器下载下来会变成乱码。这个问题在很多管理系统里都出现过,排查思路也比较固定:先看后端日志有没有异常,再用浏览器开发者工具看响应头。
5.4 上线部署前要检查的几项配置
如果项目要部署到服务器,不要等到部署时才想配置问题。
第一是数据库账号。生产环境不要使用 root 账号,要新建一个业务账号,只授予当前数据库的增删改查权限。
第二是前端接口地址。开发时写了 Vite 代理,部署时前端静态文件由 Nginx 托管,Nginx 里要把/api反向代理到后端 Java 进程。例如:
location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }第三是日志。后端项目启动时如果是在 Linux 服务器上,不要只把日志打到控制台,最好用nohup或 systemd 把日志输出到文件。出了问题后,先看日期最近的日志文件,而不是重新启动一次项目试运气。
第四是初始化数据。管理员账号、基础班级、课程数据要能在服务器上自动初始化,否则页面打开是空的,会误以为部署失败。初始化脚本可以用 Flyway 管理,也可以用一个简单的启动执行器,但尽量保证“脚本能重复执行”,不会因为重复插入造成主键冲突。
如果这套链路能完整跑通,你已经具备了把一个全栈管理系统从零带到可交付状态的基本能力。下一步再继续加 Spring Security、Redis、异步任务、前端权限路由,都会有更清晰的结构可以挂靠,而不是边写边乱。