拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于SpringBoot+Vue的智慧公寓管理系统开发实战

简介一套基于Java、Spring Boot、Vue和MySQL的产业园区智慧公寓管理系统毕业设计项目集完整源码、数据库脚本和论文文档于一体面向需要完成毕业设计、课程设计或期末大作业的计算机专业学生也适合希望快速搭建公寓管理平台的开发者。系统覆盖用户注册登录、公寓信息管理、租赁合同、费用缴纳、报修服务、访客管理等功能模块界面简洁、操作流畅项目已获导师指导并通过可直接用于实际场景、课程答辩或学习参考。资源包共925个文件以Java后端、Vue前端组件、JavaScript脚本、HTML页面、CSS样式为主另含SQL数据库脚本、XML配置、图标与界面图片以及论文Word文档整体35.31MB结构清晰便于按需复用核心代码和参考前后端交互设计。当前已有73人学习下载项目经严格调试配合MySQL8.0、IDEA、Maven和Navicat可顺畅运行无需修改即可启动。1. 产业园区智慧公寓管理系统到底在做什么产业园区里最典型的场景是一大片员工公寓配着几十栋楼管理员手里攥着 Excel 登记入住退房月底对着水电表底数手工算账。基于 Java SpringBoot Vue MySQL 的智慧公寓管理系统本质上就是把这套流程数字化房间状态实时可见、合同到期自动预警、水电读数录入后自动出账单。这类项目放到毕业设计里评估的从来不是算法难度而是数据模型是否完整、业务闭环是否走通。下面按这套组合把核心模块拆开讲表怎么建、接口怎么给、页面怎么接最后用数据验证系统扛得住翻页。2. 系统架构与数据表设计SpringBootVueMySQL 的分层方案2.1 前后端分离架构与开发环境代理先定技术栈版本这是后面所有代码的前提。后端用 SpringBoot 2.7.x MyBatis-Plus 3.5.xJDK 用 8 或 11前端用 Vue 2.7 Element UI数据库用 MySQL 8.0。这套组合的优点是资料多、组件全IDEA 里用 Spring Initializr 勾好 Web、MySQL 和 Lombok 依赖就能把 springboot 项目拉起来前端用脚手架初始化并装上页面常用的依赖vue create smart-apartment-front cd smart-apartment-front npm install element-ui axios echarts vue-router --save这条命令一次性装齐了表格表单类的 Element UI、请求库 Axios、图表库 ECharts、路由 Vue Router。这套系统不需要引入 vuex页面状态用路由参数和组件内 data 就能维持。前后端分离开发时最烦的是跨域常见做法是让前端开发服务器把 /api 开头的请求代理到后端 8080浏览器始终只和 8081 打交道// vue.config.js module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这里没有配置 pathRewrite因为后端接口路径本身就带 /api 前缀两层保持一致反而好排查changeOrigin 必须为 true否则后端收到的是 8081 的 Host 头。生产环境不用这个代理把npm run build出来的 dist 拷进 SpringBoot 的src/main/resources/static再执行mvn clean package -DskipTests打出单个 jar 就能跑完整个系统这也是毕业设计演示最省事的部署方式。2.2 核心数据表的职责划分与 ER 关系管理系统能不能讲清楚取决于表划分。这个题目至少需要八张表职责和关联关系如下表名核心字段职责sys_userusername, password, role登录账号与角色tb_buildingbuilding_no, name楼栋基础信息tb_roomroom_no, building_id, floor_num, area, rent_price, status房间与状态tb_tenantname, phone, id_card住户档案tb_contractroom_id, tenant_id, start_date, end_date租约合同tb_meterroom_id, water_kwh, elec_kwh, read_date水电表读数tb_billroom_id, bill_month, water_amount, elec_amount, total月度账单tb_repairroom_id, type, description, status报修工单房间表和合同表是整个系统的业务枢纽房间状态由合同的起止时间驱动账单由水电表读数驱动报修工单挂在房间上。每新增一张业务表先问它跟 tb_room、tb_contract 的哪个字段关联对不上就说明设计有问题。房间表是最容易被反复改结构的DDL 建议这样写CREATE TABLE tb_room ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, room_no VARCHAR(20) NOT NULL COMMENT 房间编号如 A-3-502, building_id BIGINT NOT NULL COMMENT 所属楼栋ID, floor_num TINYINT NOT NULL COMMENT 楼层号, area DECIMAL(8,2) NOT NULL COMMENT 建筑面积单位㎡, rent_price DECIMAL(10,2) NOT NULL COMMENT 月租金单位元, status TINYINT NOT NULL DEFAULT 0 COMMENT 0空置 1已入住 2维修中 3预留, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, UNIQUE KEY uk_room_no (room_no), KEY idx_building_status (building_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT房间信息表;status 用 TINYINT 而不是 VARCHAR是为了配合统计页的 GROUP BY 和筛选下拉框金额和面积一律 DECIMAL不能用 DOUBLE账单类系统对精度要求是硬性的。联合索引 idx_building_status 对应后台最常见的查询“某栋楼某状态下的房间”单查 room_no 走唯一索引两个方向都覆盖。2.3 application.yml 与初始化数据后端连 MySQL 的配置集中在 application.ymlMySQL 8 必须用 cj 驱动并带上时区参数server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/smart_apartment?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root mybatis-plus: configuration: map-underscore-to-camel-case: true # 自动映射下划线字段 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境打印SQLserverTimezoneAsia/Shanghai 缺了的话JDBC 驱动会用系统默认时区入库时间经常差 8 小时。map-underscore-to-camel-case 让 room_no 自动映射成 Java 的 roomNo省掉大量 XML resultMap。log-impl 配成 StdOutImpl开发时每条 SQL 和参数都打到控制台排查比 debugger 更直接答辩还能现场展示执行日志。初始化脚本只需要插入一个管理员账号-- 默认管理员账号 INSERT INTO sys_user (username, password, real_name, role) VALUES (admin, MD5(123456), 管理员, ADMIN);密码存 MD5 只是演示方便校验时把前端口令转成 MD5 再比对。正式项目建议换成 BCrypt论文里写了“密码加密存储”答辩前就得把 MD5 替换成 BCrypt否则评委追问实现细节时容易露馅。3. SpringBoot 后端实现登录鉴权与公寓管理接口3.1 JWT HandlerInterceptor 的最小登录鉴权这套系统最常见的做法不是引入完整 Spring Security而是用 jjwt 生成 token 加一个 HandlerInterceptor 拦截器。原因很现实Spring Security 的过滤链配置复杂答辩时很难三句话讲清楚而 JWT 加拦截器的方式可以把“token 里放什么、过期多久、拦截哪些路径”三点讲圆。token 的生成与解析集中在 JwtUtilComponent public class JwtUtil { Value(${jwt.secret}) private String secret; public String createToken(Long userId, String username) { // 24小时后过期 return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret) .parseClaimsJws(token.replace(Bearer , )) .getBody(); } }secret 放在 application.yml 的jwt.secret属性里。HS256 是对称签名密钥一旦泄露任何人都能伪造 token。过期时间设 24 小时管理员一天内频繁操作不需要重新登录如果要做“记住我”再单独发一个 7 天的 refresh token。注意 claims 里的小整数会被 jjwt 反序列化成 Integer业务方法里要取回 Long 时先用 Number 转一次。登录接口校验通过后把 token 返回给前端前端存到 localStorage后续每个请求带 Authorization 头由拦截器统一校验Component public class JwtInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equals(request.getMethod())) { return true; // 预检请求直接放行 } String token request.getHeader(Authorization); try { Claims claims jwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\登录状态已失效\}); return false; } } }OPTIONS 请求是浏览器跨域预检直接放行否则前端所有带自定义头的请求都会被拦在 401。解析失败统一返回 401 而不是 500前端响应拦截器收到 401 后跳回登录页形成闭环。拦截器要注册进 WebMvc 配置并明确放行登录接口Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/**) .excludePathPatterns(/api/auth/login); // 登录接口不需要token } }登录接口本身再用总数和密码比对各查一次注意 MyBatis-Plus 的 QueryWrapper 会自动参数化不要自己拼 SQL 字符串。3.2 房间管理接口分页查询与参数校验房间管理是这套系统最核心的 CRUD接口设计成五组后续页面都围绕它们展开方法路径参数说明GET/api/room/pagecurrent, size, status, roomNo分页条件查询GET/api/room/{id}路径 id房间详情POST/api/roomJSON 实体新增房间PUT/api/room/{id}JSON 实体修改房间DELETE/api/room/{id}路径 id删除房间分页接口用 MyBatis-Plus 的 LambdaQueryWrapper 动态拼条件RestController RequestMapping(/api/room) public class RoomController { Autowired private TbRoomMapper roomMapper; GetMapping(/page) public Result page(RequestParam(defaultValue 1) long current, RequestParam(defaultValue 10) long size, RequestParam(required false) Integer status, RequestParam(required false) String roomNo) { // 条件为空时不参与拼接 LambdaQueryWrapperTbRoom wrapper new LambdaQueryWrapper(); wrapper.eq(status ! null, TbRoom::getStatus, status) .like(StringUtils.hasText(roomNo), TbRoom::getRoomNo, roomNo) .orderByAsc(TbRoom::getRoomNo); PageTbRoom page roomMapper.selectPage(new Page(current, size), wrapper); return Result.success(page); } }eq 和 like 的第一个布尔参数是技巧所在参数为 null 时整条条件不参与拼接前端不传 status 就查全部状态不传 roomNo 就查全部房间。这里的 StringUtils 是 Spring 的org.springframework.util.StringUtils不是 Apache Commons 那个。like 走参数绑定MyBatis 内部转成 PreparedStatement不存在拼接注入。size 建议在 service 层做上限校验超过 100 直接拒绝防止有人一次拉全表。新增和修改的校验要区分场景新增时 roomNo 必填且不能重复重复时会触发唯一索引抛 DuplicateKeyException接口层要捕获并翻译成“房间编号已存在”修改时已入住的房间不能直接改回空置必须先走退租接口结算账单规则写在 service 而不是 controller让后续导出、报表复用同一套逻辑。3.3 水电账单生成事务与 BigDecimal 的正确用法水电费计算是这个系统里最有业务含量的功能。每月初管理员录入每个房间的水电表当前读数系统用“本月读数减上月读数”算出用量、乘以单价生成账单Service public class BillServiceImpl implements BillService { Override Transactional(rollbackFor Exception.class) public TbBill generateBill(Long roomId, BigDecimal waterReading, BigDecimal elecReading) { TbRoom room roomMapper.selectById(roomId); if (room null || room.getStatus() ! 1) { throw new BizException(房间不存在或当前未入住无法出账); } TbMeter last meterMapper.selectLatest(roomId); // 最近一次抄表记录 BigDecimal waterUsed waterReading.subtract(last.getWaterReading()); if (waterUsed.compareTo(BigDecimal.ZERO) 0) { throw new BizException(本月读数小于上月请核对抄表数据); } // 同一房间同一账期只能有一张账单防止重复出账 String month LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy-MM)); if (billMapper.existsByRoomAndMonth(roomId, month)) { throw new BizException(该房间本月账单已生成); } TbBill bill new TbBill(); bill.setRoomId(roomId); bill.setBillMonth(month); bill.setWaterUsed(waterUsed); bill.setWaterAmount(waterUsed.multiply(WATER_PRICE)); bill.setElecUsed(elecReading.subtract(last.getElecReading())); bill.setElecAmount(bill.getElecUsed().multiply(ELEC_PRICE)); bill.setTotal(bill.getWaterAmount().add(bill.getElecAmount())); billMapper.insert(bill); return bill; } }Transactional 默认只回滚 RuntimeExceptionrollbackFor Exception.class 让检查型异常也触发回滚读数异常和重复出账抛出的 BizException 才能把已执行的 insert 一起撤掉。BigDecimal 的 compareTo 不能换成 equals因为 0 和 0.00 的 scale 不同equals 会返回 false金额计算先乘单价再相加每步保留两位小数最后 total 是精确金额而不是浮点误差累加。selectLatest 底层是ORDER BY read_date DESC LIMIT 1配合 tb_meter 的 (room_id, read_date) 联合索引取最近读数才有性能保证。建议给 tb_bill 加唯一键 (room_id, bill_month)让数据库兜底防重。4. Vue 前端实现路由、请求封装与数据可视化4.1 路由设计与登录守卫前端项目在 src/views 下按模块组织页面login、dashboard、room、contract、tenant、bill、repair、statistics。路由用懒加载首屏只加载当前页面import Vue from vue import Router from vue-router Vue.use(Router) // hash模式打包到后端static目录后刷新不会404 export default new Router({ mode: hash, routes: [ { path: /login, component: () import(/views/login/Login.vue) }, { path: /, component: () import(/layout/Layout.vue), redirect: /dashboard, children: [ { path: dashboard, component: () import(/views/dashboard/Dashboard.vue) }, { path: room, component: () import(/views/room/RoomList.vue) }, { path: statistics, component: () import(/views/statistics/Statistics.vue) } ] } ] })mode 用 hash 而不是 history打包后放进后端 static 目录时history 模式刷新非首页路径会 404hash 模式没有这个问题。() import是 Webpack 动态导入每个页面独立 chunk首屏不用下载整个项目。路由守卫在跳转前检查 token未登录一律踢回登录页router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next({ path: /login }) } else { next() } })token 过期时后端返回 401由后面 axios 拦截器负责清 token 并跳转这里只处理“本地根本没登录”的首次访问两层职责不重叠。4.2 Axios 封装与房间列表页对接所有请求都走同一个 axios 实例统一加 token、统一处理错误码import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { if (response.data.code 401) { localStorage.removeItem(token) router.push(/login) } return response.data }, error { Message.error(error.response?.data?.msg || 网络异常请稍后重试) return Promise.reject(error) } ) export default servicetimeout 设 10 秒超出后 axios 直接抛错以后做账单导出这类长任务时单独放宽到 30 秒不能一刀切。401 处理放在响应拦截器而不是每个页面重复写这是管理后台的通用约定。房间列表页拿到数据后交给 Element UI 表格和分页组件渲染对应关系如下页面元素Vue 组件绑定的数据房间列表el-tablepage.records状态筛选el-selectfilter.status分页器el-paginationcurrent, size, totalel-pagination 的 current-change 事件触发 loadRoom 重新请求页面里维护的 current 和 size 与后端分页参数一一对应。这里有个新手常踩的坑修改 size 后 current 要重置回 1否则还停留在旧页码上后端按新的 size 已经算不出那一页了。4.3 ECharts 统计页与打包路径统计页是答辩时的门面入住率饼图和月度营收折线图配两张卡片就够。ECharts 挂载在普通 DOM 上import * as echarts from echarts mounted() { this.loadStats().then(() { // 容器渲染完成后再初始化 const chart echarts.init(this.$refs.occupancyChart) chart.setOption({ tooltip: { trigger: item }, legend: { bottom: 0 }, series: [{ type: pie, radius: [40%, 70%], data: [ { name: 已入住, value: this.stats.occupied }, { name: 空置, value: this.stats.empty }, { name: 维修中, value: this.stats.repair } ] }] }) window.addEventListener(resize, () chart.resize()) }) }init 必须等容器渲染完成后执行放在 mounted 里且用 $refs 取 DOM 最稳放进 created 时组件还没挂载ref 是 undefined。radius 配成环形饼图中间空心区域可以放 html 覆盖层显示总房间数。统计接口在 SQL 层用 COUNT GROUP BY 聚合而不是前端把全量数据拉下来再数10 万条数据的场景两者性能差一个数量级。打包部署时还有一个高频问题注意本地开发正常、部署后白屏或样式丢失多半是打包资源路径错了。vue.config.js 里补一行publicPath: ./让 js/css 走相对路径拷进 SpringBoot 的 static 目录后才能正常加载。5. 答辩前用 MySQL 数据验证分页与索引效果5.1 递归 CTE 生成 10 万条房间数据论文写“支持大数据量分页”和现场演示“翻到最后一页只有几十毫秒”是两种说服力。演示前先把数据铺满MySQL 8 的递归 CTE 一条语句就能造出 10 万条记录。先调大递归上限-- MySQL 8 递归深度默认只有 1000先调大 SET SESSION cte_max_recursion_depth 100000; WITH RECURSIVE nums AS ( SELECT 1 AS n UNION ALL SELECT n 1 FROM nums WHERE n 100000 ) INSERT INTO tb_room (room_no, building_id, floor_num, area, rent_price, status) SELECT CONCAT(A-, n), 1, CEILING(n / 8), 35.50, 1200.00, n % 3 FROM nums;不调大这个会话级变量语句会在第 1001 次迭代直接报错终止。n % 3把 status 循环成 0、1、2覆盖空置、已入住、维修中三种情况CONCAT(A-, n)保证 room_no 不撞唯一索引。灌完记录再执行一次ANALYZE TABLE tb_room更新统计信息优化器才能给出准确的执行计划。这张表建好之后把房间、合同、账单之间的关联数据也按同样的方式铺一些演示时切页面不会露怯。5.2 用 EXPLAIN 与耗时日志验证优化效果翻到最后一页是分页查询的最差情况先看执行计划-- 深翻页取第 99991 到 100000 条 EXPLAIN SELECT id, room_no, status FROM tb_room ORDER BY room_no LIMIT 10 OFFSET 99990;正常情况 type 显示 indexkey 是 uk_room_noExtra 里没有 Using filesort说明排序直接扫唯一索引的 B 树不会把 10 万行读进内存排序。这时再在 RoomController 里用 System.currentTimeMillis() 包住 selectPage把耗时打进日志long start System.currentTimeMillis(); PageTbRoom page roomMapper.selectPage(pageReq, wrapper); log.info(分页耗时: {} ms, 总数: {}, System.currentTimeMillis() - start, page.getTotal());答辩现场先查第一页、再翻最后一页对比两条日志的毫秒数。SQL 日志、耗时日志、EXPLAIN 三个结果互相印证这套验证只覆盖有序分页和等值过滤如果业务里出现任意深度的跳页查询生产环境要换成基于上一页最后一条 id 的 keyset 游标分页那套写法和这里的 LIMIT OFFSET 优化点完全不同。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门