
简介基于SpringBoot和Vue开发的学生宿舍管理系统是一套面向计算机专业毕设学生、课程设计与期末大作业场景的完整项目资料。系统围绕宿舍管理实际业务展开覆盖学生信息、宿舍分配、报修服务与费用统计等模块能够直观展示从需求分析、数据库设计到前后端联调的关键流程适合深入学习SpringBoot与Vue的技术整合。压缩包共436个文件以java源码、vue页面、sql数据库脚本和xml配置为主体另含运行脚本、开发说明文档、部署视频与代码讲解视频整体约8.69MB目录结构清晰便于按模块定位和二次开发。目前已有125人浏览学习项目代码经过调试可稳定运行配套视频和文档能帮助快速完成环境部署、理解核心逻辑既适合毕业设计参考也可作为课程项目实战和答辩查漏补缺的材料。1. 学生宿舍管理系统用SpringBootVue来写一张床位状态图贯穿到底Spring Boot Vue 的学生宿舍管理系统几乎是校园项目里最常见的选题。表面看是床位增删改查真正难的地方在“床位归属权”在时间线上怎么变化——入住、退宿、调宿、维修任何一个状态没锁死就会出现两个学生睡一张床、退宿后报表里还挂着人的事故。写这套系统的经验是先把床位状态流想清楚再用 Spring Boot 提供事务边界可靠的接口用 Vue 把房间和床位做成可视化交互。这篇内容适合两类人马上要交毕业设计但还没理清模块划分的学生以及在真实宿舍场景里要把系统做扎实、避免上线后数据错乱的开发同学。接下来全部围绕宿舍管理本身展开从表设计到后端事务、前端鉴权和部署坑一次讲透。2. 宿舍管理系统的表怎么建围绕“床位”把数据模型钉死宿舍管理系统的功能菜单看起来很多楼栋管理、房间管理、学生管理、报修、水电费。要我说最核心的只有一件事——床位到底谁在住。所有模块都在为这个答案服务。所以表设计从第一行 SQL 开始就围着床位展开而不是先堆功能菜单。2.1 为什么要把“占用状态”存下来而不是每次临时算很多新手设计第一版时喜欢给 student 表加一个 room_id 字段或者给 dorm_room 表加一个 current_students 字符串然后查询住宿明细时从这张表反推。问题在哪退宿历史丢了换宿数据乱了导出报表要不断拆字符串并发写入时统计结果还慢半拍。我一般会这样处理床位当前状态单独存在 bed 表里入住流水另存一张 stay_record。床位状态只回答“现在这一瞬间”历史归宿全部交给流水表。查“这间房住了几个人”直接按床位状态统计查“某学生住过哪些床位”就筛流水表。两个写入口必须在同一个事务里完成两边才不会打架。设计到位时数据会自己保持一致而不是靠代码里到处补丁。2.2 六张核心表的 DDL楼栋、房间、床位、学生、流水、报修、水电宿舍管理的完整业务需要这 7 张表building楼栋、dorm_room房间、bed床位、student学生、stay_record入住退宿流水、repair_order报修、water_electric水电表。建表脚本如下-- 宿舍楼 CREATE TABLE building ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(20) NOT NULL COMMENT 楼栋名如梅园1栋, floors TINYINT NOT NULL, manager VARCHAR(30), gender TINYINT COMMENT 1男 2女 0混合 ); -- 房间 CREATE TABLE dorm_room ( id BIGINT AUTO_INCREMENT PRIMARY KEY, building_id BIGINT NOT NULL, room_no VARCHAR(10) NOT NULL, bed_count TINYINT NOT NULL, is_air_conditioned TINYINT DEFAULT 0, UNIQUE KEY uk_building_room (building_id, room_no) ); -- 床位宿舍管理的核心实体 CREATE TABLE bed ( id BIGINT AUTO_INCREMENT PRIMARY KEY, room_id BIGINT NOT NULL, bed_no VARCHAR(5) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1空闲 2占用 3维修, UNIQUE KEY uk_room_bed (room_id, bed_no) ); -- 学生学号用字符串存 CREATE TABLE student ( student_no VARCHAR(20) PRIMARY KEY COMMENT 学号含字母或前导零时不能用数字类型, name VARCHAR(30) NOT NULL, gender TINYINT NOT NULL COMMENT 1男 2女, college VARCHAR(50) COMMENT 学院, class_name VARCHAR(50), phone VARCHAR(20) ); -- 入住/退宿流水只追加不修改 CREATE TABLE stay_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_no VARCHAR(20) NOT NULL, bed_id BIGINT NOT NULL, check_in_time DATETIME NOT NULL, check_out_time DATETIME DEFAULT NULL COMMENT 为NULL表示当前正在住, reason VARCHAR(100) COMMENT 入住/退出/调整原因, operator_no VARCHAR(20) NOT NULL COMMENT 操作人, KEY idx_bed_time (bed_id, check_in_time), KEY idx_student_time (student_no, check_in_time) ); -- 报修单 CREATE TABLE repair_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, room_id BIGINT NOT NULL, reporter_no VARCHAR(20) NOT NULL, report_type VARCHAR(20) COMMENT 卫生间/空调/床板/门窗, description VARCHAR(500), status TINYINT DEFAULT 0 COMMENT 0待处理 1维修中 2已解决 3已取消, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, handled_time DATETIME ); -- 水电按房间维度按月记一条 CREATE TABLE water_electric ( id BIGINT AUTO_INCREMENT PRIMARY KEY, room_id BIGINT NOT NULL, month VARCHAR(7) NOT NULL COMMENT 2025-04, water_usage DECIMAL(8,2), electric_usage DECIMAL(8,2), fee DECIMAL(8,2), UNIQUE KEY uk_room_month (room_id, month) );这里几个设计决定要单独说明。第一student_no 用 VARCHAR不能用 BIGINT。学号看起来是数字但很多学校学号带字母或者超过 19 位用数字类型会在 Java 的 Long 和 JavaScript 的 Number 之间连续踩坑。第二bed 表里有 status 字段同时 stay_record 里用 check_out_time IS NULL 表达当前入住两个数据源并存。这不是冗余是“状态字段保证查询快流水表保证历史可追溯”。第三water_electric 用 (room_id, month) 做唯一键月底由宿管录入或自动抄表同一房间同一月份不会出现两条账单。2.3 床位状态流转一张表看懂什么操作能做什么操作不能做床位状态不是任由代码改的必须有明确的流转规则。否则就会出现“入住状态下直接维修”“维修中又分配给学生”这类脏数据。我把规则固定成下面这张表动作原状态目标状态同时必须写入入住空闲占用stay_record 新增一条记录退宿占用空闲旧记录写入 check_out_time调宿占用空闲新床位从空闲到占用旧记录退宿时间 新记录 check_in_time报修空闲维修repair_order 创建维修完成维修空闲repair_order 状态更新注意占用状态的床不能直接报修。真实场景里学生还在住必须先调宿把原床位空出来再报修。这个规则在代码里要体现成很明确的状态校验不是靠前端按钮显隐来控制后端接口也要判断。状态流转还有一种特殊情况退宿时不直接删记录而是给 check_out_time 写一个当前时间。很多管理系统的报表需要按月份统计入住率流水表就能直接支撑“4 月退了多少人、5 月入住多少人”。只要退宿用 DELETE这条统计线就断了。3. Spring Boot 后端落地入住事务、报修状态与统计接口后端负责把上面的状态流转变成真正可靠的代码。这一章先讲工程骨架怎么搭再讲两个最核心的接口入住退宿事务和床位列表查询。3.1 工程骨架IDEA 里新建 Spring Boot 项目依赖别贪多用 IDEA 的 Spring Initializr 新建 Spring Boot 项目即可。第一次做这种系统很容易一口气勾上十多个依赖最后很多都用不上。宿舍管理系统实际只需要四类Web、数据库访问、参数校验、鉴权相关。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency为什么用 MyBatis-Plus 而不是 JPA宿舍系统里的统计报表多像“按楼栋统计入住率”“按学院统计人数”这类 SQL 用 MyBatis 手写更直白。MyBatis-Plus 在单表 CRUD 上能省大量代码复杂查询又保留了 XML 原生 SQL两全其美。Spring Boot 的自动装配原理在这里体现得很简单classpath 里放入了 MyBatis-Plus 依赖启动时 autoconfigure 会自动扫描配置并创建 SqlSessionFactory所以只要配置数据源就能直接跑。spring: datasource: url: jdbc:mysql://localhost:3306/dorm_db?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8 username: root password: dorm123 mybatis-plus: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.dorm.entity启动类上加MapperScan(com.dorm.mapper)Mapper 接口就不用逐个写Mapper了。这个配置是 MyBatis-Plus 的使用前提漏掉会在启动时报“找不到 bean”。3.2 入住和退宿服务一个事务里完成状态修改与流水写入入住接口是整套系统最容易被并发打穿的地方。两个宿管同时办理同一张空床的入住如果没有锁最终会出现两条 check_out_time 都为空的流水。先看代码怎么实现Service RequiredArgsConstructor public class StayServiceImpl implements StayService { private final BedMapper bedMapper; private final StudentMapper studentMapper; private final StayRecordMapper stayRecordMapper; Override Transactional(rollbackFor Exception.class) public void checkIn(Long bedId, String studentNo, String operatorNo) { // 行锁这一行的读操作会锁住直到事务提交 Bed bed bedMapper.selectByIdForUpdate(bedId); if (bed null) { throw new BusinessException(床位不存在); } if (!BedStatus.FREE.equals(bed.getStatus())) { throw new BusinessException(床位已被占用或维修中); } if (studentMapper.selectById(studentNo) null) { throw new BusinessException(学生不存在请先导入学生信息); } // 条件更新兜底即使行锁没生效更新也会失败 int rows bedMapper.updateStatusIfFree(bedId, BedStatus.OCCUPIED.getCode()); if (rows ! 1) { throw new BusinessException(床位刚被占用请刷新后再操作); } StayRecord record new StayRecord(); record.setStudentNo(studentNo); record.setBedId(bedId); record.setCheckInTime(LocalDateTime.now()); record.setOperatorNo(operatorNo); record.setReason(入住); stayRecordMapper.insert(record); } }对应的 Mapper 方法Select(SELECT * FROM bed WHERE id #{id} FOR UPDATE) Bed selectByIdForUpdate(Long id); Update(UPDATE bed SET status #{targetStatus} WHERE id #{id} AND status #{expectStatus}) int updateStatus(Long id, int expectStatus, int targetStatus);配合一个明确的注释这一步操作完成后事务提交时行锁才释放。也就是说第二个管理员在同一个事务里只能等到第一个管理员提交后才能读看到的一定是占用状态。而条件更新WHERE status 1属于在锁外再补一道防线因为 MySQL 的SELECT FOR UPDATE在 READ COMMITTED 隔离级别下可能只锁住主键索引记录条件更新能确保状态变化后的实际影响行数。退宿接口反过来处理Transactional(rollbackFor Exception.class) public void checkOut(Long bedId, String studentNo) { // 必须先查到当前入住记录防止退宿已经被处理过 StayRecord activeRecord stayRecordMapper.selectActiveByBedId(bedId); if (activeRecord null) { throw new BusinessException(当前床位没有入住记录); } if (!activeRecord.getStudentNo().equals(studentNo)) { throw new BusinessException(该学生不在这个床位); } int rows bedMapper.updateStatus(bedId, BedStatus.OCCUPIED.getCode(), BedStatus.FREE.getCode()); if (rows ! 1) { throw new BusinessException(床位状态异常可能已被退宿); } stayRecordMapper.updateCheckOutTime(activeRecord.getId(), LocalDateTime.now()); }这段逻辑里最容易忽略的是先查询当前流水再更新。很多实现里直接UPDATE stay_record SET check_out_time NOW() WHERE student_no #{studentNo} AND check_out_time IS NULL看起来没错但是一旦同一学生因为数据问题有两条空闲流水就会一起被更新状态就乱了。我的习惯是先查再改查出来的结果单条唯一再动手。3.3 房间床位列表LEFT JOIN 出当前学生姓名宿管端首页最常见的是“楼栋-房间-床位”三级展示每一个床位要显示当前住在那里的人。如果每次查列表都循环调数据库页面会慢到没法用。一条 SQL join 就能解决select idlistBedsWithCurrentStudent resultTypecom.dorm.vo.BedVO SELECT r.id AS roomId, r.room_no, r.bed_count, b.id AS bedId, b.bed_no, b.status, s.student_no, s.name AS studentName FROM dorm_room r JOIN bed b ON b.room_id r.id LEFT JOIN stay_record sr ON sr.bed_id b.id AND sr.check_out_time IS NULL LEFT JOIN student s ON s.student_no sr.student_no WHERE r.building_id #{buildingId} ORDER BY r.room_no, b.bed_no /select关键在 LEFT JOIN 的条件sr.check_out_time IS NULL。这条条件保证了关联到的是每个床位当前唯一有效的入住记录。如果某床空闲左边关联不出学生信息查询结果里 studentNo 为 NULL前端显示为“空闲”。注意这个条件下一个寝室如果有 4 个床位返回 4 行不是 4 乘住人数。分页用 MyBatis-Plus 的分页插件配置好再配合上面的 XMLBean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }分页插件用法则是让 Mapper 方法的第一个参数接收 Page 对象PageBedVO page new Page(pageNum, pageSize); PageBedVO result roomBedMapper.listBedsWithCurrentStudent(page, buildingId); return new PageResult(result.getTotal(), result.getRecords());这里注意Page 对象要作为第一个参数传入MyBatis-Plus 才能拦截并改写 SQL 生成 COUNT 语句同时返回的总数和记录列表都放在同一个 Page 对象里。3.4 统计接口入住率不是前端算的宿舍管理看板一般要展示整栋楼的入住率、各学院入住人数。这类统计如果在 Java 里循环去查数据量一大就会拖慢接口。最稳的方式还是数据库聚合。select idgetBuildingStats resultTypecom.dorm.vo.BuildingStatsVO SELECT r.building_id AS buildingId, COUNT(b.id) AS totalBeds, IFNULL(SUM(CASE WHEN b.status 2 THEN 1 ELSE 0 END), 0) AS occupiedBeds, IFNULL(SUM(CASE WHEN b.status 3 THEN 1 ELSE 0 END), 0) AS repairBeds FROM dorm_room r JOIN bed b ON b.room_id r.id GROUP BY r.building_id /select统计接口里我最看重的是“维修床位也要统计”。很多系统只统计占用和空闲维修中的床一旦空闲后又没人手动改回空闲数据就对不上了。把维修状态单独列出来宿管在页面上看到有红色床位没处理才可能去推进维修流程。4. Vue 前端落地Axios 拦截、动态路由与床位可视化前端把后端接口转换成宿管员和学生的操作界面。宿舍系统交互不复杂但有几个点必须处理对登录态、路由权限、床位可视化。4.1 Vue 安装及环境配置Vite 还是 Vue CLI新项目我推荐 Vue 3 Vite Element Plus。Vue 2 虽然还有存量项目但新写的系统没必要逆着生态走。Node 版本建议 18 以上脚手架命令npm create vitelatest dorm-web -- --template vue cd dorm-web npm install npm install element-plus axios vue-router pinia npm run dev这里几个依赖各自负责什么要说清楚。axios 是 HTTP 客户端负责所有接口请求vue-router 管路由跳转pinia 管理全局状态这里主要存登录用户信息element-plus 是 UI 组件库提供表格、表单、弹窗。注意 pinia 不是必选项宿舍系统状态量少用 localStorage 也能支撑但 pinia 在权限加载和用户信息全局读取上更顺手。4.2 Axios 封装登录态注入与统一错误处理每天要调十几个接口不能每个接口都写一遍 token 读取和错误弹窗。封装一个统一的 request 实例是必须的// src/utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router const service axios.create({ baseURL: /api, timeout: 8000 }) // 请求拦截器每次请求带上 token service.interceptors.request.use(config { const token localStorage.getItem(dorm_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理后端返回结构 service.interceptors.response.use( response { const res response.data if (res.code 200) return res.data if (res.code 401) { localStorage.removeItem(dorm_token) localStorage.removeItem(dorm_user) router.push(/login) return Promise.reject(new Error(登录已过期)) } ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) }, error { ElMessage.error(error.response?.data?.msg || 网络异常) return Promise.reject(error) } ) export default service这段代码把两件事固定下来。第一后端统一返回结构{ code, msg, data }响应拦截器判断 code所有接口拿到的是纯净的 data不用每个页面再判一次。第二401 代表 token 过期直接清空本地登录态并跳回登录页这个逻辑全局生效不会出现某个接口已经提示过期但页面还停在原处的尴尬。4.3 动态路由与路由参数权限不能只靠隐藏菜单宿舍管理系统有管理员、宿管、学生三种角色。如果把全部路由都静态注册普通学生只要手输 URL 就能进入管理员页面只是组件渲染后调接口时报错很狼狈。所以前端要用动态路由登录后根据角色动态注册。// src/router/index.js import { createRouter, createWebHistory } from vue-router const constantRoutes [ { path: /login, component: () import(/views/Login.vue), meta: { public: true } }, { path: /403, component: () import(/views/Forbidden.vue), meta: { public: true } } ] const asyncRoutes [ { path: /dorm, component: () import(/layouts/DormLayout.vue), meta: { roles: [admin, dorm_manager] }, children: [ { path: beds, component: () import(/views/bed/BedGrid.vue) }, { path: students, component: () import(/views/student/StudentList.vue) }, { path: repairs, component: () import(/views/repair/RepairList.vue) } ] }, { path: /my, component: () import(/layouts/StudentLayout.vue), meta: { roles: [student] }, children: [ { path: profile, component: () import(/views/student/MyDorm.vue) }, { path: repair, component: () import(/views/student/MyRepair.vue) } ] } ] export function setupDynamicRoutes(roles) { asyncRoutes.forEach(route { const needRoles route.meta?.roles || [] if (needRoles.some(r roles.includes(r))) { router.addRoute(route) } }) }路由守卫里判断未登录直接跳走router.beforeEach((to, from, next) { const token localStorage.getItem(dorm_token) if (to.meta.public) { next() return } if (!token) { next(/login) return } next() })另外从房间列表跳床位图时用路由参数传 roomIdrouter.push({ path: /dorm/beds, query: { roomId: room.id } })在 BedGrid 组件里读取route.query.roomId再请求后端接口。不要直接把整个 room 对象放进 queryURL 会被 JSON 序列化撑得又长又乱刷新页面后参数也容易丢失。4.4 床位可视化把一个房间渲染成网格宿舍管理界面上把床位做成可视化格子比表格更直观。宿管打开楼栋页面就能一眼看出哪张床空着、哪张床住着谁。组件示例script setup defineProps({ room: { type: Object, required: true } }) const statusLabel { 1: 空闲, 2: 已入住, 3: 维修 } const statusClass status { if (status 1) return bed-free if (status 2) return bed-used return bed-repair } /script template div classroom-card div classroom-header{{ room.roomNo }} 房间/div div classbed-list div v-forbed in room.beds :keybed.id classbed :classstatusClass(bed.status) :titlebed.status 2 ? bed.studentName 入住中 : statusLabel[bed.status] click$emit(bed-click, bed) {{ bed.bedNo }} /div /div /div /template这个组件只做展示点击事件抛给父组件处理入住、退宿或报修弹窗。因为后端已经返回了 studentName前端不需要再联查学生接口性能好交互也简单。床位颜色建议用绿色空闲、蓝色已入住、红色维修色弱场景下再配文字状态不要只靠颜色区分。5. 避坑宿舍管理系统最常见的翻车点与排查方法这里整理的项目运行中最常出现的 5 类问题每一条都是宿舍业务特有的坑按“现象、原因、解决”写清楚。5.1 学号位数太长前端显示变成科学计数法现象数据库中存的好好的学号20250301000123在 Vue 页面列表里变成了20250301000000或者显示成1.202503010000123e13。原因后端把学号定义成了 Long 类型JSON 序列化后传到浏览器JavaScript 的 Number 类型安全整数范围有限超过 2^53 的数直接丢精度。宿舍学号通常 12 位以上几乎必然触发。解决第一数据库表 student_no 用 VARCHAR实体类字段也用 String。第二给后端全局配置一个 Long 转 String 的序列化规则防止其他字段踩同一颗雷Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { return builder - builder .serializerByType(Long.class, ToStringSerializer.instance) .serializerByType(Long.TYPE, ToStringSerializer.instance); } }这个配置对主键 id 同样有效。前端拿到的所有长整型 id 都是字符串携带在 URL 参数里也不会丢精度。5.2 并发占床两个宿管同时办入住出现两条有效流水现象宿管 A 和宿管 B 同时点某个空床位的入住系统最终给两个不同学生都办理成功数据库中该床位同时出现两条 check_out_time 为 NULL 的流水。原因代码是“先查询空床再插入入住”的顺序执行。两个人同时查都看到状态空闲然后各自更新状态后更新的人把前一个覆盖了。这是典型的并发更新丢失。解决前面第 3 章已经写了必须做两件事。第一是SELECT ... FOR UPDATE行锁让并发请求在数据库层排队。第二是条件更新UPDATE bed SET status ? WHERE id ? AND status 空闲无论谁先执行后执行的人受影响行数为 0代码里判断 rows 为 0 就抛业务异常。两个手段缺一不可行锁保证排队条件更新保证即使有人绕过行锁也能兜住。5.3 换宿被拆成两次提交审计记录断档现象宿管在处理调宿时先给学生办退宿再去新房间办入住。结果中途断电或者有人卡在中间学生变成“无床可住”状态流水里也看不到换宿原因。原因前端把换宿拆成了两个独立接口调用后端又没提供“换宿”这个整体事务接口。解决后端直接提供一个换宿服务内部一个事务完成退宿加入住Transactional(rollbackFor Exception.class) public void changeBed(Long oldBedId, Long newBedId, String studentNo) { checkOut(oldBedId, studentNo, 换宿); // 新床位可能是另一栋楼校验性别和空调费规则 checkIn(newBedId, studentNo, 换宿); }前端操作时只调用这一个接口不单独调退宿和入住。这样中间任何一个校验失败数据库自动回滚不会出现住一半的情况。5.4 导出 Excel 中文乱码现象通过接口导出“住宿名单.xlsx”用 WPS 打开后中文字段全是乱码或者文件名变成了 urlencoded 字符串。原因第一类是响应头没设置文件名编码第二类是 Content-Type 写成了text/html浏览器把 Excel 当网页文本解析。解决用 Apache POI 或 EasyExcel 生成完文件后设置响应头时注意文件名要 URL 编码response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(UTF-8); String fileName URLEncoder.encode(住宿名单.xlsx, UTF-8); response.setHeader(Content-Disposition, attachment;filename*UTF-8 fileName);这里的写法是先用 URLEncoder 编码中文再用filename*UTF-8告诉浏览器这是一个 UTF-8 编码的文件名。很多项目直接用attachment;filename住宿名单.xlsx也能正常工作但在某些浏览器和代理服务器组合会变成乱码稳妥起见统一用这个写法。5.5 前后端联调跨域开发环境报 CORS生产环境 404现象开发环境里前端跑在http://localhost:5173后端跑在8080axios 请求接口直接报 CORS 错误。上线后把前端 build 出来放到 Nginx又变成所有接口 404。原因开发环境端口不同产生跨域生产环境静态资源由 Nginx 托管但 Nginx 没有把/api转发给后端。解决开发环境用 Vite 代理不折腾后端跨域配置// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })生产环境在 Nginx 中加一条 location 转发location /api/ { proxy_pass http://backend:8080/api/; proxy_set_header Host $host; }注意这里 Nginx 转发要保留/api前缀后端接口路径统一以/api开头。我的经验是开发阶段就用代理别在后端代码里配置CrossOrigin或全局 CORS这样生产环境可以直接由 Nginx 同源代理少一个环节少一种隐患。6. 部署与验证用 Docker Compose 把宿舍系统交付出去跑通开发环境只是第一步真正让宿舍管理投入使用需要把后端、前端、数据库打包部署。这里给出一套可以直接套用的 Docker Compose 方案。后端镜像用多阶段构建先 Maven 打包再跑 JRE# backend/Dockerfile FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests FROM eclipse-temurin:17-jre COPY --frombuild /build/target/dorm-server-*.jar /app.jar ENTRYPOINT [java, -jar, /app.jar, --spring.profiles.activeprod]前端镜像用 Nginx 托管静态文件同时把接口反向代理到后端容器# frontend/nginx.conf server { listen 80; root /usr/share/nginx/html; location /api/ { proxy_pass http://backend:8080/api/; proxy_set_header Host $host; client_max_body_size 20m; } location / { try_files $uri $uri/ /index.html; } }Compose 文件把三个服务串起来services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: dorm123 MYSQL_DATABASE: dorm_db volumes: - ./sql:/docker-entrypoint-initdb.d - mysql-data:/var/lib/mysql healthcheck: test: [CMD, mysqladmin, ping, -h, localhost] interval: 5s retries: 10 backend: build: ./backend environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/dorm_db SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: dorm123 depends_on: mysql: condition: service_healthy frontend: build: ./frontend ports: - 80:80 depends_on: - backend volumes: mysql-data:部署完成后我每次交付前必做一件事把数据库初始化脚本整个重跑一遍从零开始走完“导入学生、分配房间、办理入住、发起报修、退宿”五个动作。这比一堆接口文档更能暴露问题。以前有一次就是初始化脚本里漏了 stay_record 表的索引测试数据少怎么查都正常学生名单全量导入后床位列表接口慢到超时后来才补上联合索引。宿舍管理系统的难点不在代码量而在床位状态是否始终精确。后端每个要改动状态的操作都放进事务并加锁前端每个展示都来源于同一套状态机系统就不会出现“数据看起来对但不知道对不对”的玄学问题。这套工程化做法不算复杂但足以让系统经受住真实宿舍管理的日常压力。希望帮到你。本文还有配套的精品资源点击获取