作为一个长期折腾前后端分离项目的人,我把手头这套SpringBoot + Vue + MyBatis + MySQL 的小区物业管理系统完整梳理了一遍,从数据库表设计到后端接口落地,从前端页面联调到服务器部署,所有源码和操作步骤都整理出来了。这套系统不算花哨,但胜在结构干净、贴近实际业务,非常适合拿来学习前后端分离项目的完整链路,或者直接作为毕业设计、课程设计的底子。
如果你目前正处于“会写单机Demo但没跑通过一个完整前后端分离项目”的阶段,这篇文章能帮你把整条线串起来;如果你已经有实战经验,里面的表设计思路、权限控制和Nginx部署细节也值得扫一眼,有些坑确实是用时间换来的。
1. 项目定位与整体设计思路
先交代一下这套系统到底做了什么。小区物业管理系统的业务其实非常固定,绕不开几个核心场景:业主信息管理、房屋档案管理、收费管理(物业费、停车费、水费代收)、报修工单处理、公告通知发布,以及后台的用户和权限管理。我做的这个版本把这些模块全部囊括进去了。
1.1 物业管理系统到底需要管理什么
很多初学者拿到这类题目容易把系统做成“增删改查大杂烩”,看起来功能齐全,实际上经不起推敲。我重新梳理业务时确定了五个核心域:
- 业主域:业主基本信息、家庭成员、车辆信息、入住状态
- 房屋域:楼栋、单元、房号、面积、朝向、装修状态、房屋与业主的关系
- 收费域:物业费单价、抄表记录、缴费单、欠费统计,这是整个系统最核心的业务
- 工单域:业主报修、派单、维修进度、完工回访
- 系统域:用户账号、角色、菜单权限、登录日志
这套划分不是拍脑袋定的,它直接影响数据库设计。一张房屋表和一张业主表之间必须有明确的关系,收费记录必须关联到具体房屋,工单必须关联到业主和维修工,业务流转才不会乱。
1.2 为什么要做前后端分离而不是传统单体
有一次我带一个朋友接手一个老旧的物业管理系统,前后端代码混在JSP页面里,改一个按钮要翻半天标签,接口调用逻辑散落各处。后来我们推倒重来,用前后端分离架构重新实现,开发效率明显提升,最大的变化体现在三点:
第一,前端的Vue页面通过Axios调后端REST接口,页面渲染和数据处理完全解耦,后端工程师不需要关心HTML标签,前端工程师也不需要碰Java代码。第二,前后端可以并行开发,我定义好接口文档后,前端就按Mock数据自己跑起来了。第三,部署灵活,后端只提供API服务,前端构建成静态文件丢给Nginx托管,后续做小程序端、移动端都只需复用同一套接口。
1.3 技术选型:SpringBoot+Vue+MyBatis+MySQL的理由
选这套组合没有悬念,它已经是目前Java后端 + 前端分离项目最主流、资料最多的搭配。
- SpringBoot负责提供RESTful API服务,内置Tomcat,打包成JAR直接运行,不用额外配置容器
- MyBatis负责数据库操作,SQL由自己掌控,复杂的收费统计、多表关联查询写起来非常顺手
- MySQL负责数据存储,免费、稳定、社区活跃,物业管理系统这个体量用MySQL绰绰有余
- Vue负责前端页面构建,配合Element UI组件库和Vue Router、Pinia,搭建管理后台速度非常快
有朋友问过我为什么不选MyBatis-Plus,我的答复是:这个项目我故意用了原生MyBatis,目的是把XML映射文件、动态SQL、分页插件全部过一遍。等你理解了MyBatis底层的执行逻辑,再上手MyBatis-Plus就是降维打击。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端工程:从零拆解SpringBoot核心实现
后端是整个系统的心脏,所有业务规则都在这里落地。我从工程结构、数据库设计、接口实现、安全控制几个层面逐个拆开讲。
2.1 数据库设计:核心表结构与字段说明
数据库我命名为property_manage,一共建了11张表,这里挑最关键的几张说一下设计思路。
业主表owner:主键id、姓名、手机号、身份证号、性别、入住时间。注意身份证号要加唯一索引,后面业务里经常用来查重。
房屋表house:主键id、楼栋编号、单元号、房号、建筑面积、户型、装修状态。房屋和业主的关系放中间表owner_house,因为现实中存在一套房多个业主、一个业主多套房的情况,直接在外键字段上硬关联迟早出问题。
收费表charge以及charge_detail:前者记录某房屋某期应收费用(物业费、公摊费、停车费等),后者记录具体费用项、金额、缴费状态、缴费时间。缴费记录只需要对外暴露一个“总金额”和“状态”,细分项留在detail表里,报表统计时再JOIN进来。
报修表repair:工单号、报修人、联系电话、房屋id、故障描述、报修时间、指派人、处理状态、完成时间、评价内容。工单状态我用一个tinyint字段存:0待派单、1处理中、2已完成、3已取消,不要用字符串,存储和查询都省。
设计这套表的过程中我最大的心得是:金额字段一律用decimal(10,2),绝对不要用float。物业费这种涉及钱的数据,用浮点数存会出现0.1+0.2不等于0.3的问题,月底对账的时候会让你怀疑人生。
2.2 SpringBoot工程结构与启动类配置
后端工程我用Maven管理,Java版本选的8,SpringBoot版本选的2.7.x。为什么不选最新的SpringBoot 3.x?因为我用的很多开源组件和教程都基于2.x,遇到问题方便搜索;而且Java 8配合2.7.x在稳定性上久经考验,这个项目不需要追求版本号新。
工程目录按业务分包,清晰到不用看注释就能找到类:
text复制com.property.controller
com.property.service
com.property.mapper
com.property.entity
com.property.config
com.property.common
启动类加上Mapper扫描注解:
java复制@SpringBootApplication
@MapperScan("com.property.mapper")
public class PropertyApplication {
public static void main(String[] args) {
SpringApplication.run(PropertyApplication.class, args);
}
}
配置信息全部放application.yml里,数据库连接用Druid连接池,加上SQL日志打印。初学阶段强烈建议把mapper日志级别设为debug,能直接看到MyBatis生成的SQL和传入的参数,排错效率翻倍。
yaml复制spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/property_manage?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 你的密码
type: com.alibaba.druid.pool.DruidDataSource
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.property.entity
configuration:
map-underscore-to-camel-case: true
logging:
level:
com.property.mapper: debug
记得URL里必须带上serverTimezone=Asia/Shanghai,不然MySQL 8.x会报时区错误——这是我早期踩过的第一个大坑。
2.3 MyBatis接口映射与动态SQL实战
Mapper接口和XML文件的配合是整个后端中最微妙的部分。我举收费统计的查询作为例子,这个SQL涉及三张表关联,同时要按条件动态拼接。
Mapper接口:
java复制public interface ChargeMapper {
List<ChargeVO> selectChargeList(@Param("houseId") Integer houseId,
@Param("status") Integer status,
@Param("startTime") String startTime,
@Param("endTime") String endTime);
BigDecimal sumTotalByStatus(@Param("status") Integer status);
}
XML文件核心片段:
xml复制<select id="selectChargeList" resultType="com.property.entity.ChargeVO">
SELECT c.id, h.building_no, h.unit_no, h.room_no,
c.total_amount, c.charge_period, c.status, c.create_time
FROM charge c
LEFT JOIN owner_house oh ON c.house_id = oh.house_id
LEFT JOIN house h ON oh.house_id = h.id
<where>
<if test="houseId != null">
AND c.house_id = #{houseId}
</if>
<if test="status != null">
AND c.status = #{status}
</if>
<if test="startTime != null and startTime != ''">
AND c.create_time >= #{startTime}
</if>
<if test="endTime != null and endTime != ''">
AND c.create_time <= #{endTime}
</if>
</where>
ORDER BY c.create_time DESC
</select>
动态SQL里最容易翻车的细节是
2.4 JWT认证与后端接口权限控制
管理系统的接口不能裸奔,我用JWT实现了无状态登录认证。流程很简单:用户提交账号密码,后端校验通过后生成一个带有效期的token返回前端;前端后续请求在请求头带上Authorization: Bearer token;后端通过拦截器校验token并解析用户身份。
拦截器的核心逻辑:
java复制public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
response.setStatus(401);
return false;
}
try {
Claims claims = JwtUtil.parseToken(token.replace("Bearer ", ""));
request.setAttribute("userId", claims.get("userId"));
request.setAttribute("role", claims.get("role"));
return true;
} catch (Exception e) {
response.setStatus(401);
return false;
}
}
权限控制上我用了简单但有效的做法:自定义@RequireRole注解,标注在Controller方法上,拦截器里解析出角色后判断是否拥有访问权限。这个方法比接入Spring Security轻量得多,也更容易理解。如果你接手了一个没有认证模块的JavaWeb项目,想快速升级出登录鉴权能力,这套思路直接照搬就好。
3. 前端工程:Vue3 + Element Plus的页面架构
前端我用的Vue 3 + Vite + Element Plus + Pinia。很多老教程还停留在Vue 2和webpack,但新项目没必要抱旧技术不放了,Vue 3的composition API配合Vite的开发体验明显更好,启动速度、热更新速度都不是一个量级。
3.1 环境准备与项目初始化
Node.js版本建议用18以上,npm和pnpm都行。创建项目:
bash复制npm create vite@latest property-web
cd property-web
npm install
npm install element-plus axios vue-router pinia
安装完成之后,把不需要的HelloWorld组件清掉,建好views、router、store、api、utils这几个目录。项目骨架一定要事先搭利索,不然后续每加一个页面都手忙脚乱。
Vite的联调配置是绕不开的一步,开发环境下前端跑在5173端口,后端跑在8080端口,直接请求必跨域。我在vite.config.js里配置代理:
javascript复制export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})
这样前端请求/api/login就会被代理到http://localhost:8080/login,开发阶段根本不需要在后端做跨域处理。等生产部署时再用Nginx做同样的代理,一套思路贯穿到底。
3.2 路由设计、动态菜单与 Pinia 状态管理
物业系统的页面分成两类:登录页和主布局页。主布局内部通过嵌套路由承载各个业务页面。
路由守卫是关键,没有token直接跳转登录页:
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token');
const whiteList = ['/login'];
if (whiteList.includes(to.path)) {
next();
} else if (!token) {
next('/login');
} else {
next();
}
});
动态菜单的实现思路是:登录成功后,后端返回当前用户的菜单列表(从数据库菜单表按角色查询),前端根据列表动态生成侧边栏。Vue Router的addRoute方法可以动态注册路由,这就能实现不同角色看到的菜单和可访问页面完全不同。
用户状态我用Pinia管理,登录成功把用户信息、token、角色存进store,配合localStorage持久化,刷新页面后从localStorage恢复状态,不用反复登录。
3.3 Axios二次封装:拦截器统一处理token和错误
业务代码里不能到处写axios请求,统一封装是必须的。我的api/request.js核心片段:
javascript复制const service = axios.create({
baseURL: '/api',
timeout: 15000
});
service.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = 'Bearer ' + token;
}
return config;
});
service.interceptors.response.use(
response => {
const res = response.data;
if (res.code === 401) {
localStorage.clear();
router.push('/login');
return Promise.reject(new Error('未授权'));
}
if (res.code !== 200) {
ElMessage.error(res.msg || '请求失败');
return Promise.reject(new Error(res.msg));
}
return res;
},
error => {
ElMessage.error(error.message || '网络异常');
return Promise.reject(error);
}
);
这样封装的直接收益是:业务代码里三行就能完成一次请求和状态处理,后端返回的提示信息自动弹出,401自动跳登录页。我见过很多新手每个页面都重复写一遍响应拦截逻辑,页面一多代码冗余得没法看。
3.4 核心业务页面实现思路
收费管理页面是这套系统的重头戏。页面分三块:顶部是筛选条件栏,左侧是房屋树(按楼栋展开),右侧是收费记录表格。提交查询时把筛选参数传后端,返回的记录用el-table渲染,欠费记录用红色字体标记,点击“缴费”按钮弹窗确认后调缴费接口。
报修工单页面稍微复杂一点,因为涉及状态流转。前端用el-steps展示工单进度,每个状态对应一个时间节点。业主端提交报修表单,后台管理员看到待派单列表后选择维修工,维修工端看到的是自己名下的处理中工单,完成后回填处理结果。这里前端的核心是不同角色显示不同操作按钮,通过当前用户的角色字段判断v-if即可。
写前端页面时我的建议是:不要追求一次性把所有页面做完,先把路由、布局、登录、一个核心列表页(比如收费管理)完整跑通,再复制这个模式去开发其他页面。先跑通链路比铺开面积重要得多。
4. 完整部署流程:从Windows到Linux服务器
项目开发完成只是第一步,能部署到服务器上跑起来才算真正交付。我这次把部署流程在两套环境都过了一遍:Windows环境用于本地演示,Linux服务器用于正式上线。部署思路完全一致,就是后端跑一个JAR包,前端静态文件交给Nginx托管。
4.1 环境准备清单
部署前把以下东西准备好:
- JDK 8及以上(服务器上配置好JAVA_HOME)
- MySQL 5.7或8.0(注意字符集设置为utf8mb4)
- Node.js(仅构建前端时需要,构建完不依赖)
- Nginx(负责托管前端和反向代理后端)
MySQL的安装和初始化就不再展开说了,官网下载对应系统版本安装包,一直下一步即可。唯一要提醒的是root密码别搞太复杂然后自己都忘掉,我见过不止一个人在服务器上重置MySQL密码折腾半天的。
4.2 数据库初始化
把项目里的sql脚本上传到服务器,执行导入:
bash复制mysql -u root -p < property_manage.sql
导入完成后,务必要检查编码:
sql复制show variables like 'character%';
如果character_set_server不是utf8mb4,导入中文数据后全是乱码。最省事的方式是在my.cnf的[mysqld]节点下加上:
ini复制character-set-server=utf8mb4
collation-server=utf8mb4_general_ci
改完重启MySQL服务。
4.3 后端打包与启动
后端项目在本地或服务器的Maven环境打包:
bash复制mvn clean package -DskipTests
打包成功后target目录下生成property-server.jar。上传到服务器后启动:
bash复制nohup java -jar property-server.jar --spring.profiles.active=prod > property.log 2>&1 &
生产环境的数据库连接信息我放在application-prod.yml里,通过--spring.profiles.active=prod激活,这样不会把本地配置覆盖掉。启动日志里看到“Started PropertyApplication”即代表成功。
检查后端是否正常,最简单的方式是浏览器访问一个接口:http://服务器IP:8080/api/login。如果返回JSON而不是连接失败,基本就成功了。
4.4 前端构建与Nginx配置
前端构建命令:
bash复制npm run build
构建完成后dist目录里就是全部静态文件。把dist目录上传到服务器,放到比如/www/property-web下,然后配置Nginx:
nginx复制server {
listen 80;
server_name 你的服务器IP或域名;
location / {
root /www/property-web;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
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那条很关键,Vue是单页应用,前端路由切换时如果直接刷新页面,Nginx会找不到对应的物理文件从而返回404,加上try_files $uri $uri/ /index.html就能把不存在路径的请求全部指向index.html,交给Vue Router自己处理。这个配置值十块钱:没加它之前,用户刷新一个子页面直接白屏报404,加了之后一切正常。
改完配置重载Nginx:
bash复制nginx -t
nginx -s reload
4.5 联调验证清单
部署完成之后按这个清单逐项验证:
- 浏览器访问首页,能正常打开登录页面
- 输入管理员账号登录,能进入主界面且左侧菜单正常显示
- 打开业主管理页面,能看到数据库中的记录且中文不乱码
- 调一个能触发PDF或Excel导出的功能,验证文件下载正常
- 直接访问一个未登录态接口,确认返回401而没有暴露数据
- 手机访问服务器IP,确认页面在移动端也能正常打开
验证过程中如果发现后端接口访问缓慢,先看服务器负载和MySQL慢查询日志,绝大多数性能问题都出在SQL上,而不是代码本身。
5. 常见问题与实战排错实录
这一章我把自己实际部署和调试这套系统时遇到的典型问题整理成清单,连同排查思路一起写出来。如果你在跑这个项目时被某个报错卡住,先翻翻这里。
5.1 数据库连接失败的定位顺序
报错信息通常是“Cannot create PoolableConnectionFactory”或“Access denied for user”。处理顺序:
- 确认MySQL服务是否启动:systemctl status mysqld确认状态
- 确认用户名密码是否正确:直接在命令行用mysql -u root -p测试
- 确认URL中IP端口是否可达:telnet 127.0.0.1 3306
- 确认用户是否有远程连接权限:MySQL的root默认只允许localhost登录,如果后端和数据库不在同一台机器,需要授权
授权命令:
sql复制GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' IDENTIFIED BY '密码' WITH GRANT OPTION;
FLUSH PRIVILEGES;
新手最常见的坑就是把数据库密码里的特殊字符(比如@、$)直接写进application.yml,导致连接串被解析错误。解决办法是用SpringBoot的配置加密或至少把特殊字符做转义。
5.2 跨域问题的几种表现形态
开发阶段遇到跨域,优先检查Vite代理配置是否生效,注意代理只对开发服务器的请求生效,直接开浏览器访问前端地址然后请求后端接口是没有中间代理的。
生产阶段遇到跨域的典型表现是:页面正常打开,但登录请求被浏览器拦截,Network里显示CORS error。解决办法就是我在4.4里写的Nginx反代方案,后端代码里不要用@CrossOrigin,环境一多配置混乱。如果后端服务无法改动,也可以用全局CORS配置类统一处理,但这不是最优解。
5.3 Vue打包白屏的排查清单
前端npm run build成功,但打开页面白屏,按这几个项目逐一排查:
- Vite的base配置:如果静态资源放在子路径下,需要在vite.config.js里设置base:'./',否则资源路径会变成绝对路径/asset/xxx,找不到文件
- 浏览器Console报错:查看具体报错信息,通常能看到加载不了某个js文件
- Nginx的root配置路径是否指向dist目录,确认Nginx里location /实际指向的目录下有index.html
- 检查服务器返回的index.html内容,确认里面引用的JS路径能否拼接成有效URL
Vite base配置是最高频的坑,本地跑得好好的,一上服务器就白屏,八成是这个问题。
5.4 MyBatis映射不生效与查询结果为null的处理
如果返回的实体类属性全是null,但数据库里明明有值,最可能的原因是数据库下划线字段和Java驼峰属性映射失败。我在application.yml里开了map-underscore-to-camel-case,但有个前提:你的数据库字段命名规范必须是下划线风格(如create_time),实体属性必须是驼峰风格(如createTime)。如果不匹配,要么在SQL中用AS重命名,要么在resultMap里逐一映射。
另外XML里写字段时注意MySQL关键字冲突,比如enter_time带time的关键字问题不大,但status、order这类建议都加反引号保险。我遇到过一次查询报SQL语法错误,把SQL粘到Navicat里一执行立刻看到关键字标红,才知道order需要加反引号处理。
5.5 连接池耗尽与线程阻塞问题
系统运行几天后突然接口全部超时,连数据库没有任何响应,这种情况我在一个旧项目上踩过。排查步骤:
- 看后端日志有没有SQL执行超时或连接获取超时的异常
- 检查Druid连接池监控面板,看活跃连接数是否跑满
- 检查MySQL的max_connections配置
- 看是否存在大量慢SQL阻塞线程
对症下药:慢SQL加索引、连接池调大初始和最大连接数、接口开启事务后确保finally释放连接。特别注意事务方法内不要直接吞掉异常不抛出去,否则Spring的事务传播机制不会回滚,连接也不会及时归还连接池。
写在最后的小建议
整套系统从设计、编码到部署,我前前后后折腾了不少时间,最终沉淀下来这么一套完整可跑的代码。我个人的体会是:学习前后端分离项目,最忌讳的是光看不练,或者只跑通某一个环节。你把数据库建好、后端接口跑通、前端页面能登录、服务器上能访问,这一整条链路走下来,你收获的东西是死记硬背再多面试题都替代不了的。
如果你准备拿这套系统做二次开发,优先建议在收费模块加一个“一键生成催缴单”功能,以及在报表模块做多维度的欠费统计分析,这两个方向最能体现系统的业务价值。后面我也会把二次开发中涉及的核心功能缝补出来,持续分享到这里。
最后再分享一个小技巧:部署完成后,把后端启动日志、前端构建命令、Nginx配置整理成一个部署备忘文档放在项目里,下次迁移环境直接照着手册执行,十分钟搞定,远比临时翻聊天记录有效率。
