ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue宿舍管理系统全栈实战:从数据库设计到前后端联调

SpringBoot+Vue宿舍管理系统全栈实战:从数据库设计到前后端联调 做了几个宿舍管理系统之后我越来越觉得这种“看起来很普通”的Java Web项目其实最能检验一个开发者对全栈流程的掌握程度。从需求梳理、数据库设计到后端接口封装再到前端页面的联调每个环节都有坑。今天把这套基于SpringBootVueMySQLMyBatis的宿舍管理系统完整拆开讲一遍包括源码结构、核心设计思路、关键代码怎么写、联调时最容易踩的坑一一说清楚。这套方案适合正在做课程设计、毕业设计或者想通过一个完整项目把Java前后端技术栈串起来的同学参考也适合刚入行想找一套能落地的CRUD项目练手的朋友。1. 项目整体设计与技术选型思路1.1 宿舍管理系统到底在解决什么问题宿舍管理系统本质上是一个多角色、多资源关联的后台管理场景核心管理对象是“学生”和“房间”这两个实体围绕它们衍生出入住退宿、报修、访客、公告等一系列业务动作。先列一下这套系统实际要覆盖的功能模块管理员登录/退出以及管理员账号的增删改查学生信息管理支持按学号、姓名、学院、专业等条件组合筛选宿舍楼管理维护楼栋信息、宿管员、楼层数量房间管理维护房间号、所属楼栋、床位数、当前状态入住与退宿将学生与具体房间、床位绑定并可查历史记录报修管理学生提交报修管理员处理并登记结果访客登记记录来访人员进出信息公告管理发布宿舍相关通知这个系统的核心难点并不在于某个单独的功能多复杂而在于多个实体之间如何保证数据一致性。比如一个学生入住前你要确保房间床位没满退宿之后床位要释放给下一个学生报修时要知道这个学生住在哪栋哪间。这些关联关系如果只靠脑子记写到后面前后端联调的时候一定会乱。所以第一步必须先做数据库设计把实体关系和约束定好。1.2 为什么选这套技术栈网上类似的系统有各种组合SSH的、SSM的、SpringBootJSP的都有。我为什么建议用SpringBootVue这套组合先说后端。SpringBoot相比传统SSM最大的优势是“省心”。内嵌Tomcat解决了环境配置问题自动装配把大量Bean的声明去掉了一个spring-boot-starter-web就能把Web环境跑起来。对于课设和毕设这种短周期项目来说开发效率比SSM高一大截。配合MyBatis做数据访问层SQL都是手写的既灵活又直观调试起来也方便出了问题一眼就能看到是哪条SQL写得不对。再说前端。选择Vue而不是JSP核心原因是前后端分离带来的开发体验提升。JSP时代前端代码和后端代码混在一起改个样式可能都要重启服务。Vue这边用Element UI组件库后台管理系统最常见的表格、表单、弹窗、分页都有现成组件写起来非常快。而且Vue的响应式机制天然适合这种以数据展示为主的页面场景数据变化直接驱动视图更新不需要手动操作DOM。MySQL做数据库没有太多悬念免费、主流、资料多遇到问题搜索一下就能找到解决方案。5.7和8.0都行如果本地已经装了8.0就用8.0注意数据库驱动版本要匹配。我个人的建议是优先用本地已有的环境没必要为了版本纠结后面我会专门说版本不一致导致的坑。1.3 项目结构规划前后端分离怎么分整套系统的目录结构我推荐这样组织dormitory-system ├── backend # SpringBoot后端工程 │ ├── src/main/java/com/dormitory │ │ ├── controller # 接口层接收请求、返回结果 │ │ ├── service # 业务逻辑层 │ │ ├── mapper # MyBatis数据访问接口 │ │ ├── entity # 数据库实体类 │ │ ├── dto # 前端交互参数对象 │ │ ├── common # 统一返回结果、异常处理 │ │ ├── config # 拦截器、跨域配置等 │ │ └── util # JWT、MD5等工具类 │ └── src/main/resources │ ├── application.yml │ └── mapper/*.xml # MyBatis的SQL映射文件 ├── frontend # Vue前端工程 │ ├── src │ │ ├── api # 接口请求封装 │ │ ├── router # 路由配置 │ │ ├── store # 全局状态管理Vuex │ │ ├── views # 页面组件 │ │ ├── components # 公共组件 │ │ └── utils # 工具类 │ ├── package.json │ └── vue.config.js # 开发代理配置前后端分离以后开发时并行进行后端把接口定义好返回JSON前端对着接口文档或Swagger页面开发页面。不要把前后端代码写在同一个工程里不然就失去了分离的意义。2. 数据库设计系统的地基与核心表结构2.1 核心表结构设计数据库设计我习惯先画ER图理清关系再落到建表SQL。这套系统的表不算多我把核心表结构列一下都是经过实际验证的可以直接参考。管理员表 admin字段名类型说明idint主键自增usernamevarchar(50)登录账号唯一passwordvarchar(100)密码MD5加密后存储real_namevarchar(50)管理员姓名rolevarchar(20)角色super/admincreate_timedatetime创建时间学生表 student字段名类型说明idint主键自增student_novarchar(20)学号唯一namevarchar(50)姓名gendervarchar(10)性别phonevarchar(20)手机号collegevarchar(50)学院majorvarchar(50)专业class_namevarchar(50)班级statusint状态0离校1在校create_timedatetime创建时间宿舍楼表 dormitory_building字段名类型说明idint主键自增building_namevarchar(50)楼栋名称managervarchar(50)宿管姓名phonevarchar(20)联系电话floor_countint楼层数remarkvarchar(255)备注房间表 room字段名类型说明idint主键自增building_idint所属楼栋IDroom_novarchar(20)房间号floorint所在楼层capacityint可容纳人数statusint状态0空闲1部分入住2已满入住记录表 student_room字段名类型说明idint主键自增student_idint学生IDroom_idint房间IDbed_noint床位编号check_in_timedatetime入住时间check_out_timedatetime退宿时间为空表示在住statusint状态0已退宿1在住报修表 repair字段名类型说明idint主键自增student_idint报修学生IDroom_idint房间IDcontentvarchar(500)报修内容phonevarchar(20)联系电话statusint状态0待处理1已处理create_timedatetime提交时间handle_timedatetime处理时间handle_resultvarchar(500)处理结果访客表 visitor字段名类型说明idint主键自增student_idint被访学生IDvisitor_namevarchar(50)访客姓名visitor_phonevarchar(20)访客电话visit_timedatetime到访时间leave_timedatetime离开时间reasonvarchar(200)来访事由公告表 notice字段名类型说明idint主键自增titlevarchar(100)公告标题contenttext公告内容publishervarchar(50)发布人create_timedatetime发布时间2.2 多表关联的关键设计思路这套系统最容易设计错的地方是学生和房间的关系。最简单的做法是在学生表上加一个room_id字段入住时直接更新。这种方案的缺点很明显一旦学生中途换宿舍、毕业退宿历史记录就丢了数据也容易不一致。所以我用了student_room关联表把入住和退宿设计成一条记录的生命周期。入住时新增一条student_room记录status1check_out_time为空退宿时把这条记录的status改为0并写入check_out_time。这样要查“某个学生住过哪些房间”一条SQL就出来了。宿舍的实时空床数则通过当前状态下status1的记录数来统计。房间表里我加了一个status字段空闲/部分入住/已满但这个字段不建议每次都手动更新更可靠的做法是根据入住记录实时计算。比如查询房间列表时用下面这条SQL把已住人数带出来SELECT r.*, b.building_name, (SELECT COUNT(*) FROM student_room sr WHERE sr.room_id r.id AND sr.status 1) AS lived_count FROM room r LEFT JOIN dormitory_building b ON r.building_id b.id这样房间表里的status可以作为一个冗余字段界面展示时按“已住人数是否达到capacity”来决定要不要显示“已满”而不是依赖这个字段的开关。3. 后端核心实现SpringBootMyBatis的关键环节3.1 三层架构与统一返回结果后端我严格按照Controller、Service、Mapper三层来写。Controller层只负责接收前端参数和返回结果不写业务逻辑Service层处理业务规则比如入住时校验房间是否有空床位Mapper层只做数据库操作。这样的分层看起来多了一些类但后期维护的时候逻辑非常清晰。所有的接口返回统一使用一个结果类包装前端拿到数据结构一致也好做统一处理Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }同时配合RestControllerAdvice做全局异常处理和ControllerAdvice做全局参数校验这样后端不会出现“某次请求抛了个异常前端直接卡死”的情况所有异常最终都会落到统一定义的返回结构上。有个小细节很多人容易忽略LocalDateTime在Jackson序列化时默认输出的格式是一长串数组接口返回的JSON里时间看起来和预期完全不一样还需要在前端额外处理。所以最好在配置里直接全局格式化Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder - { builder.simpleDateFormat(yyyy-MM-dd HH:mm:ss); builder.serializers(new LocalDateTimeSerializer(...)); }; } }3.2 登录鉴权与拦截器登录模块我用的是JWT方案。登录成功后后端用管理员ID和密钥生成一个token返回给前端前端存在localStorage里。之后的每次请求前端在请求头里带上token字段后端通过拦截器统一校验。JWT工具类核心代码如下public class JwtUtil { private static final String SECRET dormitory-secret-key; private static final long EXPIRE_TIME 24 * 60 * 60 * 1000; public static String createToken(Integer adminId, String username) { Calendar calendar Calendar.getInstance(); calendar.add(Calendar.DATE, 1); return Jwts.builder() .setSubject(username) .claim(adminId, adminId) .setExpiration(calendar.getTime()) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }拦截器里面校验token并从中取出管理员ID放到Request域中供后续业务使用public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(token); if (token null || token.isEmpty()) { response.setStatus(401); return false; } try { Claims claims JwtUtil.parseToken(token); request.setAttribute(adminId, claims.get(adminId)); return true; } catch (Exception e) { response.setStatus(401); return false; } } }然后在WebMvcConfigurer里注册拦截器并放行登录接口和静态资源Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/api/login, /error); } }密码存储方面虽然这个项目是学习用途但也不要明文存储。最简单的做法是用MD5加盐做到“就算数据库泄露也不能直接看到密码原文”。3.3 MyBatis动态SQL的实战写法MyBatis在这个项目里的一个主要使用场景是多条件组合查询。比如学生列表页前端可能按学号搜可能按姓名搜可能按学院筛最笨的办法是为每一种情况写一个接口但那样接口数量爆炸。用动态SQL一个接口就搞定。以学生条件查询为例Mapper接口方法定义为ListStudent selectStudentList(Param(studentNo) String studentNo, Param(name) String name, Param(college) String college, Param(status) Integer status);对应XML文件select idselectStudentList resultTypecom.dormitory.entity.Student SELECT * FROM student where if teststudentNo ! null and studentNo ! AND student_no #{studentNo} /if if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testcollege ! null and college ! AND college LIKE CONCAT(%, #{college}, %) /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC /select这里有几个细节值得说where标签的好处是它会自动去掉第一个条件前面的AND如果你自己手写WHERE 11然后拼AND虽然也能跑但看起来很业余而且后期不好维护。LIKE CONCAT(%, #{name}, %)这种写法比用‘%${name}%’安全得多。${}是字符串拼接存在SQL注入风险#{}是预编译参数占位符安全。哪怕你是做课程设计也别养成用${}的习惯。分页功能建议直接用PageHelper插件三行配置就能用PageHelper.startPage(pageNum, pageSize); ListStudent list studentMapper.selectStudentList(...); PageInfoStudent pageInfo new PageInfo(list);返回给前端时带上total、pageNum、pageSize、list四个字段前端去做分页条展示。3.4 入住与退宿的事务处理入住这个操作涉及两张表的更新往student_room表加一条记录同时要判断房间是否还有空位。如果并发请求在同一时刻操作同一个房间可能两个学生同时住进同一个床位。所以入住逻辑必须加事务Transactional(rollbackFor Exception.class) public ResultString checkIn(Integer studentId, Integer roomId, Integer bedNo) { // 查询房间 Room room roomMapper.selectById(roomId); // 统计当前已住人数 Integer livedCount studentRoomMapper.countLivedByRoomId(roomId); if (livedCount room.getCapacity()) { return Result.error(该房间床位已满); } // 校验该学生是否已经在住 StudentRoom activeRecord studentRoomMapper.selectActiveByStudentId(studentId); if (activeRecord ! null) { return Result.error(该学生已在住请先办理退宿); } // 新增入住记录 StudentRoom sr new StudentRoom(); sr.setStudentId(studentId); sr.setRoomId(roomId); sr.setBedNo(bedNo); sr.setStatus(1); sr.setCheckInTime(LocalDateTime.now()); studentRoomMapper.insert(sr); return Result.success(入住成功); }别忘了事务。Transactional加上去之后如果中途任何一个环节出错整个操作都会回滚不会出现“学生住进去了但床位统计没更新”这种脏数据。4. 前端实现VueElement UI的关键细节4.1 前端工程与路由设计前端用Vue 2 Element UI这套组合熟悉的人最多遇到问题也最好搜。如果电脑上的Node版本比较新创建项目时可能会遇到兼容问题建议直接用Vue CLI或Vite搭建创建命令打一遍很熟悉再去具体做业务页面。路由设计上我分成两层登录页是独立页面所有管理页面都在一个带有侧边栏的布局组件下面。const routes [ { path: /login, component: Login }, { path: /, component: Layout, redirect: /dashboard, children: [ { path: dashboard, component: Dashboard, meta: { title: 数据概览 } }, { path: student, component: StudentManage, meta: { title: 学生管理 } }, { path: building, component: BuildingManage, meta: { title: 宿舍楼管理 } }, { path: room, component: RoomManage, meta: { title: 房间管理 } }, { path: checkin, component: CheckInManage, meta: { title: 入住退宿 } }, { path: repair, component: RepairManage, meta: { title: 报修管理 } }, { path: visitor, component: VisitorManage, meta: { title: 访客登记 } }, { path: notice, component: NoticeManage, meta: { title: 公告管理 } }, { path: admin, component: AdminManage, meta: { title: 管理员管理 } } ] } ]路由守卫是登录拦截的核心没有token的用户访问任何管理页都会弹回登录页router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else if (!token) { next(/login) } else { next() } })4.2 Axios封装与请求拦截axios如果直接在页面里this.$http.post(...)这样用每次都要重复写token而且错误处理不统一。我习惯把axios封装成一个模块// api/request.js import axios from axios import { Message } from element-ui import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[token] token } return config }) request.interceptors.response.use( response { const res response.data if (res.code 200) { return res } else { Message.error(res.message) return Promise.reject(res) } }, error { if (error.response error.response.status 401) { Message.error(登录已过期请重新登录) localStorage.removeItem(token) router.push(/login) } else { Message.error(网络异常请稍后重试) } return Promise.reject(error) } ) export default request这样封装之后页面里调用接口只需要关心数据本身错误提示和登录过期跳转都是全局处理的不会遗漏。跨域问题是前后端分离项目的第一个拦路虎。开发环境下最简单的方式是在vue.config.js里配置代理module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } } }这个配置的意思是前端发出的所有以/api开头的请求开发服务器会转发到http://localhost:8081这个后端地址。这样在前端代码里看起来就像同源请求不会产生跨域问题。4.3 表格与表单组合的实战模式后台管理系统最典型的页面模式是“表格展示弹窗表单分页”。以学生管理页为例整体逻辑是这样的页面加载时调用fetchList方法请求第一页数据把返回的list赋给表格的dataSource属性total赋给分页组件的total属性。点击“新增”按钮时弹出一个对话框里面放表单控件表单提交时根据是否有editId来判断走新增接口还是更新接口。fetchList() { const params { pageNum: this.pageNum, pageSize: this.pageSize, name: this.searchForm.name, college: this.searchForm.college } request.get(/api/student/list, { params }).then(res { this.tableData res.data.list this.total res.data.total }) }新增和编辑可以共用一个对话框靠dialogTitle区分el-dialog :titledialogTitle :visible.syncdialogVisible width500px el-form refstudentForm :modelstudentForm :rulesformRules label-width80px el-form-item label学号 propstudentNo el-input v-modelstudentForm.studentNo / /el-form-item el-form-item label姓名 propname el-input v-modelstudentForm.name / /el-form-item !-- 其他字段省略 -- /el-form div slotfooter el-button clickdialogVisible false取消/el-button el-button typeprimary clicksubmitForm确定/el-button /div /el-dialog表单校验规则用Element UI自带的rules属性声明式配置比如学号必填、手机号格式校验formRules: { studentNo: [{ required: true, message: 请输入学号, trigger: blur }], name: [{ required: true, message: 请输入姓名, trigger: blur }], phone: [{ pattern: /^1[3-9]\d{9}$/, message: 手机号格式不正确, trigger: blur }] }这套表格表单的开发模式后面的房间管理、管理员管理、公告管理页面基本是复制粘贴再改改字段熟练了之后一天能写完三四个页面。5. 常见问题与排查技巧实录5.1 前后端联调高频报错No Access-Control-Allow-Origin header is present这个报错几乎每个做前后端分离的人都会遇到。原因很直接前端的8080端口向后端的8081端口发请求浏览器认为这是跨域请求而后端没有返回允许跨域的响应头。解决方式有两种。第一种是后端加一个跨域配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }第二种就是上文提到的前端代理方案。两种方式选一种即可我建议优先用前端代理因为生产环境部署时还可以用Nginx做统一转发思路是一致的。前端请求能拿到数据但页面刷新后数据丢失这种情况基本是状态管理只放在Vuex里、没有写localStorage导致的。Vuex的特性是页面刷新后内存中的数据会重置所以需要持久化的数据比如用户信息和token必须放在localStorage里。我一般是在登录成功之后就同步写一份到localStorage后续页面初始化时优先从localStorage读取。5.2 后端与MyBatis相关报错Invalid bound statement (not found): xxxMapper.xxx这个报错的意思是MyBatis找不到Mapper接口对应的SQL语句。排查思路三步走第一步检查XML文件路径。Mapper接口在com.dormitory.mapper包下XML文件是否放在了resources/mapper目录下并保持了对应的结构。第二步检查XML文件的namespace。namespace必须写Mapper接口全限定名少了一个包路径都会找不对。第三步检查application.yml里的mapper-locations配置mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.dormitory.entity这个配置写错或者漏写MyBatis就不会扫描XML文件于是所有Mapper方法都变成Invalid bound statement。MySQL 8.0连接报错Public Key Retrieval is not allowed这是MySQL 8.0在国内环境下的一个经典报错根本原因是MySQL 8.0默认使用caching_sha2_password认证插件客户端连接时如果无法获取公钥就会报这个错。解决方案是在JDBC连接串后面加一个参数jdbc:mysql://localhost:3306/dormitory?useUnicodetruecharacterEncodingutf8useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai顺便提醒一下serverTimezone参数也很关键。不加这个参数连接时会报时区错误加上Asia/Shanghai之后时间和本地就对齐了避免出现“数据库存的时间比实际时间早8小时”的问题。5.3 Vue打包与部署经验开发完成之后需要把前端打包成静态文件由后端提供访问。Vue打包命令是npm run build打包完成后在项目根目录生成dist文件夹。把dist文件夹里的文件放到SpringBoot的src/main/resources/static目录下重新打包后端一个可运行的jar就完成了。访问http://localhost:8081时SpringBoot会自动把静态文件返回给浏览器。这里有一个很重要的坑Vue路由使用history模式时直接访问非根路径会404。原因很简单后端Tomcat不认识/student这种前端路由它只会找有没有对应的Controller。刷新一下页面就404。解决方案有两个。简单的是把Vue路由改成hash模式地址栏会多个#号刷新不会404。或者在后端写一个转发配置把所有非API路径都转发到index.htmlController public class PageForwardController { RequestMapping(/{path:[^\\.]*}) public String forward() { return forward:/index.html; } }因为路由是前端处理的后端只需要在任何未知路径时把index.html返回给浏览器Vue拿到之后会根据URL匹配到对应的页面组件。5.4 几个容易忽略的实操细节数据库连接一定要加useSSLfalse本地开发时如果你没给MySQL配置SSL证书不加这个参数可能导致连接被拒绝很多人卡在这。前端页面不要只做英文或拼音命名路由路径虽然建议用英文但页面标题、菜单名、按钮文字这些用户能看到的地方一定要写中文这是产品体验的基本要求。时间字段在数据库里用datetime类型在实体类里用LocalDateTime但注意前端拿到的是格式化后的字符串提交给后端时也要能自动转回去。如果发现格式对不上检查一下Jackson配置和前端组件的时间格式是否一致。备份数据库这是一个非常朴素但重要的建议。开发过程中随时可能改错数据、删错表尤其到了答辩前夜一个误操作可能让整个演示崩掉。定期用mysqldump导出备份成本几乎为零收益巨大。6. 答辩与扩展经验分享最后分享几个实践经验。第一个是答辩演示前务必把典型操作练熟。面试官或者老师很可能让你现场演示“入住一个学生然后展示床位变化”。这种联动效果的背后是房间已住人数的实时统计后台需要把入住逻辑的事务处理好演示才能顺畅。演练时建议走一遍完整业务闭环新建宿舍楼、新建房间、添加学生、办理入住、提交报修、处理报修、办理退宿整套流程跑通比任何讲解都有说服力。第二个是说明设计思路比堆功能更重要。面试官问“为什么这样设计”“换一种方案会怎样”比问“做了几个页面”更能体现水平。比如学生和房间用关联表而不用外键字段比如为什么选JWT而不是Session比如为什么MyBatis用#{}而不用${}——这些问题提前想清楚回答的时候会从容很多。第三个是这个项目后续的扩展空间。如果想把项目做得更完整可以加这两块一是加一个学生端小程序或H5页面学生可以自助查看自己的入住信息、在线提交报修二是加数据可视化模块用ECharts展示各楼栋入住率、各学院住宿人数分布等统计图。这两块并不复杂但会让项目的完整性和亮点明显提升。做系统这件事本质上是把一个真实场景中的业务流程理顺、落地。宿舍管理系统虽然只是一个经典的管理类项目但它把全栈开发的常规问题都包含了一遍。把这套逻辑吃透后面再做其他管理系统基本就是换皮换表的问题了。
返回列表