去年我去一个物业服务中心做信息化调研看到的真实场景比技术难题更让人头疼报修靠微信群接龙缴费记录散落在几十张Excel表格里房子一旦出租居住信息根本对不上。物业经理拉着我说他们缺的从来不是一个APP而是一套能把业主、房屋、工单、账单全部串起来的管理系统。后来我决定用Java生态里最经典的一套组合——SpringBootVue3MyBatis配MySQL数据库做一套前后端分离的智慧社区系统。从需求梳理到上线前后大约用了两个月这套源码目前已经跑在本地服务器上稳定运行。这篇文章把我完整的实现思路、数据库设计、后端分层、Vue3前端工程化、联调阶段踩过的坑、部署与性能优化的实测笔记全部整理出来。如果你想做类似的项目不管是毕设选题还是公司内部管理系统都可以直接拿来当参考蓝本。1. 智慧社区系统到底在解决什么问题三类用户的真实诉求1.1 业主端在线报修与缴费的闭环智慧社区这四个字听起来偏概念落到地面其实就是解决几个具体问题。最常见的第一条业主报修。传统模式下业主发现家里水管漏水要么打电话给物业前台要么在微信群里发消息然后等回复。电话占线、群消息刷屏都是常态更麻烦的是后续没人跟进业主不知道自己的工单到底处理到哪一步了。系统里我设计了一条完整的报修闭环业主在小程序端或者前端页面提交报修工单选报修类型、填问题描述、上传照片后台自动生成工单编号物业人员接单后更新处理状态业主随时能看到待接单—处理中—待验收—已完成的进度。验收环节尤其重要业主确认完成之后这个工单才算真正关闭而不是物业自己说完成就完成。缴费同样是高频场景。物业费、水费、停车费如果是线下催收物业人员要拿着纸质账单挨家挨户跑。我这里把房屋信息、业主信息和缴费账单关联起来业主在系统里就能看到自己名下所有待缴账单在线缴费之后状态自动更新物业端同步看到收款记录。1.2 物业端工单处理与账务管理的核心物业端实际上是整个系统里操作量最大的一段。保安、前台、维修工、财务不同角色需要不同的功能入口。我按照工作流程把物业端拆成几块工单中心查看所有报修、投诉、建议进行派单、处理、备注、回访。住户与房屋台账房屋基础信息、业主信息、租客信息统一维护一张表看清整个小区每套房子的状态。账单管理按月生成物业费账单记录每笔缴费支持导出和催缴标记。公告发布停水停电通知、社区活动通知编辑后一键发布到业主端。物业端设计的一个关键是待办优先。我特意做了工作台登录之后第一眼看到的是待接单数量、待处理工单、今日到期账单而不是一堆静态图表。系统是给物业人员用的如果让他们去一堆菜单里翻待办事项这个系统很快就会被嫌弃。1.3 管理层数据看板与权限体系小区物业经理或者业委会需要的是整体情况本月报修多少单、平均处理时长多久、物业费收缴率多少、投诉集中在哪些区域。这些数据在传统模式下要靠人手工汇总月底做一次已经是极限。我在管理端做了简单的统计看板报修趋势、缴费率、工单状态分布、房屋入住率。这些数据全部从业务表中实时聚合不需要额外的人工报表流程。权限体系用RBAC模型来实现业主、物业、管理员三种角色分别对应不同的菜单和数据范围避免出现普通业主登录后能看到全小区账单这种低级事故。1.4 技术选型为什么是SpringBootVue3MyBatis这套组合选型的时候我确实想过要不要直接用现成的低代码平台或者综合后台框架。最后放弃低代码的原因是后续定制空间太受限物业公司提的需求往往很具体低代码平台遇到复杂报表或者特殊业务字段经常要绕路。也考虑过Spring Cloud那套微服务方案对智慧社区这个体量来说完全是过度设计一个单体应用部署简单、排错方便运维成本低太多。SpringBoot负责后端接口快速开发、内嵌Tomcat、生态成熟在事务处理、安全认证、定时任务这些方面都非常稳定。MyBatis我用了很多年最大的优势是SQL可以自己控制复杂的多条件查询、连表统计、动态SQL写起来非常直观比JPA在复杂查询场景下更顺手。Vue3给前端带来了组合式API逻辑复用比Vue2时代清爽太多配合Vite的开发体验也确实快。这套组合对中小型管理系统来说属于不怎么需要犹豫的稳妥方案。2. 数据库设计一张表看懂社区业务的全部关系2.1 八个核心业务表的关系与关键字段数据库设计是整套系统的地基。我花了不少时间梳理业务关系最后核心表定下来八张用户表、房屋表、报修单表、缴费单表、公告表、投诉表、车位表、访客登记表。它们之间的关系用一句话说清楚用户和车位挂到房屋上报修、缴费、投诉、访客记录都是围绕房屋发生的业务单据。这样设计的好处是统计视角非常统一——按房屋维度可以查所有历史记录按时间维度可以查全小区的业务量。以最核心的两张表为例房屋表 house_info字段类型说明idbigint主键building_novarchar(10)楼栋号unit_novarchar(10)单元号room_novarchar(10)房号areadecimal(10,2)建筑面积owner_namevarchar(50)业主姓名owner_phonevarchar(20)联系电话owner_typetinyint1自住 2出租statustinyint0未售 1已入住 2空置报修单表 repair_order字段类型说明idbigint主键order_novarchar(32)工单编号house_idbigint关联房屋user_idbigint报修人repair_typetinyint1水电 2门窗 3公共设施contentvarchar(500)问题描述imagesvarchar(1000)上传图片URLstatustinyint0待接单 1处理中 2待验收 3已完成 4已取消handler_idbigint处理人handle_timedatetime接单时间handle_notevarchar(500)处理说明create_timedatetime提交时间finish_timedatetime完成时间两个细节值得说明。一是工单编号不要用自增ID直接暴露给前端我生成的规则是日期流水号比如20250612001既方便看、又避免别人通过ID遍历你的数据。二是图片字段存的是URL字符串而不是二进制图片本身上传到本地目录或者对象存储数据库只存路径这样表的体积不会失控。2.2 状态机与审计字段的设计细节业务单据最怕状态混乱。报修单的状态我设计了五档0待接单、1处理中、2待验收、3已完成、4已取消。每个状态之间的流转关系是固定的——业主提交后是待接单物业接单后变成处理中处理完标记待验收业主在系统里确认验收之后才是已完成。有一个边界情况要处理业主提交的工单物业一直不接单怎么办我加了一个超时提醒的定时任务超过24小时未接单的自动推送给管理员。审计字段我的做法是每张业务表统一带上create_time、update_time、deleted。前面两个字段记录数据的新增和修改时间排障的时候非常有用。deleted是逻辑删除标记默认0做删除操作时只把值改成1不真正物理删除。对社区业务来说缴费记录、投诉记录这些数据后续要查历史物理删掉再想恢复就麻烦了。索引规划这块我踩过一个典型的问题。前期数据量少没感觉等模拟数据录入两三万条之后按status和create_time筛选的时候明显变慢。原因很简单status字段区分度太低单独建索引效果有限。正确的做法是建联合索引(status, create_time)把筛选条件下推到索引层面实测查询时间从几百毫秒降到几十毫秒。3. 后端落地的核心SpringBootMyBatis分层与动态SQL实战3.1 后端包结构从Controller到Mapper的职责划分后端代码我坚持用经典的四层结构不搞花活。项目包名com.estate下面分 controller、service、mapper、entity、dto、vo、config、common 几个包。每一层的职责非常明确Controller接收请求参数、校验基础格式、调用Service、返回统一结果。Service业务逻辑的核心事务边界在这里控制。Mapper数据访问层只负责SQL和执行不写业务判断。Entity和数据库表字段一一对应的实体类。DTO接收前端传入的参数对象和Entity解耦。VO返回给前端的视图对象只包含页面需要展示的字段。一开始容易犯的错误是把DTO、VO、Entity当成一回事结果前端要什么字段就加什么字段久而久之Entity被塞进一堆乱七八糟的属性。我现在的规矩是Entity只管表结构VO只放前端要展示的内容中间用BeanUtils或者MapStruct做转换。3.2 Result对象与全局异常处理前后端分离之后接口返回的数据格式必须统一否则前端每个页面都要做不同的解析逻辑。我定义了一个通用的ResultT对象code状态码200成功其他为各种失败。message提示信息。data业务数据。所有Controller都返回这个结构。配合全局异常处理器我不用在业务代码里到处写 try-catch。比如数据校验失败抛BusinessException在RestControllerAdvice里统一捕获转成对应的Result返回。这样Service里逻辑足够干净出了问题也能在日志里一眼定位。3.3 MyBatis动态SQL复杂查询与分页的落地写法MyBatis最出彩的地方就是动态SQL。社区系统的查询条件非常灵活报修工单列表可能要按状态筛、按时间段筛、按楼栋筛、按关键字搜组合方式非常多。在XML里用where加if标签把条件拼起来代码可读性比在Java里玩字符串拼接好太多。一个典型的报修分页查询SQLselect idselectRepairPage resultTypecom.estate.vo.RepairVO SELECT r.order_no, r.repair_type, r.content, r.status, r.create_time, h.building_no, h.unit_no, h.room_no, u.real_name FROM repair_order r LEFT JOIN house_info h ON r.house_id h.id LEFT JOIN sys_user u ON r.user_id u.id where if teststatus ! null AND r.status #{status} /if if testkeyword ! null and keyword ! AND (h.building_no LIKE CONCAT(%, #{keyword}, %) OR h.room_no LIKE CONCAT(%, #{keyword}, %) OR r.order_no LIKE CONCAT(%, #{keyword}, %)) /if if teststartTime ! null AND r.create_time gt; #{startTime} /if if testendTime ! null AND r.create_time lt; #{endTime} /if /where ORDER BY r.create_time DESC /select几个容易被忽略的点。第一where标签会自动处理第一个条件前面的AND不需要额外写WHERE 11这种丑写法。第二和在XML里要转义成gt;和lt;否则解析会报错。第三CONCAT(%, #{keyword}, %)用参数绑定而不是直接把关键字拼进SQL这是防SQL注入的基本底线。分页我用的是MyBatis的分页插件PageHelper一行代码搞定物理分页PageHelper.startPage(pageNum, pageSize); ListRepairVO list repairMapper.selectRepairPage(query); PageInfoRepairVO pageInfo new PageInfo(list);注意PageHelper.startPage后面必须紧跟第一条查询语句中间插任何其他SQL都会导致分页失效这个坑我踩过不止一次。3.4 JWT登录认证与用户上下文登录认证我用了JWT方案。用户输入账号密码登录服务端校验成功后用密钥签发一个Token前端存在localStorage里后续请求通过Authorization头带上。后端写了一个拦截器统一校验Token校验通过后把用户信息放进ThreadLocal这样在业务代码里随时可以拿到当前登录用户的ID和角色。密码存储必须加密我用的BCrypt同一密码每次加密结果不同数据库被拖库也不能直接反推出明文。注册用户的时候加密存储登录校验时用BCryptPasswordEncoder.matches比对。4. Vue3前端工程化组合式API、权限路由与业务页面4.1 搭建ViteVue3Element Plus后台骨架前端我直接用Vite从零搭建没有套用现成的后台模板。原因是我觉得模板里的无关代码太多后期清理反而费劲。初始化命令很简单npm create vitelatest estate-admin --template vue cd estate-admin npm install npm install element-plus axios pinia vue-router目录结构按功能划分src ├── api # 所有接口请求封装 ├── assets # 静态资源 ├── components # 通用组件 ├── layout # 后台布局框架 ├── router # 路由配置 ├── stores # Pinia状态管理 ├── utils # 工具函数 ├── views # 页面组件Element Plus按需引入让打包体积小一些。我用的方式是借助vite的插件做自动导入在vite.config.js里配好unplugin-vue-components和unplugin-auto-import组件和API用的时候直接写不用手动import。4.2 Axios封装与Pinia状态管理Axios封装是前端工程质量的关键。我统一创建了一个实例配好baseURL和超时时间然后加两层拦截器。请求拦截器从localStorage里取Token有就加到请求头上。响应拦截器统一处理200之外的错误码401跳转登录页其他业务错误弹出提示。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 { const res response.data if (res.code 200) { return res } ElMessage.error(res.message) return Promise.reject(new Error(res.message)) }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } )Pinia我用来管理登录用户信息和权限菜单。登录成功之后把用户信息存到store里刷新页面时再从后端拉一次最新的用户资料保证状态不丢失。4.3 路由守卫按角色控制页面访问权限控制如果只靠后端接口校验前端页面还是能通过手动输URL访问体验很差。我在前端路由里给需要权限的页面加了meta.requiresAuth和meta.roles路由守卫里做两层判断没登录的跳登录页登录了但角色不对的跳401页面。router.beforeEach((to, from, next) { const token localStorage.getItem(token) const userStore useUserStore() if (to.meta.requiresAuth !token) { next(/login) return } if (token !userStore.userInfo) { userStore.fetchUserInfo().then(() { if (to.meta.roles !to.meta.roles.includes(userStore.userInfo.roleType)) { next(/403) } else { next() } }) return } next() })两个细节我踩过坑一是守卫里异步获取用户信息之后要记得return否则会执行到后面的next()导致逻辑混乱二是角色判断数字类型要保持一致后端返回的roleType是数字meta.roles数组里如果写字符串1用includes判断会永远等于false。4.4 一个业务页面的完整实现思路拿报修工单列表页举例。页面顶部是状态Tab下面是对应的表格和分页器。切换Tab时重新请求数据。这里有一个Vue3的小坑用reactive定义查询条件对象然后整体替换某个属性是没问题的但如果你尝试给post对象新增一个之前不存在的属性视图不会更新。所以查询条件我一开始就定义好所有字段function loadList() { loading.value true getRepairPage({ pageNum: query.pageNum, pageSize: query.pageSize, status: activeTab.value }).then(res { list.value res.data.records total.value res.data.total }).finally(() { loading.value false }) }列表操作区放了接单处理完成验收几个按钮按钮的显隐逻辑跟着当前行的status走待接单的显示接单处理中的显示处理完成待验收的显示验收。这样业务的流转在界面上是清晰可见的也不会出现业主和物业都能点同一按钮的情况。5. 前后端分离联调时躲不掉的几个坑5.1 跨域问题开发环境代理与生产环境Nginx前后端分离之后跨域是第一道坎。前端开发服务器跑在5173端口后端接口在8080端口域名都不一样浏览器的同源策略会直接拦截请求。开发阶段最简单的方案是在Vite里配置代理。在vite.config.js的server.proxy里把/api路径代理到后端地址浏览器眼里请求是同源的后端也不需要额外处理CORSserver: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }生产环境部署到Nginx之后同样在Nginx层做反向代理把/api/开头的请求转发到后端的jar服务。这里要记住一个细节没有配proxy_set_header Host的情况下后端拿到的Host是内网地址有些依赖Host做判断的逻辑会出错要显式加上location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }5.2 LocalDateTime序列化与前端显示的时间差前后端时间格式不一致的问题几乎每个项目都会遇到。后端Java8之后用LocalDateTime默认序列化出来的格式是一长串带T的比如2025-06-12T10:30:00。前端表格里直接显示这种格式既不美观用户也读不惯。我在application.yml里加了全局配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai这里还有个隐藏坑如果没配time-zone默认是GMT时区国内访问会看到时间差8个小时。另一个坑是前端Element Plus的日期选择器传参格式和后端不匹配。我统一在el-date-picker上加了value-formatYYYY-MM-DD HH:mm:ss保证传出去的字符串和后端解析的格式完全一致。5.3 字典状态码的前后端契约状态值在数据库里用的是数字前端展示要翻译成文字。比如报修状态0到4分别对应待接单、处理中、待验收、已完成、已取消。这个对应关系如果前后端各自维护一份很容易出现改了一端忘了另一端的情况。我的方案是把状态枚举写成一份共享的常量文件后端Java里定义枚举类前端也定义对应的常量对象然后接口文档里明确标注每个数值的含义。前端页面里用计算属性根据状态值去查字典文本不直接在模板里写死数字。后面如果要调整文案改一处就够不用满项目搜索数字。5.4 文件上传与体积限制报修功能允许传图片我在联调时遇到一个很经典的问题本地测试好好的部署到服务器之后一传图就报错返回413。排查半天发现是Nginx默认的client_max_body_size是1M超过1M的请求体直接拒收。解决方法是Nginx配置里显式调大client_max_body_size 20m;同时后端SpringBoot也要设置对应的multipart限制spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB还有一个前端配合的小细节压缩图片后再上传。我用canvas把超过1M的图片等比压缩到宽1200px以内再转成Blob提交既省流量又避免大图拖慢接口响应。6. 上线部署与性能优化给未来运维留一条活路6.1 部署清单前端dist、后端jar与MySQL系统开发完不能只在本地跑部署才是真正考验项目健壮性的环节。服务器我用的是一台Linux主机的经典组合Nginx SpringBoot jar MySQL全部用systemd管理。后端的部署步骤本地mvn package打jar包上传到服务器/opt/estate/目录。编写systemd服务文件让jar以后台守护进程方式运行开机自启、崩溃自动重启。MySQL建好数据库导入初始SQL脚本配置好账号和访问权限。systemd服务文件长这样[Unit] DescriptionEstate API Service Afternetwork.target [Service] ExecStart/usr/bin/java -jar /opt/estate/estate-api.jar Restarton-failure Userestate Groupestate [Install] WantedBymulti-user.target前端的部署更简单npm run build之后把dist目录整个扔到Nginx指定的静态目录里再把/api的反向代理配置到后端端口就完成。这里要注意的是前端路由用history模式的话Nginx必须配try_files $uri $uri/ /index.html;不然刷新子路由页面会404。数据库的备份我用crontab每天凌晨执行一次mysqldump保留最近7天的备份文件。社区系统的数据虽然算不上海量但真出问题的时候一张缴费记录表就是物业对账的凭证丢不得。6.2 索引与SQL优化从慢查询日志说起上线之后我做了一轮性能测试重点看接口响应时间。发现问题最多的是几个列表查询接口输入模拟数据之后响应时间明显变长。打开MySQL慢查询日志把执行时间超过1秒的SQL捞出来一看索引问题非常典型。pay_order表按status查询的SQL走了全表扫描因为status字段的区分度太低MySQL优化器认为建了索引也未必快干脆不用。解决办法是把条件改成(status, create_time)联合索引同时order by create_time也能直接命中索引。优化之后缴费列表接口从1.2秒降到60毫秒左右这个对比非常直观。另一个是分页的深翻页问题。PageHelper做limit 100000, 10这种分页时偏移量越深越慢因为MySQL要扫前10万行再丢弃。我遇到这个问题的场景是公告列表的旧数据查询方案是改成子查询SELECT * FROM notice_info WHERE id ( SELECT id FROM notice_info ORDER BY id LIMIT 100000, 1 ) ORDER BY id LIMIT 10;这样把大偏移量的扫描变成走索引的定位速度提升明显。6.3 MyBatis缓存和批量写入的取舍MyBatis的缓存分两级。一级缓存默认开启作用于同一个SqlSession内同一个查询条件会直接从缓存里拿结果。它在单体应用里几乎没有副作用但要注意在循环里执行查询时如果数据被修改过缓存可能拿到旧值。二级缓存默认不开启需要显式配置。我在系统里没有开二级缓存原因是社区系统的查询实时性要求高报修状态、缴费状态这些数据一变业主端应该立刻能看到最新结果。缓存一旦引入就要处理缓存失效问题复杂度上升和收益不成正比。如果你的场景是公告列表这类很少变动的数据可以考虑单独做缓存。批量写入方面我有一个小体会插入缴费账单这类数据如果一条条循环insert几百条数据能明显感觉慢。用MyBatis的foreach一次性拼接多条insert能快几倍。但要注意SQL有长度上限一批控制在100到200条比较稳妥别贪多。整套系统从设计到上线我最深的体会是技术本身没有多难真正花精力的是把业务状态和角色权限梳理清楚。SpringBootVue3MyBatis这套组合在智慧社区这个场景下开发效率、可维护性、部署成本都达到了一个很平衡的状态。如果你正在做类似的园区、后勤或者物业管理系统强烈建议先把数据库关系表和状态流转图画清楚再动手写代码能省掉后面至少一半的返工时间。
阅读完成 · 觉得有帮助?