高校宿舍管理是学生工作的重要组成部分。传统管理方式多依赖纸质表格或 Excel 记录,存在以下痛点:
针对上述问题,本课题设计并实现了一个基于 SSM(Spring + Spring MVC + MyBatis)框架的宿舍管理系统,提供线上化的宿舍资源管理和学生自助服务。
| 模块 | 功能说明 |
|---|---|
| 登录认证 | 学生和管理员双角色登录,session 会话管理 |
| 宿舍楼管理 | 管理员对宿舍楼进行增删改查操作 |
| 房间管理 | 管理员按楼栋管理房间,含分页查询 |
| 床位管理 | 管理员按房间管理床位状态 |
| 用户管理 | 管理员管理学生账户,含头像上传 |
| 分配管理 | 管理员查看和调整学生住宿分配记录 |
| 学生选宿 | 学生查看当前住宿,自主选择空闲床位 |
| 类别 | 选型 | 版本 |
|---|---|---|
| 操作系统 | Windows / Linux | — |
| JDK | Java | 8 |
| Web 服务器 | Apache Tomcat | 9.x |
| 数据库 | MySQL | 8.0.25 |
| IDE | Eclipse / IntelliJ IDEA | — |
| 构建工具 | Maven | 3.x |
| Spring Framework | Spring + Spring MVC | 5.3.31 |
| ORM 框架 | MyBatis | 3.5.6 |
| 视图技术 | JSP + JSTL | 2.3+ |
| 数据库连接池 | Druid | 1.2.8 |
| 分页插件 | PageHelper | 5.x |
| 日志 | Logback | 1.2.11 |
| 前端 | Bootstrap 4 + jQuery | — |
角色一:管理员(Admin)
角色二:学生(Student)
系统采用 SSM 经典三层架构 + MVC 设计模式:
graph TD
subgraph 视图层 Presentation
JSP[JSP 页面<br/>Bootstrap + jQuery]
end
subgraph 控制层 Controller
C[Controller<br/>接收请求 / 调用服务 / 返回视图]
I[Interceptor<br/>登录校验 + 权限控制]
end
subgraph 业务层 Service
S[Service Interface]
SI[ServiceImpl<br/>@Transactional 事务管理]
end
subgraph 持久层 Persistence
M[Mapper Interface]
MX[MyBatis XML 映射文件]
P[PageHelper 分页插件]
end
subgraph 数据层
DB[(MySQL Database<br/>dormitory_manage)]
end
JSP -- HTTP 请求 --> C
C -- 调用 --> S
S -- 实现 --> SI
SI -- 调用 --> M
M -- 引用 --> MX
MX -- SQL --> DB
C -- 经过 --> I
请求处理流程:
DispatcherServlet 拦截Interceptor 链(登录检查 → 权限检查)Controller 接收请求参数,调用 Service 层Service 层处理业务逻辑(事务控制),调用 MapperMapper 通过 MyBatis XML 执行 SQL,操作数据库Controller 将数据存入 Model,转发到 JSP 视图erDiagram
user ||--o{ assignment : "拥有分配记录"
dormitory_building ||--o{ room : "包含房间"
room ||--o{ bed : "包含床位"
bed ||--o{ assignment : "被分配"
user {
bigint id PK
varchar username UK
varchar password
varchar name
tinyint gender
varchar phone
tinyint role
varchar student_id
varchar avatar
datetime created_at
datetime updated_at
}
dormitory_building {
bigint id PK
varchar name
varchar code
tinyint gender
int floor_count
varchar description
datetime created_at
datetime updated_at
}
room {
bigint id PK
bigint building_id FK
varchar room_number
int floor
int capacity
tinyint gender
datetime created_at
datetime updated_at
}
bed {
bigint id PK
bigint room_id FK
varchar bed_number
tinyint status
datetime created_at
datetime updated_at
}
assignment {
bigint id PK
bigint user_id FK
bigint bed_id FK
date assign_date
date end_date
tinyint status
datetime created_at
datetime updated_at
}
表1:user(用户表)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | BIGINT | PK AUTO_INCREMENT | 用户ID |
| username | VARCHAR(50) | UNIQUE NOT NULL | 登录用户名 |
| password | VARCHAR(255) | NOT NULL | 登录密码(MD5加密) |
| name | VARCHAR(50) | NOT NULL | 真实姓名 |
| gender | TINYINT | DEFAULT 0 | 0-女,1-男 |
| phone | VARCHAR(20) | 联系电话 | |
| role | TINYINT | NOT NULL DEFAULT 1 | 0-管理员,1-学生 |
| student_id | VARCHAR(50) | 学号(学生特有) | |
| avatar | VARCHAR(255) | 头像文件路径 | |
| created_at | DATETIME | 创建时间 | |
| updated_at | DATETIME | 更新时间 |
表2:dormitory_building(宿舍楼表)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | BIGINT | PK AUTO_INCREMENT | 楼栋ID |
| name | VARCHAR(100) | NOT NULL | 楼栋名称(如"梅苑A栋") |
| code | VARCHAR(20) | 楼栋编号 | |
| gender | TINYINT | DEFAULT 2 | 0-女,1-男,2-混合 |
| floor_count | INT | DEFAULT 6 | 楼层数 |
| description | VARCHAR(500) | 备注描述 | |
| created_at | DATETIME | ||
| updated_at | DATETIME |
表3:room(房间表)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | BIGINT | PK AUTO_INCREMENT | 房间ID |
| building_id | BIGINT | FK → dormitory_building.id | 所属楼栋 |
| room_number | VARCHAR(20) | NOT NULL | 房间号(如"101") |
| floor | INT | 所在楼层 | |
| capacity | INT | NOT NULL | 总床位数 |
| gender | TINYINT | 性别限制(可覆盖楼栋设置) | |
| created_at | DATETIME | ||
| updated_at | DATETIME |
表4:bed(床位表)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | BIGINT | PK AUTO_INCREMENT | 床位ID |
| room_id | BIGINT | FK → room.id | 所属房间 |
| bed_number | VARCHAR(10) | NOT NULL | 床位编号(如"A"、"1") |
| status | TINYINT | DEFAULT 0 | 0-空闲,1-占用,2-维修 |
| created_at | DATETIME | ||
| updated_at | DATETIME |
表5:assignment(分配记录表)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | BIGINT | PK AUTO_INCREMENT | 记录ID |
| user_id | BIGINT | FK → user.id | 学生用户 |
| bed_id | BIGINT | FK → bed.id | 分配的床位 |
| assign_date | DATE | NOT NULL | 入住日期 |
| end_date | DATE | 迁出日期(空=当前住宿) | |
| status | TINYINT | DEFAULT 0 | 0-当前住宿,1-已迁出 |
| created_at | DATETIME | ||
| updated_at | DATETIME |
user(username) — 唯一索引user(student_id) — 唯一索引assignment(user_id, status) — 联合索引,快速查询学生当前住宿assignment(bed_id, status) — 联合索引,快速查询床位当前是否被占用room(building_id, room_number) — 唯一约束,同一楼栋房号唯一mindmap
root((宿舍管理系统))
登录模块
学生登录
管理员登录
拦截器认证
宿舍楼管理_Admin
新增楼栋
编辑楼栋
删除楼栋
分页查询
房间管理_Admin
新增房间
编辑房间
删除房间
按楼栋筛选
分页查询
床位管理_Admin
新增床位
编辑床位
删除床位
按房间筛选
分页查询
用户管理_Admin
新增学生
编辑学生
删除学生
分页查询
头像上传
分配管理_Admin
查看分配记录
调整入住
分页查询
学生自助
查看当前住宿
浏览空闲床位
选择床位_直接入住
flowchart TD
A[学生登录] --> B{已有住宿记录?}
B -->|是 status=0| C[展示当前住宿信息<br/>楼栋 → 房间 → 床位]
B -->|否| D[查询可选宿舍楼根据学生性别筛选]
D --> E[展示可选宿舍楼列表]
E --> F[选择楼栋]
F --> G[展示该楼栋可选房间有剩余床位的房间]
G --> H[选择房间]
H --> I[展示房间内空闲床位]
I --> J[选择床位]
J --> K[确认入住]
K --> L{床位是否仍空闲?}
L -->|是| M[创建 assignment 记录<br/>status=0]
L -->|否| N[提示已被他人选择]
M --> O[更新床位 status=1]
O --> P[跳转至住宿信息页]
N --> I
使用 Maven 的 maven-archetype-webapp 原型创建项目,或直接在 IDE 中新建 Maven Web 项目。项目基本信息如下:
space.anyiSSM_templatewarpom.xml 完整依赖
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.version>5.3.31</spring.version>
<mybatis.version>3.5.6</mybatis.version>
<mysql.version>8.0.25</mysql.version>
</properties>
<dependencies>
<!-- Spring 核心 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.7</version>
</dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>${mybatis.version}</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>2.0.6</version>
</dependency>
<!-- MySQL + 连接池 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.8</version>
</dependency>
<!-- JSP + JSTL -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.servlet.jsp.jstl</groupId>
<artifactId>javax.servlet.jsp.jstl-api</artifactId>
<version>1.2.2</version>
</dependency>
<dependency>
<groupId>org.apache.taglibs</groupId>
<artifactId>taglibs-standard-impl</artifactId>
<version>1.2.5</version>
</dependency>
<!-- 分页插件 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper</artifactId>
<version>5.3.2</version>
</dependency>
<!-- 文件上传 -->
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.4</version>
</dependency>
<!-- 日志 -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.11</version>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.tomcat.maven</groupId>
<artifactId>tomcat7-maven-plugin</artifactId>
<version>2.2</version>
<configuration>
<port>8080</port>
<path>/</path>
<uriEncoding>UTF-8</uriEncoding>
</configuration>
</plugin>
</plugins>
</build>
spring-mvc.xml — 配置 Spring MVC 视图解析器、文件上传和拦截器:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xsi:schemaLocation="...">
<!-- 扫描 Controller 包 -->
<context:component-scan base-package="space.anyi.controller"/>
<mvc:annotation-driven/>
<mvc:default-servlet-handler/>
<!-- JSP 视图解析器 -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/views/"/>
<property name="suffix" value=".jsp"/>
</bean>
<!-- 文件上传解析器 -->
<bean id="multipartResolver"
class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
<property name="maxUploadSize" value="2097152"/>
<property name="defaultEncoding" value="UTF-8"/>
</bean>
<!-- 拦截器链 -->
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/**"/>
<mvc:exclude-mapping path="/login"/>
<mvc:exclude-mapping path="/static/**"/>
<bean class="space.anyi.interceptor.LoginInterceptor"/>
</mvc:interceptor>
<mvc:interceptor>
<mvc:mapping path="/admin/**"/>
<bean class="space.anyi.interceptor.AuthInterceptor"/>
</mvc:interceptor>
</mvc:interceptors>
</beans>
spring-context.xml — 配置 Spring 核心、数据源、MyBatis 和事务:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:tx="http://www.springframework.org/schema/tx"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="...">
<!-- 扫描包(排除 Controller,由 spring-mvc.xml 扫描) -->
<context:component-scan base-package="space.anyi">
<context:exclude-filter type="annotation"
expression="org.springframework.stereotype.Controller"/>
</context:component-scan>
<!-- 加载数据库配置 -->
<context:property-placeholder location="classpath:jdbc.properties"/>
<!-- Druid 数据源 -->
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource">
<property name="driverClassName" value="${jdbc.driver}"/>
<property name="url" value="${jdbc.url}"/>
<property name="username" value="${jdbc.username}"/>
<property name="password" value="${jdbc.password}"/>
</bean>
<!-- MyBatis SqlSessionFactory -->
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource"/>
<property name="configLocation" value="classpath:mybatis-config.xml"/>
<property name="mapperLocations" value="classpath:mapper/*.xml"/>
<property name="typeAliasesPackage" value="space.anyi.entity"/>
</bean>
<!-- Mapper 接口扫描 -->
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="basePackage" value="space.anyi.mapper"/>
<property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/>
</bean>
<!-- 事务管理器 -->
<bean id="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>
<tx:annotation-driven transaction-manager="transactionManager"/>
</beans>
mybatis-config.xml — 配置 PageHelper 插件和 MyBatis 设置:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org/DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="cacheEnabled" value="false"/>
<setting name="logImpl" value="STDOUT_LOGGING"/>
</settings>
<plugins>
<plugin interceptor="com.github.pagehelper.PageInterceptor">
<property name="helperDialect" value="mysql"/>
<property name="reasonable" value="true"/>
</plugin>
</plugins>
</configuration>
logback.xml — 日志配置(控制台 + 滚动文件):
<configuration scan="true" scanPeriod="60 seconds" debug="false">
<property name="LOG_HOME" value="logs"/>
<property name="MAX_HISTORY" value="30"/>
<property name="MAX_FILE_SIZE" value="100MB"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/ssm-info.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/archive/ssm-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>${MAX_FILE_SIZE}</maxFileSize>
<maxHistory>${MAX_HISTORY}</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/ssm-error.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/archive/ssm-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>${MAX_FILE_SIZE}</maxFileSize>
<maxHistory>${MAX_HISTORY}</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE_INFO"/>
<appender-ref ref="FILE_ERROR"/>
</root>
<logger name="space.anyi.mapper" level="DEBUG" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>
</configuration>
web.xml — 配置 DispatcherServlet、ContextLoaderListener 和字符编码过滤器:
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="..."
version="4.0">
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:spring-context.xml</param-value>
</context-param>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:spring-mvc.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
<init-param>
<param-name>forceEncoding</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
</web-app>
jdbc.properties — 数据库连接配置:
jdbc.driver=com.mysql.cj.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/dormitory_manage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
jdbc.username=root
jdbc.password=root
src/main/java/space/anyi/
├── common/ # 通用工具类
│ ├── PageResult.java # 分页结果封装
│ └── Result.java # JSON 统一返回格式
├── controller/ # 控制层
│ ├── AdminController.java
│ ├── AssignmentController.java
│ ├── BedController.java
│ ├── BuildingController.java
│ ├── LoginController.java
│ ├── RoomController.java
│ ├── StudentController.java
│ └── UserController.java
├── entity/ # 实体类
│ ├── Assignment.java
│ ├── Bed.java
│ ├── DormitoryBuilding.java
│ ├── Room.java
│ └── User.java
├── interceptor/ # 拦截器
│ ├── AuthInterceptor.java # 权限控制
│ └── LoginInterceptor.java # 登录验证
├── mapper/ # MyBatis Mapper 接口
│ ├── AssignmentMapper.java
│ ├── BedMapper.java
│ ├── DormitoryBuildingMapper.java
│ ├── RoomMapper.java
│ └── UserMapper.java
└── service/ # 业务层
├── AssignmentService.java
├── BedService.java
├── BuildingService.java
├── RoomService.java
├── UserService.java
└── impl/
├── AssignmentServiceImpl.java
├── BedServiceImpl.java
├── BuildingServiceImpl.java
├── RoomServiceImpl.java
└── UserServiceImpl.java
src/main/resources/
├── jdbc.properties # 数据库连接配置
├── logback.xml # 日志配置
├── mybatis-config.xml # MyBatis 配置
├── spring-context.xml # Spring + MyBatis + 事务
├── spring-mvc.xml # Spring MVC + 视图解析器 + 文件上传
└── mapper/ # MyBatis XML 映射文件
├── AssignmentMapper.xml
├── BedMapper.xml
├── DormitoryBuildingMapper.xml
├── RoomMapper.xml
└── UserMapper.xml
src/main/webapp/
├── WEB-INF/
│ ├── web.xml # Servlet 配置
│ └── views/ # JSP 视图文件
│ ├── admin/ # 管理员页面
│ │ ├── dashboard.jsp
│ │ ├── building_list.jsp
│ │ ├── building_edit.jsp
│ │ ├── room_list.jsp
│ │ ├── room_edit.jsp
│ │ ├── bed_list.jsp
│ │ ├── bed_edit.jsp
│ │ ├── user_list.jsp
│ │ ├── user_edit.jsp
│ │ └── assignment_list.jsp
│ ├── student/ # 学生页面
│ │ ├── dashboard.jsp
│ │ └── select_bed.jsp
│ ├── common/
│ │ └── header.jsp
│ ├── login.jsp
│ └── error.jsp
└── static/
└── uploads/ # 头像上传目录
CREATE DATABASE IF NOT EXISTS dormitory_manage DEFAULT CHARACTER SET utf8mb4;
USE dormitory_manage;
-- 用户表
CREATE TABLE user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
name VARCHAR(50) NOT NULL,
gender TINYINT DEFAULT 0 COMMENT '0-女, 1-男',
phone VARCHAR(20),
role TINYINT NOT NULL DEFAULT 1 COMMENT '0-管理员, 1-学生',
student_id VARCHAR(50) UNIQUE COMMENT '学号',
avatar VARCHAR(255) COMMENT '头像路径',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 宿舍楼表
CREATE TABLE dormitory_building (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
code VARCHAR(20),
gender TINYINT DEFAULT 2 COMMENT '0-女, 1-男, 2-混合',
floor_count INT DEFAULT 6,
description VARCHAR(500),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 房间表
CREATE TABLE room (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
building_id BIGINT NOT NULL,
room_number VARCHAR(20) NOT NULL,
floor INT,
capacity INT NOT NULL COMMENT '总床位数',
gender TINYINT COMMENT '可覆盖楼栋性别限制',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_building_id (building_id),
UNIQUE KEY uk_building_room (building_id, room_number)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 床位表
CREATE TABLE bed (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
room_id BIGINT NOT NULL,
bed_number VARCHAR(10) NOT NULL,
status TINYINT DEFAULT 0 COMMENT '0-空闲, 1-占用, 2-维修',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_room_id (room_id),
UNIQUE KEY uk_room_bed (room_id, bed_number)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 分配记录表(中间表)
CREATE TABLE assignment (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
bed_id BIGINT NOT NULL,
assign_date DATE NOT NULL,
end_date DATE COMMENT '迁出日期, NULL=当前住宿',
status TINYINT DEFAULT 0 COMMENT '0-当前住宿, 1-已迁出',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_bed_id (bed_id),
INDEX idx_user_status (user_id, status),
INDEX idx_bed_status (bed_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 管理员账号
INSERT INTO user (username, password, name, role) VALUES
('admin', '21232f297a57a5a743894a0e4a801fc3', '系统管理员', 0);
-- 密码 admin123 的 MD5
-- 学生账号
INSERT INTO user (username, password, name, gender, role, student_id) VALUES
('zhangsan', 'e10adc3949ba59abbe56e057f20f883e', '张三', 1, 1, '2024001'),
('lisi', 'e10adc3949ba59abbe56e057f20f883e', '李四', 1, 1, '2024002'),
('wangwu', 'e10adc3949ba59abbe56e057f20f883e', '王五', 1, 1, '2024003');
-- 密码 123456 的 MD5
-- 宿舍楼
INSERT INTO dormitory_building (name, code, gender, floor_count) VALUES
('梅苑A栋', 'MA', 1, 6),
('兰苑B栋', 'LB', 0, 6),
('竹苑C栋', 'ZC', 2, 5);
-- 房间(梅苑A栋 101~106)
INSERT INTO room (building_id, room_number, floor, capacity, gender) VALUES
(1, '101', 1, 4, 1),
(1, '102', 1, 4, 1),
(1, '103', 1, 4, 1);
-- 床位(101 房间 4 个床位)
INSERT INTO bed (room_id, bed_number, status) VALUES
(1, 'A', 0), (1, 'B', 0), (1, 'C', 0), (1, 'D', 0);
以 User.java 为例:
package space.anyi.entity;
import java.time.LocalDateTime;
public class User {
private Long id;
private String username;
private String password;
private String name;
private Integer gender;
private String phone;
private Integer role; // 0-管理员, 1-学生
private String studentId;
private String avatar;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// getter / setter(省略)
}
以 DormitoryBuildingMapper.java 为例:
package space.anyi.mapper;
import space.anyi.entity.DormitoryBuilding;
import java.util.List;
public interface DormitoryBuildingMapper {
List<DormitoryBuilding> selectAll();
DormitoryBuilding selectById(Long id);
int insert(DormitoryBuilding building);
int update(DormitoryBuilding building);
int deleteById(Long id);
}
DormitoryBuildingMapper.xml:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org/DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="space.anyi.mapper.DormitoryBuildingMapper">
<resultMap id="BaseResultMap" type="space.anyi.entity.DormitoryBuilding">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="code" column="code"/>
<result property="gender" column="gender"/>
<result property="floorCount" column="floor_count"/>
<result property="description" column="description"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<sql id="BaseColumns">
id, name, code, gender, floor_count,
description, created_at, updated_at
</sql>
<select id="selectAll" resultMap="BaseResultMap">
SELECT <include refid="BaseColumns"/>
FROM dormitory_building
ORDER BY id
</select>
<select id="selectById" resultMap="BaseResultMap">
SELECT <include refid="BaseColumns"/>
FROM dormitory_building WHERE id = #{id}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO dormitory_building (name, code, gender, floor_count, description)
VALUES (#{name}, #{code}, #{gender}, #{floorCount}, #{description})
</insert>
<update id="update">
UPDATE dormitory_building
SET name = #{name}, code = #{code}, gender = #{gender},
floor_count = #{floorCount}, description = #{description}
WHERE id = #{id}
</update>
<delete id="deleteById">
DELETE FROM dormitory_building WHERE id = #{id}
</delete>
</mapper>
以 BedService.java 为例,展示选床核心逻辑:
package space.anyi.service;
public interface BedService {
PageResult<Bed> selectByRoomPage(Long roomId, int pageNum, int pageSize);
Bed selectById(Long id);
int addBed(Bed bed);
int updateBed(Bed bed);
int deleteBed(Long id);
/** 学生选床:直接入住 */
Assignment assignBed(Long userId, Long bedId);
}
BedServiceImpl.java(完整实现,含分页查询和核心选床逻辑):
package space.anyi.service.impl;
@Service
public class BedServiceImpl implements BedService {
@Autowired
private BedMapper bedMapper;
@Autowired
private AssignmentMapper assignmentMapper;
@Override
public PageResult<Bed> selectPage(int pageNum, int pageSize) {
PageHelper.startPage(pageNum, pageSize);
List<Bed> list = bedMapper.selectAll();
PageInfo<Bed> pageInfo = new PageInfo<>(list);
return new PageResult<>(pageInfo.getList(), pageInfo.getTotal(),
pageInfo.getPageNum(), pageInfo.getPages());
}
@Override
public PageResult<Bed> selectByRoomPage(Long roomId, int pageNum, int pageSize) {
PageHelper.startPage(pageNum, pageSize);
List<Bed> list = bedMapper.selectByRoomId(roomId);
PageInfo<Bed> pageInfo = new PageInfo<>(list);
return new PageResult<>(pageInfo.getList(), pageInfo.getTotal(),
pageInfo.getPageNum(), pageInfo.getPages());
}
@Override
public List<Bed> selectAvailableByRoomId(Long roomId) {
return bedMapper.selectAvailableByRoomId(roomId);
}
@Override
public Bed selectById(Long id) {
return bedMapper.selectById(id);
}
@Override
@Transactional
public int addBed(Bed bed) {
if (bed.getStatus() == null) bed.setStatus(0);
return bedMapper.insert(bed);
}
@Override
@Transactional
public int updateBed(Bed bed) {
return bedMapper.update(bed);
}
@Override
@Transactional
public int deleteBed(Long id) {
return bedMapper.deleteById(id);
}
@Override
@Transactional
public Assignment assignBed(Long userId, Long bedId) {
Bed bed = bedMapper.selectById(bedId);
if (bed == null || bed.getStatus() != 0) {
throw new RuntimeException("该床位不可用");
}
Assignment active = assignmentMapper.selectActiveByUserId(userId);
if (active != null) {
throw new RuntimeException("你已有住宿记录,不可重复选择");
}
Assignment assignment = new Assignment();
assignment.setUserId(userId);
assignment.setBedId(bedId);
assignment.setAssignDate(LocalDate.now());
assignment.setStatus(0);
assignmentMapper.insert(assignment);
bed.setStatus(1);
bedMapper.update(bed);
return assignment;
}
}
以 StudentController.java 为例(完整实现,含选床向导和头像上传):
package space.anyi.controller;
@Controller
@RequestMapping("/student")
public class StudentController {
@Autowired
private AssignmentService assignmentService;
@Autowired
private BuildingService buildingService;
@Autowired
private RoomService roomService;
@Autowired
private BedService bedService;
@Autowired
private UserService userService;
@GetMapping("/dashboard")
public String dashboard(HttpSession session, Model model) {
User user = (User) session.getAttribute("user");
Assignment assignment = assignmentService.selectActiveByUserId(user.getId());
model.addAttribute("assignment", assignment);
return "student/dashboard";
}
@GetMapping("/select")
public String selectBed(@RequestParam(required = false) Long buildingId,
@RequestParam(required = false) Long roomId,
HttpSession session, Model model) {
User user = (User) session.getAttribute("user");
// 已有住宿则跳转
Assignment existing = assignmentService.selectActiveByUserId(user.getId());
if (existing != null) {
return "redirect:/student/dashboard";
}
// 按学生性别筛选可用楼栋
List<DormitoryBuilding> buildings =
buildingService.selectByGender(user.getGender());
model.addAttribute("buildings", buildings);
if (buildingId != null) {
List<Room> rooms = roomService.selectAvailableByBuildingId(buildingId);
model.addAttribute("rooms", rooms);
model.addAttribute("selectedBuildingId", buildingId);
}
if (roomId != null) {
List<Bed> beds = bedService.selectAvailableByRoomId(roomId);
model.addAttribute("beds", beds);
model.addAttribute("selectedRoomId", roomId);
}
return "student/select_bed";
}
@PostMapping("/assign")
public String assign(Long bedId, HttpSession session) {
User user = (User) session.getAttribute("user");
try {
bedService.assignBed(user.getId(), bedId);
} catch (RuntimeException e) {
return "redirect:/student/select?error=" + e.getMessage();
}
return "redirect:/student/dashboard";
}
@PostMapping("/uploadAvatar")
public String uploadAvatar(@RequestParam("file") MultipartFile file,
HttpSession session) throws IOException {
if (file.isEmpty() || !file.getContentType().startsWith("image/")) {
return "redirect:/student/dashboard?error=format";
}
if (file.getSize() > 2 * 1024 * 1024) {
return "redirect:/student/dashboard?error=size";
}
// 唯一文件名 → 保存到 /static/uploads/ → 更新用户头像
String fileName = UUID.randomUUID().toString()
+ file.getOriginalFilename().substring(
file.getOriginalFilename().lastIndexOf("."));
String path = session.getServletContext().getRealPath("/static/uploads/");
new File(path).mkdirs();
file.transferTo(new File(path + fileName));
User user = (User) session.getAttribute("user");
userService.updateAvatar(user.getId(), "/static/uploads/" + fileName);
user.setAvatar("/static/uploads/" + fileName);
session.setAttribute("user", user);
return "redirect:/student/dashboard";
}
}
以 student/dashboard.jsp 为例:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
<title>我的住宿</title>
<link rel="stylesheet" href="https://cdn.staticfile.org/twitter-bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>
<jsp:include page="../common/header.jsp"/>
<div class="container">
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-header">个人资料</div>
<div class="card-body text-center">
<c:choose>
<c:when test="${not empty sessionScope.user.avatar}">
<img src="${pageContext.request.contextPath}${sessionScope.user.avatar}"
class="rounded-circle mb-2"
style="width:120px;height:120px;object-fit:cover">
</c:when>
<c:otherwise>
<div class="mb-2" style="width:120px;height:120px;border-radius:50%;
background:#e9ecef;display:flex;align-items:center;justify-content:center;
margin:0 auto;font-size:48px;color:#6c757d">
${sessionScope.user.name.charAt(0)}
</div>
</c:otherwise>
</c:choose>
<h5>${sessionScope.user.name}</h5>
<p class="text-muted">${sessionScope.user.studentId}</p>
<form action="${pageContext.request.contextPath}/student/uploadAvatar"
method="post" enctype="multipart/form-data">
<div class="custom-file mb-2">
<input type="file" class="custom-file-input" name="file"
accept="image/*" required>
<label class="custom-file-label">选择头像</label>
</div>
<button type="submit" class="btn btn-sm btn-outline-primary">上传</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header">住宿信息</div>
<div class="card-body">
<c:choose>
<c:when test="${not empty assignment}">
<table class="table table-bordered">
<tr><th>宿舍楼</th><td>${assignment.buildingName}</td></tr>
<tr><th>房间号</th><td>${assignment.roomNumber}</td></tr>
<tr><th>床位号</th><td>${assignment.bedNumber}</td></tr>
<tr><th>入住日期</th><td>${assignment.assignDate}</td></tr>
</table>
</c:when>
<c:otherwise>
<div class="alert alert-warning">
你还没有分配床位。
<a href="${pageContext.request.contextPath}/student/select"
class="btn btn-primary btn-sm ml-2">去选床</a>
</div>
</c:otherwise>
</c:choose>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.staticfile.org/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdn.staticfile.org/twitter-bootstrap/4.3.1/js/bootstrap.min.js"></script>
</body>
</html>
package space.anyi.interceptor;
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
HttpSession session = request.getSession();
User user = (User) session.getAttribute("user");
if (user == null) {
response.sendRedirect(request.getContextPath() + "/login");
return false;
}
return true;
}
}
在 spring-mvc.xml 中注册:
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/**"/>
<mvc:exclude-mapping path="/login"/>
<mvc:exclude-mapping path="/static/**"/>
<bean class="space.anyi.interceptor.LoginInterceptor"/>
</mvc:interceptor>
<mvc:interceptor>
<mvc:mapping path="/admin/**"/>
<bean class="space.anyi.interceptor.AuthInterceptor"/>
</mvc:interceptor>
</mvc:interceptors>
package space.anyi.interceptor;
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
User user = (User) request.getSession().getAttribute("user");
if (user == null || user.getRole() != 0) {
response.sendRedirect(request.getContextPath() + "/login?error=unauthorized");
return false;
}
return true;
}
}
在 mybatis-config.xml 中配置 PageHelper 插件:
<configuration>
<plugins>
<plugin interceptor="com.github.pagehelper.PageInterceptor">
<property name="helperDialect" value="mysql"/>
<property name="reasonable" value="true"/>
</plugin>
</plugins>
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="logImpl" value="STDOUT_LOGGING"/>
</settings>
</configuration>
Service 层使用:
@Override
public PageResult<User> selectUserPage(int pageNum, int pageSize) {
PageHelper.startPage(pageNum, pageSize);
List<User> list = userMapper.selectAll();
PageInfo<User> pageInfo = new PageInfo<>(list);
return new PageResult<>(pageInfo.getList(), pageInfo.getTotal(),
pageInfo.getPageNum(), pageInfo.getPages());
}
Controller 层处理(取自 StudentController.java):
@PostMapping("/uploadAvatar")
public String uploadAvatar(@RequestParam("file") MultipartFile file,
HttpSession session) throws IOException {
if (file.isEmpty() || !file.getContentType().startsWith("image/")) {
return "redirect:/student/dashboard?error=format";
}
if (file.getSize() > 2 * 1024 * 1024) {
return "redirect:/student/dashboard?error=size";
}
String originalName = file.getOriginalFilename();
String ext = originalName.substring(originalName.lastIndexOf("."));
String fileName = UUID.randomUUID().toString() + ext;
String uploadPath = session.getServletContext().getRealPath("/static/uploads/");
File dir = new File(uploadPath);
if (!dir.exists()) dir.mkdirs();
file.transferTo(new File(uploadPath + fileName));
User user = (User) session.getAttribute("user");
userService.updateAvatar(user.getId(), "/static/uploads/" + fileName);
user.setAvatar("/static/uploads/" + fileName);
session.setAttribute("user", user);
return "redirect:/student/dashboard";
}
在 spring-context.xml 中已有配置:
<bean id="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>
<tx:annotation-driven transaction-manager="transactionManager"/>
Service 实现类中使用 @Transactional 保证选床等操作的原子性。
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 文件上传返回 null | 未配置 multipartResolver Bean,或 <form> 缺少 enctype="multipart/form-data" |
在 spring-mvc.xml 中声明 CommonsMultipartResolver,表单添加 enctype |
| 分页查询总页数不准确 | PageHelper 的 reasonable 参数未开启,页码超出范围时返回空数据 |
在 mybatis-config.xml 中设置 <property name="reasonable" value="true"/> |
| 关联查询 N+1 问题 | 床位列表页面循环查询每个床位的房间和楼栋信息 | 使用 MyBatis LEFT JOIN 一次性查出关联数据,resultMap 映射额外字段 |
| 选床并发冲突 | 两个学生同时选中同一空闲床位,事务提交后出现一条床位的两个占用记录 | assignBed 使用 @Transactional 保证原子性;后续可在 SQL 层加 SELECT ... FOR UPDATE 悲观锁 |
| 头像上传后页面不更新 | 数据库已更新但 session 中的 user.avatar 仍为旧值 |
上传成功后显式 session.setAttribute("user", user) 更新会话 |
| JSP EL 表达式不解析 | Tomcat 版本默认关闭 EL 或缺少 JSTL 实现库 | 添加 taglibs-standard-impl 依赖,或检查 web.xml 中的 web-app 版本设置 |
| 模块 | 测试方法 | 预期结果 |
|---|---|---|
| 登录 | 学生/管理员凭正确/错误凭据登录 | 正确凭据跳转对应首页,错误凭据提示错误 |
| 宿舍楼管理 | 增删改查操作 | 数据正常同步到数据库,列表分页显示 |
| 房间管理 | 按楼栋筛选后操作 | 只显示选中楼栋的房间 |
| 床位管理 | 按房间筛选,选床操作 | 空闲/占用状态正确切换 |
| 学生选宿 | 未分配学生进入选床页面,选择空闲床位 | 选床成功,生成分配记录,床位状态更新 |
| 重复选床 | 已分配学生再次选床 | 提示"已有住宿记录" |
| 拦截器 | 未登录访问任意页面 | 跳转至登录页 |
| 权限拦截 | 学生访问 admin 路径 | 提示无权限 |
| 分页 | 列表页翻页操作 | 每页数据正确,页码导航正常 |
| 文件上传 | 上传合法/非法文件 | 合法文件上传成功,非法格式/超大小被拒绝 |
截图待项目部署运行后补充
| 功能点 | 截图说明 |
|---|---|
| 项目启动 | Tomcat 启动成功,项目部署无异常 |
| 登录页 | 输入用户名密码,选择角色 |
| 学生首页(已分配) | 显示当前住宿的楼栋/房间/床位信息 |
| 学生选床页面 | 展示可选房间列表和空闲床位 |
| 管理员-宿舍楼列表 | 宿舍楼分页列表,含新增/编辑/删除按钮 |
| 管理员-分配记录 | 所有学生的住宿分配情况 |
| 拦截器效果 | 未登录访问被拦截并跳转 |
| 权限拦截效果 | 学生访问 admin 路径提示无权限 |
| 分页效果 | 列表底部页码导航 |
| 头像上传 | 上传前/上传后头像显示 |
(待项目部署后填写)
| 不足 | 优化方向 |
|---|---|
| 密码使用 MD5 加密,强度不足 | 改为 BCrypt 或 Spring Security |
| 前端校验依赖 jQuery,较为基础 | 引入 Vue.js 或 React 提升交互体验 |
| 缺少批量导入功能 | 支持 Excel 批量导入学生、宿舍数据 |
| 选床并发控制使用乐观锁,仍有极小概率冲突 | 引入 Redis 分布式锁 |
| 无邮件/短信通知 | 分配成功后自动通知学生 |
| 仅支持 PC 端 | 适配移动端或开发微信小程序 |
本宿舍管理系统基于 SSM 框架实现了宿舍资源的线上化管理。系统覆盖了宿舍楼、房间、床位的三级资源管理,支持管理员统一分配和学生自助选床两种模式。通过拦截器实现了登录验证和角色权限控制,利用 PageHelper 完成了列表分页,通过 CommonsMultipartResolver 实现了头像上传功能。项目完整实践了 SSM 框架整合流程中的配置、编码、调试等环节。
(待项目完成后填写)
@Transactional 配合 AOP 可以精确控制业务方法的事务边界,选床操作中同时插入 assignment 记录和更新 bed 状态,任一失败则全部回滚。LIMIT 语句,业务代码几乎零改动。