全栈实战:基于SpringBoot+Vue的产业园区智慧公寓管理系统是怎样炼成的
每年毕业季和项目实训期,SpringBoot+Vue这套经典组合都会迎来一波搜索高峰,但很多同学卡在同一个地方:源码下载了一堆,要么版本对不上跑不起来,要么代码逻辑看不懂不知道怎么改。今天分享的这套产业园区智慧公寓管理系统,算是我手里比较完整、也踩过不少坑之后梳理顺畅的一套全栈项目。它用SpringBoot做后端、Vue做前端、MyBatis负责数据持久化、MySQL存数据,覆盖了公寓管理系统中常见的房源管理、租客入住、合同签订、水电抄表、费用收缴、报修处理这些核心业务。无论你是做毕业设计,还是打算把这套代码改成商用项目的骨架,这篇文章都能帮你少走很多弯路。
我先把话放前面:这套系统不是那种只有几个增删改查页面的Demo,它是按照产业园区公寓管理的真实业务场景来设计的。租客从看房、签约、入住到退房,管理员从抄表、催费到报修派单,每一条业务流程都有对应的代码实现。文章会从技术选型、数据库设计、后端接口实现、前端页面联调这四个维度展开,把源码里那些“你看了一眼就头疼”的部分拆开揉碎讲清楚,包括环境怎么配、表怎么建、接口怎么调、页面怎么渲染,以及我在实际运行中遇到的那些报错和解决办法。
1. 系统整体设计与技术选型思路
1.1 为什么还是SpringBoot+Vue这套经典组合
先说后端。SpringBoot之所以在JAVA项目里地位稳固,是因为它把Spring家族那套复杂的XML配置几乎全部自动化了。传统Spring项目光是配置数据源、事务管理器、扫描注解就要写一大堆XML,而SpringBoot通过自动配置机制帮你把默认方案都准备好,你只需要在配置文件里写上数据库地址和账号密码就能跑起来。对于公寓管理系统这种业务以CRUD为主、但又需要事务控制的场景,SpringBoot的JdbcTemplate和声明式事务已经足够用了。
前端选Vue也很好理解。Vue的核心是组件化开发,一个页面拆成多个Vue组件,每个组件负责自己的渲染和交互逻辑,想改某个区域的样式不会牵扯到其他部分。配合Vue Router实现页面跳转,Vuex或Pinia管理全局状态(比如当前登录用户信息),再用Element UI做现成的表格、表单、弹窗组件——这套组合在中小型后台管理系统的开发效率上,确实比原生JS手动操作DOM高太多了。
MyBatis的定位则是介于纯JDBC和全自动ORM框架之间的一个选择。它不像Hibernate那样帮你把对象和表关系完全映射好,而是让你自己写SQL,控制力更强。公寓管理系统里经常出现复杂的多表联查,比如查询某个房间时既要关联楼栋表、又要关联租客合同表、还要带上水电费余额,这种场景用MyBatis的SQL映射文件来写非常灵活,SQL优化也方便。
1.2 智慧公寓系统到底“智慧”在哪里
先给没接触过这类项目的人扫个盲:产业园区智慧公寓管理系统和我们平时住的普通小区物业管理是完全两个概念。产业园区里的公寓住的是企业员工、实习学生或者短期驻场人员,流动性大、入住退房频率高、水电费计算周期短,考勤和安全管控要求也更高。所以这套系统里必须要有几个关键模块。
一是房间管理维度,不是简单列个房间列表就完了,而是要把每个房间的状态管理起来。空闲、已入住、保洁中、维修中、已锁定,这五种状态直接决定前台人员能不能把房间租出去。房间收费维度要看押金、月租金、电表底数、水表底数,这些数据在入住时必须初始化,退房时要根据新读数计算差价。
二是合同管理,要把租客和房间绑定起来。合同里要有起止日期、月租金、付款方式(月付季度付年付)、押金金额,还要有违约条款。系统在租客登录后只能看到自己的合同信息,管理员则能看到全部合同,并且要能在合同快到期时自动提醒续签或退房。
三是缴费和催费流程,这是公寓运营方的核心关注点。系统里每个月生成水电费账单,租客在手机端或前台交费后,财务人员登记收款记录。逾期未缴的要自动进入催费列表,管理员可以一键群发短信或站内信提醒。
四是报修流程,租客在App或H5端提交报修单(选择房间、填写问题描述、上传图片),系统自动派单给维修工,维修工接到工单后上门处理,完成后上传处理结果和照片,租客确认后工单关闭。
听起来功能点很多,但拆到代码层面,本质上还是那几套常规操作:单表CRUD、多表关联查询、状态字段流转、文件上传和一两个定时任务。技术难度并不算太高,难点在于业务流程的梳理和所有模块之间的数据联动。
1.3 代码结构怎么组织才容易被看懂
很多同学下载源码以后最大的困惑是不知道从哪个文件开始看。这里我先花点篇幅把后端和前端各自的项目结构讲清楚,后面的内容都基于这个结构。
后端项目的包结构我习惯这样划分:
com.plant.apartment ├── config // 配置类:跨域处理、拦截器、Swagger等 ├── controller // 接口层:接收前端请求,调用service ├── service // 业务逻辑层:处理具体业务规则 ├── serviceImpl // service接口的实现 ├── mapper // MyBatis的mapper接口(DAO层) ├── entity // 实体类,对应数据库表 ├── dto // 数据传输对象,封装前端传来的复杂参数 ├── vo // 视图对象,封装要返回给前端的数据结构 ├── utils // 工具类:日期处理、金额计算、结果封装等 ├── exception // 全局异常处理 └── common // 通用返回结果类、分页结果类等前端Vue项目的标准结构是:
src ├── api // 所有请求接口的封装,按模块拆分文件 ├── assets // 静态资源:图片、样式文件 ├── components // 公共组件:分页组件、弹窗组件等 ├── router // 路由配置文件 ├── store // 状态管理(Vuex/Pinia) ├── views // 页面组件,按功能模块建文件夹 ├── utils // 工具函数:请求拦截器、格式化函数等 └── App.vue // 根组件这套结构不是随便拍的,每一层都有明确职责:controller只负责接收请求和返回结果,不写业务逻辑;service只处理业务规则,不直接操作数据库;mapper只做SQL映射。前端这边,api目录统一管请求,views目录管页面展示,组件之间通过props和事件通信——这样一来,拿到源码后想找一个功能点,顺着“页面 -> api -> controller -> service -> mapper”这条线一路找下去就行了,非常清晰。
2. 数据库设计与核心表结构解析
2.1 建表思路:从业务对象到数据模型
数据库设计这一步,决定了一个项目后续开发是顺风顺水还是到处踩坑。智慧公寓系统的核心业务对象其实就那么几个:管理员(系统用户)、公寓楼栋、房间、租客(公寓住户)、合同、账单、报修工单、系统菜单权限。围绕这些对象,我把表拆分成了八张核心表和两张辅助表。
先说用户相关的:admin_user表存管理端账号,字段有主键id、用户名、密码(存的是BCrypt加密后的hash值)、真实姓名、手机号、角色id、创建时间;app_user表存租客(住户)账号,字段类似,但多了一个关联字段room_id用于标记这个租客当前住在哪个房间,还多了一个status字段标记租客状态(正常/退租/拉黑)。
房间相关的表是核心中的核心:building表(楼栋表)字段有楼栋编号、楼栋名称、楼层数、房间数;room表(房间表)是最复杂的一张表,字段包括:
- 所属楼栋id(关联building表)
- 房间号(比如A栋101)
- 房间类型(单间/一室一厅/两室一厅/四人间/六人间)
- 建筑面积
- 月租金
- 押金
- 当前状态(0空闲 1已入住 2保洁中 3维修中 4已锁定)
- 水表底数(入住时抄的数)
- 电表底数
- 朝向、楼层、装修情况等属性字段
这里我要多说一句:水表底数和电表底数一定要放在room表里,而不是放在合同表里。因为在退房的时候,即使合同已经结束了,下一个租客入住时也要读取这个房间当前的水电表读数。如果放在合同表里,查到的是一个历史快照,没法直接作为新合同的初始值。
合同表contract的主要字段有合同编号、租客id、房间id、起租日期、到期日期、租金标准、押金金额、付款方式、入住时水表读数、入住时电表读数、合同状态(执行中/已到期/已退租/已作废)。注意合同表要有unique约束在room_id和status之间做联合限制,防止同一个房间同时存在两份执行中的合同。这个约束在并发场景下特别重要——两个管理员同时给同一个空闲房间办理入住,如果没有唯一约束就会产生脏数据。
账单表bill是财务模块的主力表,字段包括账单编号、租客id、合同id、费用类型(水费/电费/房租/物业费/维修费)、费用月份、金额、本次水表读数、本次电表读数、上期读数、账单状态(未缴/已缴/已逾期/已退费)、缴费方式和缴费时间。设计账单表时我强烈建议加上一个唯一索引(租客id + 费用类型 + 费用月份),这样同一个月的水电费不会因为用户连续点了两次生成按钮而重复生成。
报修表repair的字段包括工单编号、报修人id、房间id、报修类型(水/电/门锁/家电/网络)、问题描述、报修图片(存图片URL)、紧急程度、状态(待派单/处理中/已完成/已取消)、派单人、维修人、完成时间、处理结果说明。这块在设计上要注意的是:维修工这个角色我建议直接复用admin_user表,用角色字段区分是管理员还是维修工,不要单独建一张维修工表。道理很简单——维修工可能同时也是管理员,单独建表会把人搞成两个账号,维护起来很痛苦。
另外还有几张辅助表:notice表(系统公告,租客端首页能看到)、feedback表(投诉建议)、role和menu表(基于RBAC模型的权限管理)。菜单权限表在设计时用父子结构,parent_id为0的是顶级菜单,下级菜单通过parent_id关联,路由表里的path和component字段要和前端路由做映射,这样后端返回的菜单列表,前端拿到后可以直接动态生成导航菜单。
2.2 数据库初始化脚本与关键索引
源码里附带SQL文件,我在本地跑过一遍,核心表建完后数据量不大,但如果要应付几千间房的园区,索引设计还是要提前思考的。room表建议在building_id、status上建联合索引或单列索引,因为前台查询房间列表时通常是“按楼栋过滤+按状态过滤”;contract表要在room_id和status上建联合索引。bill表比较简单,一个账单状态索引就够用,但如果有按月份汇总的需求,建议在bill_month上建索引。
值得注意的一个细节是:所有涉及金额的字段统一用decimal(10,2)类型,不要用float或double。公寓管理场景下涉及押金、租金、水电费这些精确金额,而float在计算过程中会产生精度丢失,比如0.1+0.2算出来是0.30000000000000004,这在财务场景是不能接受的。至于日期字段,我用datetime,但推荐所有表都加上create_time和update_time两个通用字段,同时把update_time设置成ON UPDATE CURRENT_TIMESTAMP自动更新,这样排查数据问题时非常方便。
MySQL建表时还有一个容易踩的坑:表名和字段名尽量使用下划线命名法(snake_case),在Java实体类里用驼峰命名法(camelCase),然后开启MyBatis的map-underscore-to-camel-case配置让两者自动映射。如果不做这个映射,每次查询都要手写resultMap,代码量又臭又长。
3. 后端核心实现:SpringBoot + MyBatis + MySQL全链条
3.1 环境版本搭配:Java 8还是Java 17,MySQL 5.7还是8.0
先说一个最让人头疼的版本兼容问题。我看到网上很多同学因为springboot版本太高,项目连启动都启动不起来。为什么?因为SpringBoot的版本和Java版本、Tomcat版本、MyBatis Starter版本之间是有很强依赖关系的。以下是我实测稳定运行的版本组合:
- JDK 1.8(即Java 8)
- SpringBoot 2.7.18(直接拉满2.x的最后一个版本,不用再上更高了)
- MyBatis SpringBoot Starter 2.3.1
- MySQL Connector/J 8.0.33
- MySQL数据库8.0.27(本教程也兼容MySQL 5.7.x)
- Maven 3.8.x
- Node.js 16.x或18.x,Vue CLI 5.x
我为什么特意停在SpringBoot 2.x而不推荐直接上SpringBoot 3.x?因为SpringBoot 3.0之后强制要求Java 17起步,JDK从8升到17带来的最大影响是javax包变成了jakarta包——老代码里所有的import javax.servlet、javax.validation都要全局替换成jakarta。MyBatis相关的starter兼容性也需要重新适配。对于学习项目或非大型团队的生产项目,用SpringBoot 2.7.18是非常稳健的选择,大部分教材、网课、开源项目都是这个版本。
MySQL这里多说一句。MySQL 5.7和8.0核心语法差别不大,但新版MySQL对时区、字符集、认证方式(caching_sha2_password)的处理让连接串必须额外指定serverTimezone和allowPublicKeyRetrieval参数。我用的连接串是:
jdbc:mysql://localhost:3306/apartment_db? useUnicode=true&characterEncoding=utf8&useSSL=false &serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true注意连接串里useSSL=false一定要带上,否则MySQL 8默认启用SSL握手,本地连接会报SSL连接警告甚至直接超时。字符集要用utf8mb4而不是utf8,因为utf8在MySQL里只支持三个字节,存不了某些生僻汉字和emoji表情。
3.2 后端项目启动配置:application.yml的关键设置
后端项目的application.yml配置区有个非常重要的坑:开发环境和生产环境要分开,至少用多环境配置(application-dev.yml和application-prod.yml)。开发环境用本地MySQL,生产环境用服务器上的数据库,这样一套代码在不同环境只需要通过spring.profiles.active切换配置文件就行。
具体配置项按我的习惯是:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/apartment_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 你自己的密码 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:/mappers/*.xml type-aliases-package: com.plant.apartment.entity configuration: map-underscore-to-camel-case: true cache-enabled: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging: level: com.plant.apartment.mapper: debug有两点值得展开说明。第一,mybatis.configuration.log-impl这里配置成StdOutImpl,作用是在控制台打印每一条SQL语句和参数,联调时排查问题极其有用。生产环境建议关掉或改成log4j2输出日志文件。第二,map-underscore-to-camel-case: true开启后,查询结果里数据库的create_time字段会自动映射到实体的createTime属性,省去大量手写resultMap的体力活。
3.3 统一结果封装与全局异常处理
后端接口不可能只返回裸数据字典,必须有一层统一的结果封装类。我写的Result对象结构是:
public class Result<T> { private Integer code; // 200成功,500失败 private String message; // 提示消息 private T data; // 数据负载 public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }为什么一定要做这一步?因为前端Vue的请求拦截器要统一判断code是否为200,如果不是就直接弹错。如果没有统一封装的类,每个接口返回的JSON结构都不一样,前端就要为每个接口写一套错误处理逻辑,既是重复劳动,也容易漏掉错误分支。统一结果封装之后,前端只需要在axios响应拦截器里写一次公共逻辑即可。
同样重要的还有全局异常处理器。我用@RestControllerAdvice注解实现一个全局异常捕获类,把业务异常(比如“房间已被锁定不可办理入住”)、参数校验异常、SQL异常统一转成Result格式返回给前端,避免项目自带的错误页或者默认错误信息把技术细节(SQL语句、类型转换错误等)直接暴露给调用方。这个习惯在新手项目中很容易被忽视,但它对接口的健壮性影响很大——比如前端传了一个不符合格式的日期参数,你没做异常兜底,后端直接返回500和一堆堆栈信息,前端拿到后根本不知道该提示用户什么。
3.4 MyBatis核心:Mapper接口与XML映射的配合套路
在实际项目中,MyBatis的使用主要分两种情况。简单的CRUD——比如根据主键查询、插入一条记录、更新某个字段——我用注解方式写在Mapper接口上,简单直观。复杂查询——包括多表关联、动态条件拼SQL、分页查询——我写在XML映射文件里,用动态SQL标签(if、where、foreach、choose)灵活拼装。
举一个实际例子:房间列表查询接口。这个接口要支持按楼栋筛选、按状态筛选、按房间号模糊搜索,同时要关联building表把楼栋名称带出来。如果用注解写在接口里,SQL会变得非常长且难维护,所以我选择写在XML里。
<select id="selectRoomList" resultType="com.plant.apartment.vo.RoomVO"> SELECT r.*, b.name AS buildingName FROM room r LEFT JOIN building b ON r.building_id = b.id <where> <if test="buildingId != null"> AND r.building_id = #{buildingId} </if> <if test="status != null"> AND r.status = #{status} </if> <if test="roomNo != null and roomNo != ''"> AND r.room_no LIKE CONCAT('%', #{roomNo}, '%') </if> </where> ORDER BY r.building_id, r.floor, r.room_no </select>这里最关键的是 标签配合 标签的动态SQL能力。前端传了哪个参数,就拼上哪个条件;不传参数,就不拼。注意NULL判断和空字符串判断要写全,比如roomNo如果是空字符串,若只判断roomNo != null,可能拼出一个LIKE '%%'的查询,把所有房间都查出来,这在功能上没错,但性能上是一种浪费。
再讲一下MyBatis的分页实现。新手最容易踩的坑是手写LIMIT分页——算好offset手动拼到SQL里。这样实现也能用,但存在几个问题:翻页时总条数要单独查一次,COUNT聚合SQL和列表SQL往往条件不完全一致,容易导致总页数不准。我推荐用PageHelper插件,只需要在Service层查询前调用PageHelper.startPage(pageNum, pageSize),然后正常执行列表查询,插件会自动把SQL追加LIMIT语句,并且通过AOP自动执行COUNT查询、填充分页参数到分页结果对象中。pom.xml里加一行依赖就能用:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.6</version> </dependency>使用PageHelper时有一个很重要的限制:PageHelper.startPage()必须在要分页的查询语句之前调用,而且要确保这一条查询不是返回多条查询结果的方法。如果在一个循环里先调了startPage再查,后一次查询会覆盖前一次的分页条件,结果就会异常。我见到过一个真实的bug:用户在Service里先调startPage分页查房间列表,紧接着又调了一次内部查询获取楼栋数量,结果第二条SQL也被自动追加了LIMIT,导致返回的数据量比预期少了很多。
3.5 登录认证与权限控制:从JWT到拦截器
公寓管理系统有管理端和租客端两套登录入口,权限模型不能混在一起。我的做法是:管理端登录成功后返回一个JWT令牌,租客端登录成功后也返回一个令牌,两个端用不同的JWT secret连续串生成,用于防止管理端令牌在租客端复用。
JWT令牌本质上是一段经过签名加密的字符串,前端登录成功后把它存在localStorage里,之后每次请求通过axios拦截器在请求头里携带。后端用一个拦截器(HandlerInterceptor)拦截所有需要登录的请求路径,从请求头参数或Header里取出token,用私钥验证签名、检查过期时间,验证通过后把用户信息存到ThreadLocal里,供后续Service层获取当前用户。
这里要强调一个安全细节:JWT里只存用户id和用户名,绝对不要存密码、手机号等敏感信息。因为JWT的payload部分只是Base64编码的明文,任何人都能解码出来,签名只保证内容没被篡改,不保证内容不可见。另外,如果用户被管理员禁用拉黑,JWT本身是无法立刻失效的——因为它的校验只看签名和过期时间,不查数据库。如果需要这种即时失效能力,就要在Service里做二次校验:每次请求时根据用户id到数据库查一次状态,只有status为正常才继续放行。
这套设计在毕业设计答辩时也是一个加分项——说明了狭义JWT的局限性,以及项目的改进方案,面试官或答辩老师一听就知道你没停留在“会用框架”的层面。
4. 前端Vue实现:从环境配置到页面联调
4.1 Vue环境配置:node、npm、脚手架一次搞定
前端这边第一道坎是Vue环境安装与配置。很多同学下载了源码,在命令行里npm install装依赖,结果报一堆错,大概率是Node.js版本和项目依赖的版本不匹配。比如Vue 2项目搭配Webpack 4,如果Node版本太高(比如Node 18),node-sass编译就会直接失败;反过来,Vue 3项目里的vite对Node版本也有最低要求。
我推荐以下配置:
- Node.js 16.20.0或18.x LTS版本
- npm使用国内镜像加速安装依赖:npm config set registry https://registry.npmmirror.com
- Vue CLI 5.x(创建Vue 3项目:vue create apartment-admin)
- 如果项目依赖里出现了node-sass的编译错误,建议把node-sass替换成sass(dart-sass),版本选择1.63以上
项目依赖安装完以后,会有一个非常经典的坑:不同机器上npm install安装出来的依赖树可能不一致,导致“在我电脑上能跑,在你电脑上就报错”的诡异问题。解决办法是项目里保留package-lock.json文件,它锁定了所有依赖的精确版本。拿到源码的同学不要删除这个文件,第一次安装依赖后能用,后续装新增依赖也要正常commit这个文件。
4.2 Vue项目结构:路由、请求拦截、状态管理
前端项目的src目录结构我前面已经列过,这里把各个部分的职责再讲具体一点。
首先说路由。管理端路由分为静态路由和动态路由两部分:静态路由就是登录页、404页这些不需要权限就能访问的页面;动态路由是登录成功后根据后端返回的菜单列表生成的,不同角色看到的路由不同,从而实现了“超级管理员能看到系统设置菜单,普通管理员看不到”的效果。说是动态路由,实现起来其实就是登录后调用后端接口获取菜单列表,把返回的component字段映射到前端对应组件,用router.addRoute()动态添加到路由表里。
然后是请求拦截器。所有API请求统一走一个axios实例,在request拦截器里给headers加上token,在response拦截器里统一处理code不等于200的接口报错。响应拦截器里要加一个401状态码的全局处理——如果后端返回401,说明token过期或被篡改,直接清空本地存储的token并跳转回登录页。
还有一个很实用的东西:极简的状态管理。如果项目用的是Vue 3,我会用Pinia管理全局状态,比如当前用户信息、侧边栏折叠开关状态、全局的通知数量等。如果项目是Vue 2,用Vuex。注意不要把请求到的数据都塞进State里,State只保存跨页面共享的数据,页面局部数据放组件自己的data或ref里就好,否则代码很难梳理。
4.3 页面渲染与后端数据联调:拿房间管理举例
前端页面里最典型的一个模块是房间列表页。页面效果大概是:顶部几个筛选条件(选择楼栋、选择状态、输入房间号搜索),中间一个表格展示房间数据(房间号、楼栋、类型、面积、月租金、当前状态、操作按钮),底部一个分页器。点击“新增房间”按钮弹出一个表单弹窗,填写房间信息后提交保存。
我们来看一下这个页面和后端接口的数据流向。前端在mounted或onMounted钩子里调用房间列表API:
export function getRoomList(params) { return request({ url: '/api/room/list', method: 'get', params: params }); }后端Controller接收请求后调用Service层,Service通过PageHelper分页查询房间列表,返回的数据结构是{records: [...], total: 100, current: 1, size: 10}。前端拿到records渲染表格,total用来控制分页组件的总页码数。
表格里每一行有一个“状态”列,这里要注意展示层的枚举映射。后端存的是0、1、2、3、4这样的数字状态,前端不能直接显示数字,而是映射成中文标签和对应的Tag颜色,比如0显示为“空闲”用绿色、1显示为“已入住”用蓝色、2显示为“保洁中”用橙色、3显示为“维修中”用红色。这种枚举映射在前端可以用一个statusMap常量对象维护。
联调阶段最烦的是接口路径对不上。比如前端请求的是/api/room/list,后端却把Controller映射成了/api/room/queryList,就404了。为了保证前后端接口一致性,我强烈建议后端在开发时引入Swagger(springfox或springdoc),接口文档自动生成,前端照着Swagger里的路径和参数调试,比互相问要省事得多。源码里如果没集成Swagger,你可以手动加依赖并配置一个SwaggerConfig类,只是十几行代码的事。
4.4 前后端联调跨域问题:三个解决思路
前后端联调必然遇到跨域问题。前端跑在8081端口(Vue CLI默认),后端跑在8080端口,两个端口不同,浏览器出于同源策略会拦截请求。解决跨域有三种思路:
第一种,后端加跨域配置。在SpringBoot里写一个WebMvcConfigurer配置类,重写addCorsMappings方法,允许来自http://localhost:8081的请求。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:8081") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }第二种,前端用代理转发。在Vue项目根目录的vue.config.js里配置devServer.proxy,把/api开头的请求代理到后端8080端口。这种方式的好处是浏览器看到的请求是同源的,不会触发跨域问题,也是我最推荐的方式:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } };第三种,通过Nginx反向代理,把前端静态文件和后端接口放在同一个域名路径下。这通常是生产环境的做法,开发阶段用前两种就够了。
这里要提醒一个常见的报错:配置了前端代理后依然报跨域,大概率是前端请求根本没有走代理,而是直接写了完整地址http://localhost:8080/api/xxx。记得用相对路径/api/xxx,代理才会生效。
5. 常见问题与排查技巧实录
5.1 数据库连接类报错:时区、SSL、驱动一个都不能少
我拿这套系统在几台电脑上跑过,数据库连接这一环最容易出问题。最常见的错误是连接超时或“Could not create connection to database server”。排查思路首先看MySQL服务有没有启动,Windows上可以打开服务管理器确认MySQL80服务是运行状态;其次看账号密码是否正确;最后把连接串换成我刚才给的完整版本。如果用的是MySQL 8.0,出现“Public Key Retrieval is not allowed”错误,就要在连接串上加上allowPublicKeyRetrieval=true,这与MySQL 8的caching_sha2_password认证方式有关。
另一个让人挠头的问题是驱动类加载不到。pom.xml里如果引入的是mysql:mysql-connector-java但版本号只在 里由SpringBoot统一管理,会默认引入一个高版本驱动。有些老项目的driver-class-name还写着com.mysql.jdbc.Driver,这个类在MySQL 8驱动里已经改名成com.mysql.cj.jdbc.Driver了。如果你用的连接池是Druid,配置里不仅要写对driverClassName,还要确认Druid版本不能太老(建议1.2.x以上)。
5.2 MyBatis启动失败:XML映射路径、实体类别名、依赖冲突
MyBatis相关的报错花样也很多。最常见的是启动时就报“Invalid bound statement (not found)”,意思是Mapper接口找到了,但对应的XML映射没有加载。这时候去pom.xml或构建配置里看,target/classes目录下是否存在mappers文件夹和其中的XML文件——如果源码里XML放在src/main/java目录下面,而Maven默认的构建路径没有把XML文件复制到classes目录,就会导致这个问题。解决办法是在pom.xml的build节点里增加resource配置,把src/main/java下面的.xml文件也打包进去。
如果不做这个配置,还有一种临时方案是偷懒点:把XML全部移到src/main/resources/mappers目录下,然后在application.yml里把mapper-locations配置成classpath:/mappers/*.xml。这是最稳的做法,也符合大多数项目的规范。
另外一种报错是“Type interface ... is not known to the MapperRegistry”。导致这个错误的原因通常是@MapperScan注解没有扫到Mapper接口所在的包,或者Mapper接口没有标记@Mapper注解。项目里我给每个Mapper接口都加了@Mapper注解,同时在启动类上加了@MapperScan注解,双保险就不会漏了。
如果项目里同时引入了MyBatis官方starter和mybatis-plus的starter,还可能因为两个框架的beanName冲突导致启动报错。这种问题最恶心,因为你单独看某个依赖好像都没问题,但两个同时存在就会打架。解决办法就是只保留一个,像这套系统我只用纯MyBatis,功能完全够用,没必要额外引入mybatis-plus。
5.3 PageHelper分页失效:为什么只返回了所有数据
PageHelper分页失效是一位同学在开发中遇到的真实问题:页面要每页10条,结果接口返回了50条全部数据。排查后发现他把PageHelper.startPage()写在了一个Service方法里,但这个方法里有一条循环语句,循环内部又调用了另一个Mapper查询,PageHelper拦截到了循环里最后一条SQL,并且因为循环多次执行startPage的覆盖,最后的分页SQL没有作用在真正的列表查询上。
解决方法是把startPage调用放在列表查询方法的紧前面,并确保这一方法只有一条查询逻辑。如果确实需要在列表查询前做其他前置操作,也要保证前置操作里没有其他Mapper查询,或者在查询前重新调用startPage。
还有一个小细节,开启sql日志后(mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl),能看到PageHelper自动拼接的COUNT查询语句。检查总记录数是否和列表数据一致,就能判断PageHelper是否正常工作。我这里遇到过一种情况:列表SQL里带了DISTINCT去重,而COUNT语句用的是默认的COUNT(*),两边统计结果不一致。遇到这种情况就要在Mapper里面写两条SQL,一条countQuery传给PageHelper做总条数统计,一条普通查询列表数据。
5.4 Vue页面白屏或组件加载失败:路由配置和大小写问题
前端常见的坑是页面白屏,打开控制台F12能看到路由相关的警告。首当其冲是路由的component路径写错了,比如视图文件是views/room/index.vue,路由里却写成了views/room/list.vue,加载不到组件自然白屏。还有一个非常隐蔽的问题:在Linux服务器上部署前端时,文件名大小写敏感,而windows开发机上大小写不敏感,所以本地跑得好好的,放到服务器上就报模块找不到。
Vue项目如果想提高安全性,前端代码里所有自定义组件的导入路径、路由路径、api路径都要保持大小写一致,不要一会儿RoomList,一会儿roomlist。
还有一个Element UI组件的坑:Dialog弹窗打开后表单校验时报“Cannot read properties of undefined (reading 'validate')”,多半是表单的ref没有正确绑定。Form组件里要写ref="formRef",提交按钮里调用this.$refs.formRef.validate()。如果ref写错了或用在了错误节点上,就是undefined。这个问题我刚接触Element UI时也踩过,后来养成习惯:表单弹窗打开后,校验前先打印一下this.$refs.formRef是否存在。
5.5 部署上线:SpringBoot打Jar包,Vue打包到Nginx
最后说下部署。后端部署很简单,Maven里执行mvn clean package -DskipTests,就会在target目录生成一个jar包,服务器上放好jdk后执行java -jar apartment-admin.jar即可。注意不要把数据库密码明文写在配置文件里提交到Git仓库,生产环境建议用环境变量注入(比如用${DB_PASSWORD}占位符)。另外,Vue前端打包成静态文件,执行npm run build,输出到dist目录,把这个目录扔到Nginx的html目录下,配置nginx.conf把/api开头的请求反向代理到后端端口,就完成了整套系统的上线部署。
Nginx的配置片段我贴一下,方便有部署需求的同学直接抄:
server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # 解决Vue路由刷新404 } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }注意try_files那行配置,它保证访问任意前端路由(比如直接刷新/room/list这个URL)时都回退到index.html,由Vue Router接管页面渲染,不然Nginx默认会返回404。这是很多前后端分离项目中“刷新页面就白屏”的经典问题来源,部署前一定要确认这一段配置写上了。
6. 基于这套源码的二次开发方向建议
源码拿到手别急着完事,可以在此基础上做一些扩展,对个人能力和毕业设计都是加分项。我列几个我认为性价比高的方向。
第一个方向是增加园区门禁和设备管理。现实中智慧公寓都会对接智能门锁、人脸识别机、水电表集抄设备,这些设备通过MQTT或HTTP回调与系统交互。系统里可以加一个device表,存设备编号、类型、关联房间、在线状态、最后一次上报时间。租客入住后由管理员远程下发门锁密码或蓝牙钥匙,退房后密码自动失效。这一块业务对物联网交互和多表联查的要求比较高,写起来也有意思。
第二个方向是增加数据可视化大屏。园区运营方喜欢在管理大厅放一个大屏,展示园区公寓的入住率、水电消耗趋势、房间类型占比、当月收费金额等指标。数据来源就是之前统计好的bill和contract表——可以对当月缴费金额按月统计,用ECharts渲染柱状图、折线图、饼图,页面顶部几张大卡片放核心指标数字。这种大屏首页在毕业设计里非常出效果。
第三个方向是增加定时任务:合同到期提醒、生成月度账单、逾期账单催费短信发送。用SpringBoot内置的@Scheduled注解就能实现简单定时任务,不需要额外引入Quartz。注意定时任务里要考虑幂等性——比如生成月度账单的任务,如果上次跑失败了这次重跑,不能重复生成。解决方案是在bill表上建唯一索引,重复插入直接报DuplicateKey异常,用try-catch捕获然后跳过。
第四个方向是前端App化。园区员工的移动端体验很重要,但这套源码本身是Web端(管理端和租客端都通过浏览器访问)。如果要做成H5或小程序,后端接口完全可以复用,只需要新写一套移动端前端就行。Vue 3 + Vant或uni-app都是不错的选型。uni-app的优势是一套代码能同时编译到微信小程序和H5,适合没有原生开发经验的人快速出成果。
以我个人的实际体会来说,这套系统的代码层面并不存在高不可攀的技术壁垒,难点反而在业务细节。比如抄表算费时需要注意“阶梯水价”还是“统一水价”,押金退还是否扣除违约金,退房时要先把合同状态变更再释放房间——这些逻辑嵌套着写在一起时,千万要避免把大量if-else堆在一个方法里,尽量抽出独立的方法,每个方法只做一件事,后边排查问题才不至于晕头转向。
最后再分享一个技巧。如果你发现项目跑起来后,控制台光打印SQL却不显示传递的参数值,可以在application.yml里增加一个配置项:
logging: level: com.plant.apartment.mapper: debug打印日志的等级设为debug后,不仅能看到SQL语句和参数,还能看到每个查询的结果总条数。而如果你登录后发现自己页面数据全是空的,先别急着怀疑后端,打开浏览器开发者工具里的Network面板,看看接口响应到底返回了什么内容——我先打开控制台看报错,再看响应体数据结构,最后才从头到尾翻代码。这套排查顺序基本能解决大部分联调问题。