ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot+Vue实现小区家政预约平台:从设计与实现到部署运维

Spring Boot+Vue实现小区家政预约平台:从设计与实现到部署运维 从最开始接到这个小区家政服务预约平台的需求我心里其实很清楚这类项目表面上是一个简单的前后端应用真正磨人的地方在后面——预约的时间冲突、服务人员的排班、用户爽约、平台方对订单的管控任何一个点处理不好都会让项目变成演示能用、上线就崩的玩具。这篇文章我打算用Spring Boot 2.7.18 Vue前后端分离的方式把从需求拆解到部署上线的完整过程讲透重点说清楚为什么这么设计、踩过哪些坑、参数怎么调希望对正在做类似项目的朋友有实际帮助。源码级的东西我会尽量写细但不会贴大段无用代码核心逻辑、表结构设计、关键配置、排查经验才是大头。这篇文章适合准备做Spring Boot毕设或练手项目的人也适合想快速搭建预约类SaaS后台的Java工程师。1. 项目概述与设计思路1.1 这个平台到底解决了什么问题先还原一个真实场景小区业主想找保洁传统方式要么在业主群里问一圈要么翻物业底下的名片又怕来路不明的人进门服务完出了问题也找不到人追责。物业这边呢本身就有一批合作的保洁、维修人员但预约靠电话和纸笔登记经常出现同一个人被约了两次、到了时间发现师傅在别的小区回不来这种状况。这个平台做的事情就是把这套线下流程搬到一个BS架构的管理系统里。业主在网页上按服务类型、时间段去预约后台能看到资源占用情况服务人员收到派单提醒完成后上传服务凭证业主确认付款和评价物业在管理端做汇总和结算。一句话让预约、派单、履约、结算的闭环在线上完成减少沟通成本和爽约率。BS架构在这里其实是必然选择。家政服务的使用方是业主、服务人员和物业三方如果做成CS客户端每个角色都要装软件更新一次版本要协调所有人维护成本极高。用浏览器访问就省掉了这些事服务器上部署一套所有人打开网址就能用。Spring Boot负责后端接口前端用Vue做单页应用数据交互走JSON分工清晰。1.2 技术选型背后的取舍逻辑选Spring Boot而不是Spring MVC或者SSH理由很现实。Spring Boot解决了传统Spring项目里繁琐的XML配置问题内置Tomcat打包成Jar就能直接跑这对一个需要频繁迭代、快速上线的预约平台来说太关键了。而且Spring Boot的生态非常成熟权限控制用Spring Security或Sa-TokenORM用MyBatis-Plus缓存用Redis消息推送用WebSocket这些组件都是开箱即用。做技术选型时有个容易被忽略的点——版本兼容。Spring Boot从2.x升到3.x很多用法变了比如javax.servlet变成了jakarta.servletMyBatis-Plus的starter也换了坐标。我在这个项目里选择2.7.18作为基线原因很简单JDK 8还是很多生产环境的标配Spring Boot 2.7.18是兼容JDK 8的最后一个稳定版本第三方组件的适配也最齐全不会出现导个包进来发现版本冲突这种问题。ORM选MyBatis-Plus还有个私心它的分页插件PageHelper已经不用了直接内置PaginationInnerInterceptor写个配置类就能用比原生MyBatis手写分页SQL省不少事。这对预约列表、订单列表这种高频分页查询场景非常友好。1.3 角色与业务流程梳理先把这个系统的用户画像拆清楚因为角色的不同直接决定了权限设计和接口设计。业主注册登录、浏览服务列表、预约服务、在线支付、查看订单、取消预约、评价。服务人员接单、查看待执行任务、更新服务状态、上传服务照片、查看收益。物业管理员审核入住信息、管理服务人员、设置服务项目和价格、处理投诉退款、查看经营报表。这三类人对应的业务流是业主提交预约单 - 系统自动校验时间冲突 - 派单给服务人员 - 服务人员接单并上门 - 完成后业主确认 - 物业抽成结算。每一个环节的状态变化都应该在数据库里留下痕迹方便后续对账和售后。这里需要特别强调的是状态机设计。预约单的状态我建议从一开始就定义好待支付、待派单、已派单、服务中、待确认、已完成、已取消、已退款。状态流转要设计成单向的比如已取消的订单不能回到待派单已完成的订单不能直接退款每一步操作必须在代码里做状态校验不能只靠前端按钮控制。这个设计后期给排查问题省了大力气。2. 系统模块与数据模型设计2.1 前后端分离下的模块划分既然定了Spring Boot Vue的前后端分离结构那后端就不是简单把Controller写出来就完事分层很重要。controller层只做参数接收、权限校验、调用service不写业务逻辑。service层核心业务逻辑比如预约时间冲突检测、订单状态机流转、支付回调处理。mapper层MyBatis-Plus的BaseMapper复杂查询用XML。entity和dto分开数据库实体和前端传参对象别混用避免把密码、内部字段暴露给前端。前端我用Vue 2 Element UI管理后台和用户端分开两个页面工程。有人会问怎么不用Vue 3我当时考虑的是Element UI对Vue 2的支持更稳定日期选择器、表格、表单这些后台组件照搬就能用开发速度最快。如果是从零学VueVue 2的教程和踩坑资料也最多遇到问题至少查得到。模块上我拆成了用户模块、服务模块、预约模块、订单支付模块、评价模块、通知模块、统计报表模块。每个模块的生命周期独立比如预约模块里包含预约单、时间冲突检测、派单策略这三块代码放到一个包下面比散落在多个包里好维护很多。2.2 核心表结构设计要点表结构是一个项目的灵魂设计烂了后面所有代码都在打补丁。我这个项目里核心的表就这么几张用户表、服务分类表、服务项目表、预约单表、订单表、服务人员排班表、评价表、通知记录表。预约单表是关键字段除了常规的id、user_id、worker_id还有几个字段必须保留expect_start_time和expect_end_time这两列是时间冲突检测的基础。status预约单当前状态。cancel_reason取消原因后来排查爽约率就靠这列。address_detail上门地址快照因为业主可能中途搬家不能关联当前物业地址。service_price_snapshot下单时的服务价格快照防止后续改价影响历史订单。排班表也很重要。我一开始没有排班表只靠时间冲突检测结果发现同一个服务人员可以被约在早上9点和下午3点但中间通勤时间根本不够。后来加了排班表设置每个服务人员每天的可服务时段预约只能在排班时段内选择这个问题才算彻底解决。排班表设计成worker_id work_date start_time end_time slot_status服务人员在管理端确认自己一周的出勤情况。数据库设计里有一个很实用的小技巧所有订单和预约单都冗余一个create_time和update_time这两个字段统一用数据库的CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP不要靠应用层填。因为后期对账、统计、排查数据问题时这两个字段是排查的锚点。2.3 状态机的代码落地方式我见过太多人把订单状态用if-else硬写在service里导致状态越写越乱最后连自己都搞不清楚什么状态能做什么操作。这块我推荐用枚举加上状态流转校验方法。public enum OrderStatusEnum { PENDING_PAYMENT(0, 待支付), PENDING_ASSIGN(1, 待派单), ASSIGNED(2, 已派单), IN_SERVICE(3, 服务中), WAITING_CONFIRM(4, 待确认), COMPLETED(5, 已完成), CANCELLED(6, 已取消), REFUNDED(7, 已退款); private final Integer code; private final String desc; // 省略构造函数和getter }然后在订单实体的状态变更方法里统一做校验private boolean canTransitTo(OrderStatusEnum target) { switch (this.status) { case PENDING_PAYMENT: return target PENDING_ASSIGN || target CANCELLED; case PENDING_ASSIGN: return target ASSIGNED || target CANCELLED; // 其他状态的合法流转组 default: return false; } }这样所有状态变更都必须经过canTransitTo校验后续哪怕前端把按钮暴露错了后端也会拦下来。这个习惯我建议从一开始就养成做所有状态类业务都适用给人感觉整个系统的稳定性瞬间上一个档次。3. 工程搭建与核心接口实现3.1 从0到1搭建Spring Boot工程先聊聊版本环境。我的开发环境是JDK 1.8 Maven 3.8 IDEA。关于IDEA创建Spring Boot项目这个操作有个常见的坑如果网络不好或者Spring Initializr拉不到元数据选不了Spring Boot版本可以改用https://start.spring.io这个地址或者直接在IDEA里New Project选Spring Initializr服务器URL手动填阿里云的镜像地址速度会快很多。如果遇到IDEA创建不了项目、不能用JDK 1.8的问题多半是IDEA版本和JDK版本不匹配。新版本的IDEA默认可能不带JDK 8的运行时支持建议装JDK 8后再在Project Structure里手动添加JDK或者直接换用低版本IDEA2021.2以下配JDK 1.8这套组合最稳定。pom.xml里我用的核心依赖大致是这样parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesMyBatis-Plus的分页插件是必须在配置类里显式声明的不声明的话分页不生效只会查出全部数据。我当时在这个问题上折腾了小半天后来看了源码才发现分页插件默认没启用。配置方式很简单Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }3.2 登录鉴权的实现方案用户端和管理端我用了两套认证策略。用户端用JWT Token状态保存在Redis里有效期为7天用户登出时从Redis删除。这样做的好处是服务端无需维护Session适合前后端分离的部署方式。JWT的生成我推荐使用io.jsonwebtoken的jjwt库版本0.9.1。生成Token和解析Token的方法封装到JwtUtil工具类里关键点是秘钥过期后解析会抛出ExpiredJwtException这个异常要在全局异常处理器里捕获返回401而不是500。public String generateToken(String userId, String role) { Date now new Date(); Date expireDate new Date(now.getTime() EXPIRE_TIME); return Jwts.builder() .setSubject(userId) .claim(role, role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }Token校验我放在拦截器里。springboot的拦截器实现HandlerInterceptor接口在preHandle方法里从请求头取Authorization解析Token如果解析失败直接返回401。要注意的是白名单配置登录接口、注册接口、服务分类查询等不需要鉴权的接口要放行其他接口全部拦截。放行路径用addPathPatterns去配置比如/api/auth/放行/api/user/需要登录/api/admin/**需要管理员权限。这里有一个非常容易被坑到的点如果你在后端拦截器里验证token但前端Vue请求时忘记在Axios拦截器里带上token那就会出现接口全部正常但一登录就401的现象。排查顺序应该是先看请求头里有没有token再看后端拦截器到底怎么写的不要一上来就怀疑JWT库有问题。3.3 预约核心流程的代码实现预约下单是整个系统的核心操作代码逻辑必须严密。这里有一个经典的并发问题两个业主同时预约同一个服务人员的同一个时间段如果先查询再插入不加锁的话就会产生超卖。解决办法是对时间冲突检测加锁我用的方案是Redis的分布式锁锁的key设计为reserve:{workerId}:{expectStartTime}-{expectEndTime}获取锁失败就提示该时间段已被预约请选择其他时间。预约下单完整流程分五步校验用户登录状态获取userId。校验服务项目是否存在且上架获取价格快照。校验服务人员在指定时间段是否有排班并用分布式锁防止并发冲突。创建预约单状态为待支付。返回预约单号前端跳转支付页。校验排班和检测冲突要通过数据库唯一约束兜底。我在预约单表上加了唯一索引(worker_id, expect_start_time, expect_end_time)这样即使代码锁被穿透数据库层面也能拦住双保险。这个思路对做预约类系统的朋友非常关键一定不要只靠代码层校验。支付这块我接入的是微信支付但业务里更关键的是支付回调的幂等处理。支付回调接口可能被微信服务器重复调用所以必须在回调逻辑里先查询订单状态如果已经是已支付直接返回成功不再执行第二次发货逻辑。判断幂等的字段用transaction_id和out_trade_no配合数据库唯一索引。3.4 实时通知服务的设计预约平台天然需要实时通知能力。业主提交预约后服务人员端要立刻看到新订单提醒服务完成后业主也要收到服务完成通知。这一步用长轮询太重用WebSocket最合适。Spring Boot整合WebSocket的流程不算复杂核心有三件套WebSocketConfigurer配置类、WebSocketHandler处理类、握手拦截器。握手拦截器里做身份鉴权从URL参数里带上token握手成功后把用户标识塞进WebSocketSession的attributes里这样后续推送时能根据userId找到对应的session。我把session管理封装了一个SessionManager底层是ConcurrentHashMapkey是userIdvalue是session的CopyOnWriteArraySet集合。为什么用集合而不是单个session因为同一个用户可能在多个标签页打开系统每个标签页一个session推送时要把全部session都发一遍否则用户收不到通知。实际开发中还遇到了一个问题WebSocket连接会断开尤其是浏览器休眠、网络切换时。我的解决方案是前端加heartbeat心跳包每30秒发一次{pong}后端10秒内没收到心跳就主动关闭连接前端检测到close再自动重连。这个机制上线后稳定性提高了非常多通知漏发率降到了几乎为零。MQTT和EMQX我也调研过适合物联网设备端的消息推送但在纯浏览器场景下WebSocket已经够用没必要引入额外的中间件增加运维负担。如果你后期要做App端推送那建议再引入第三方推送服务不要强上火箭。3.5 文件处理与报表导出的落地做法物业管理端经常需要导出服务记录和结算报表业主端也需要下载服务单据。这个需求我用Hutool工具类配合IText生成PDF或者用cn.hutool.extra.template搭配Thymeleaf模板引擎把HTML模板渲染成PDF。springboot实现HTML转PDF的路径我推荐用openhtmltopdf或者IText Flying Saucer。实际项目里我用的是IText 5 xmlworker把前端传来的HTML字符串转成PDF注意中文显示需要注册中文字体否则PDF里中文全部是乱码和方框。注册字体的代码大致是BaseFont bf BaseFont.createFont(STSong-Light, UniGB-UCS2-H, BaseFont.NOT_EMBEDDED); Font font new Font(bf, 12, Font.NORMAL);font注册后在生成PDF文档时把字体对象传入所有需要显示中文的文本段不需要改变原来HTML的结构。这个坑我踩过一次当时生成出来全是方框排查了半天才发现是字体没注册。还有上传附件这块比如业主上传排水管堵塞的照片、服务人员上传完工照片我用的是本地存储加Nginx静态映射。文件上传类型和大小要做限制不能让用户随便上传exe之类的东西。我在Spring Boot里配置了multipart.max-file-size和max-request-size默认1MB对于照片来说偏小我调到了10MB同时写了一个FileCheckUtil根据文件头判断真实类型防止有人绕过后缀名限制上传木马。4. 安全防护与常见问题排查4.1 XSS攻击过滤与上传文件安全XSS是BS系统最容易遇到的安全问题。用户在评价里写一段 如果后台原样存储、原样渲染所有人的浏览器都会执行这段脚本轻则被恶搞重则Cookie被窃取。常规做法是在后端拦截器里写一个XSS过滤器对请求参数做HTML转义。把、、、等字符转成、等实体。但这里有一个坑上传PDF文件时如果也走XSS过滤器文件内容会被全部转义导致上传的PDF解析失败。当时网上搜到有人问springboot项目xssfilter处理上传pdf的xss攻击处理怎么解决就是遇到了这个问题。我的处理方案是XSS过滤只处理Content-Type为application/x-www-form-urlencoded、application/json或multipart/form-data中非文件字段的值不要对文件流做任何转义。在拦截器里判断请求的contentType如果包含multipart/form-data只取字符串参数部分做清洗文件部分原样透传。Override public String clean(String value) { if (value null || .equals(value)) return value; value value.replaceAll(, lt;).replaceAll(, gt;); value value.replaceAll(\, quot;); value value.replaceAll(, #x27;); value value.replaceAll(/, #x2F;); return value; }前端在展示文本时我也做了一个双重保险Vue里用过滤器处理用户内容把反斜杠和尖括号再过滤一遍。总的来说后端过滤是必须的前端辅助显示别只靠一头。4.2 并发预约导致的时间冲突问题预约时间冲突这个坑我实际开发中被坑得很惨。第一版没加锁测试人员用两个浏览器同时点击同一个时间段的预约居然全部成功了数据里出现了同一时间段两条记录。排查后确认是经典的超卖问题两个请求同时select都发现没有记录再同时insert都插入成功。解决方案就是我前面提到的三层防护。第一层是代码层分布式锁用Redis的tryLock拿到锁才能继续拿不到就提示时间段已被占。第二层是数据库唯一索引这个是最底线的保障即使代码层出问题数据库也不会放行重复插入。第三层是业务层状态判断在事务里重新查询该时间段是否已有有效预约记录。还要注意分布式锁的过期时间设置。如果每个操作超过3秒还没完成锁要加上看门狗自动续期。如果你用Redisson框架它内部已经做好了看门狗机制默认30秒续期比我手动设过期时间方便很多。这个场景我踩过坑锁过期时间设得太短业务还没执行完锁就释放了另一个请求趁虚而入又产生了重复数据。4.3 springboot版本兼容与第三方组件集成中的大坑版本兼容问题在Spring Boot项目里非常高频我做这个项目时几乎被它逼疯过。常见的是这些springboot版本太高导致某个starter不支持比如Spring Boot 3.x里MyBatis-Plus要用3.5.3以上版本而且依赖的groupId变成了mybatis-plus-spring-boot3-starter。idea不能创建springboot项目或者不能使用jdk1.8这个大部分是IDEA版本太高的问题。springboot从2.x升级到3.5.xjavax改名jakartaswagger还报NoClassDefFoundError。我的建议是暂时别盲目升级生产项目的Spring Boot版本。除非有新特性必须用否则就待在2.7.18这个安全的版本里。组件选型也尽量挑官方支持列表里面的别看到网上推荐一个新依赖就往里塞集成之前先看一眼它支持的Spring Boot版本范围能省掉一大把时间。springboot与锐浪报表服务器深度整合这类场景本质上是引入第三方报表引擎。如果你要用锐浪要注意它的DLL文件和Jar包对操作系统有要求Linux服务器还要装对应的依赖库。这块我后续单独写了一篇配置文档这里提一句凡是用到本地库的第三方组件尽量在Dockerfile里把so文件一起打包否则部署到别的机器上就会报UnsatisfiedLinkError。4.4 Docker部署与日常运维部署这块我用Docker Compose把所有服务编排起来包括MySQL、Redis、后端应用和前端Nginx。后面单独部署时不要用java -jar直接跑容易因为没有守护进程导致进程被kill。Dockerfile的内容很简单FROM openjdk:8-jre-alpine COPY target/home-service.jar /app/home-service.jar WORKDIR /app EXPOSE 8080 ENTRYPOINT [java, -jar, /app/home-service.jar]docker-compose.yml里我会把MySQL和Redis的数据目录映射到宿主机加上restart: always这样机器重启后服务能自动恢复。部署上线的第一天我就因为没配restart策略凌晨服务器重启后整个平台挂了一整夜第二天早上一看日志才明白问题出在哪。日志排查是我每天必做的事。Spring Boot默认的logback配置会输出到控制台我用logging.file.name把日志写到/data/logs/app.log并按天滚动。排查线上问题的时候第一件事就是看这个日的日志搜订单号和时间段。如果日志里转头一看全是红色异常堆栈先别急着改代码看第一行Caused by是什么比瞎猜要快得多。5. 扩展思考与个人经验5.1 工作流引擎的引入时机很多人在做这类平台时一上来就想集成Flowable工作流引擎觉得审批流很高级。我的建议是如果预约平台的核心还是预约和支付先别上工作流。为什么Flowable会给项目增加非常大的复杂性——需要创建几十张ACT开头的表概念有流程定义、流程实例、任务、网关对不熟悉的人简直是灾难。管理端的订单审核流程如果只有两三个节点用一个状态字段加一张流转记录表完全够用了。我当时是在项目迭代到第二期物业管理提出要支持退款审批链业主申请退款 - 客服初审 - 财务复核 - 负责人终审才发现状态机硬写维护不住才引入了Flowable。在真正需要的时候再上这是最务实的做法。5.2 搜索与分词在功能中的实际价值平台上线后小区业主经常搜通马桶水管漏了家电维修但后台的服务项目可能只叫疏通下水道管道抢修空调清洗匹配不到。这个问题的解法是给每个服务项维护一组别名关键词业主搜索时把别名也放进搜索条件。更进一步可以做HanLP分词。我的做法是在后端集成HanLP的标准分词模式对搜索关键词做分词再把分出的每个词都拿去和名称、别名做模糊匹配匹配得分最高的排在前面。这样通马桶会被分词成通和马桶再去匹配疏通的别名通马桶时能够命中。用户搜索体验提升非常明显。其实这些功能在Spring Boot里集成都不难HanLP就是一个Jar包的事分词接口通过Service调用就行。重点在于别想着一步到位做搜索引擎用数据库likeHanLP分词已经能覆盖90%的需求了。5.3 给做类似项目的人几句实在话这个项目做完我最大的感受是预约类系统的复杂度不在CRUD而在各种边界情况。时间冲突、重复支付、状态不可逆、并发超卖、文件上传的XSS绕过哪一个环节没处理好上线那天就是翻车那天。如果你是在做Spring Boot的毕设或者外包项目我的建议是先把核心业务闭环跑通再考虑加亮眼功能。用户登录、服务展示、预约支付、后台管理这四块做实了项目就立住了。在这个基础上再接实时通知、报表导出、分词搜索这些锦上添花的功能让评委或甲方觉得这人做了不少东西项目档次立刻不一样。还有一点开发过程中一定多用Git做版本管理每完成一个模块就打一个tag。我见过太多同学做完整个项目一次提交出了问题想回退都没法回退。分阶段提交每个功能一个commit注释写好这才是专业开发者的做派。
返回列表