news 2026/10/1 19:34:23

SpringBoot+Vue宿舍管理系统:床位状态流转与事务设计实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Vue宿舍管理系统:床位状态流转与事务设计实战

简介:基于SpringBoot和Vue开发的学生宿舍管理系统,是一套面向计算机专业毕设学生、课程设计与期末大作业场景的完整项目资料。系统围绕宿舍管理实际业务展开,覆盖学生信息、宿舍分配、报修服务与费用统计等模块,能够直观展示从需求分析、数据库设计到前后端联调的关键流程,适合深入学习SpringBoot与Vue的技术整合。压缩包共436个文件,以java源码、vue页面、sql数据库脚本和xml配置为主体,另含运行脚本、开发说明文档、部署视频与代码讲解视频,整体约8.69MB,目录结构清晰,便于按模块定位和二次开发。目前已有125人浏览学习,项目代码经过调试可稳定运行,配套视频和文档能帮助快速完成环境部署、理解核心逻辑,既适合毕业设计参考,也可作为课程项目实战和答辩查漏补缺的材料。

1. 学生宿舍管理系统用SpringBoot+Vue来写:一张床位状态图贯穿到底

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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</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?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8 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 id="listBedsWithCurrentStudent" resultType="com.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 的分页插件配置好,再配合上面的 XML:

@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }

分页插件用法则是让 Mapper 方法的第一个参数接收 Page 对象:

Page<BedVO> page = new Page<>(pageNum, pageSize); Page<BedVO> result = roomBedMapper.listBedsWithCurrentStudent(page, buildingId); return new PageResult<>(result.getTotal(), result.getRecords());

这里注意,Page 对象要作为第一个参数传入,MyBatis-Plus 才能拦截并改写 SQL 生成 COUNT 语句,同时返回的总数和记录列表都放在同一个 Page 对象里。

3.4 统计接口:入住率不是前端算的

宿舍管理看板一般要展示整栋楼的入住率、各学院入住人数。这类统计如果在 Java 里循环去查,数据量一大就会拖慢接口。最稳的方式还是数据库聚合。

<select id="getBuildingStats" resultType="com.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 vite@latest 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() })

另外,从房间列表跳床位图时用路由参数传 roomId:

router.push({ path: '/dorm/beds', query: { roomId: room.id } })

在 BedGrid 组件里读取route.query.roomId,再请求后端接口。不要直接把整个 room 对象放进 query,URL 会被 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 class="room-card"> <div class="room-header">{{ room.roomNo }} 房间</div> <div class="bed-list"> <div v-for="bed in room.beds" :key="bed.id" class="bed" :class="statusClass(bed.status)" :title="bed.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.202503010000123e+13。

原因:后端把学号定义成了 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,后端跑在8080,axios 请求接口直接报 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 --from=build /build/target/dorm-server-*.jar /app.jar ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=prod"]

前端镜像用 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 表的索引,测试数据少怎么查都正常,学生名单全量导入后床位列表接口慢到超时,后来才补上联合索引。

宿舍管理系统的难点不在代码量,而在床位状态是否始终精确。后端每个要改动状态的操作都放进事务并加锁,前端每个展示都来源于同一套状态机,系统就不会出现“数据看起来对但不知道对不对”的玄学问题。这套工程化做法不算复杂,但足以让系统经受住真实宿舍管理的日常压力。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 19:32:51

教师AI实操手册:从会用AI到用好AI的底层逻辑与核心方法

1. 从"会用AI"到"用好AI"&#xff1a;教师实操手册的底层逻辑很多老师第一次接触AI工具时&#xff0c;最典型的反应是两种&#xff1a;一种是"这东西太神奇了&#xff0c;什么都能干"&#xff0c;另一种是"试了一下&#xff0c;生成的东西没…

作者头像 李华
网站建设 2026/10/1 19:32:03

AI系统性能工程实战:从推理引擎到成本优化的全链路拆解

1. AI 系统性能工程的核心命题拆解 1.1 为什么“性能”在 AI 系统里是个完全不同的物种 传统后端系统的性能工程&#xff0c;核心指标无非是 QPS、P99 延迟、CPU 利用率、内存占用这几样&#xff0c;调优手段也相对成熟——加缓存、拆服务、异步化、连接池调参&#xff0c;一套…

作者头像 李华
网站建设 2026/10/1 19:32:03

AI系统性能工程实战:从指标拆解到全链路优化

1. AI 系统性能工程的核心命题&#xff1a;从单点优化到全链路治理 做 AI 系统性能工程这件事&#xff0c;最怕的就是把它当成单纯的“调参”或者“加机器”。我见过太多团队在模型上线之后发现延迟飙高、吞吐上不去、GPU 利用率长期趴在 30% 以下&#xff0c;第一反应就是“模…

作者头像 李华
网站建设 2026/10/1 19:32:00

DeepSeek Harness 开源工作台实战:从安装部署到技能扩展的完整指南

1. 从一句需求到看得见成果&#xff1a;这个工作台到底在解决什么 大多数人第一次接触 AI 工作台&#xff0c;脑子里浮现的画面是聊天框——你问一句&#xff0c;它答一句&#xff0c;聊完关掉&#xff0c;什么都没留下。这种模式在"随便问问"的场景下够用&#xff0…

作者头像 李华
网站建设 2026/10/1 19:30:57

基于PyTorch的对偶生成对抗网络图像去雾实战:从原理到源码解析

简介&#xff1a;这份资源是面向计算机相关专业毕业设计、课程设计及期末作业场景的PyTorch实战项目&#xff0c;核心任务是用对偶生成对抗网络完成图像去雾。项目由生成器与判别器双网络协同训练&#xff0c;配套训练、预测、参数解析、数据加载与可视化等模块&#xff0c;适合…

作者头像 李华
网站建设 2026/10/1 19:30:53

Mac配置Java环境变量全指南:从JAVA_HOME到PATH的深度解析

1. 为什么在Mac上配环境变量经常翻车——先理解Mac的路径机制很多从Windows转过来的朋友&#xff0c;第一次在Mac上配置Java环境变量&#xff0c;都会对着终端一脸茫然&#xff1a;明明照着网上的教程敲了export JAVA_HOME...&#xff0c;重启终端又失效了&#xff1b;明明已经…

作者头像 李华