同城宠物照看系统实战:Spring Boot+Vue全栈开发与部署要点
发布时间:2026/10/12 9:38:35 锦皓数字建站

最近一段时间我一直在反复调试并整理一套基于 Spring Boot Java Vue 的前后端分离项目名字叫“同城宠物照看系统”。第一眼看到这个项目名我以为是普通的 CRUD 作业真正把源码跑通、把整条业务流程理清之后才发现里面涉及的权限控制、订单状态流转、服务匹配逻辑和前后端联调细节远比想象中要有价值。这也是我写这篇文章的初衷把做这套系统时的整体设计思路、关键实现手段、踩过的坑以及部署上线时的注意点一次说清楚。无论你是准备做毕业设计还是想找一份能写进简历的全栈项目这套业务模型都值得花点时间拆一拆。1. 项目背景与需求拆解1.1 同城宠物照看到底解决什么问题养宠物的人都有同一个痛点出差、旅游、临时加班家里的猫猫狗狗没人照顾。传统寄养店一是贵二是很多宠物到了陌生环境容易应激尤其是猫。于是“同城照看”这种轻模式就流行起来了。它和普通寄养最大的区别在于“服务发生在宠物熟悉的环境里”照看人上门喂食、铲屎、遛狗或者按约定把宠物接到自己家代养几天。这样宠物不用离开熟悉的气味环境主人出门也更安心。这里有一个最关键的信息匹配问题宠物主人短时间内很难判断一个陌生照看人是否靠谱。靠谱的照看人又没有渠道把服务时间和经验展示出来。这套系统的核心价值就是做一个本地生活服务撮合平台把“谁需要照看”“谁能提供照看”“服务到什么程度”“怎么保障双方权益”这几个环节全部线上化。1.2 核心功能模块划分按角色来拆功能是最容易理解的切分方式。整个系统包含三类登录身份普通宠物主人、照看人、平台管理员。每个身份对应的操作路径完全不同功能模块也跟着分了三层。普通用户端注册登录、宠物档案管理品种、年龄、疫苗记录、性格备注、发布照看需求、按城市/服务类型搜索照看人、下单、查看订单状态、订单完成后的评价。照看人端申请成为照看人、维护可接单时段和服务项目上门喂养、遛狗、寄养、从平台接单、更新服务进度、查看收入记录。管理员端审核照看人资质、管理用户状态、监控异常订单、处理举报与投诉、基础数据统计。这些模块合起来就是一个完整的 O2O 业务闭环。单纯做一个 CRUD 很容易真正花时间的其实是“状态之间的流转规则”比如一个订单什么时候可以被取消、照看人接单之后能不能转单、服务完成后怎么触发评价入口。这些规则直接决定代码的复杂程度。1.3 用户角色与权限边界做这类系统我个人的习惯是先画一张角色权限表再动手写代码。普通用户和照看人本质上是同一张用户表只是通过角色字段区分。一个用户既可以是宠物主人也可以申请成为照看人这是很常见的真实业务场景。权限边界划分上后端必须做到“接口级控制”。比如普通用户只能操作自己的订单不能查看别人的订单详情照看人只能接符合服务范围的订单管理员可以查看全部订单但不参与接单。这些都是后端在 Controller 层就需要校验的逻辑不能完全依赖前端隐藏按钮。前端的角色控制只是改善体验真正的安全边界必须在后端。2. 技术选型与架构设计2.1 为什么选 Spring Boot Vue 前后端分离如果只是做一个学生练手项目可以用 JSP 或者 Thymeleaf 做服务端渲染一个 Tomcat 跑完所有东西。但放到真实业务场景里我建议直接上前后端分离。原因有三个第一前端和后端可以独立开发、独立部署。前端同学只需要关心接口文档后端同学只需要保证接口稳定不用互相等。第二一套后端接口可以同时支撑 Web 端、小程序端和 App 端以后加一个移动端入口前端工作量很小。第三这种架构更贴近现在绝大多数公司的实际开发模式简历上写“熟悉前后端分离开发”面试官一问到底怎么分离的你至少能讲清楚接口设计和部署方案。这套系统的后端负责提供 RESTful API前端用 Vue 搭建页面通过 Axios 请求接口拿到 JSON 数据再渲染。后端不关心页面长什么样前端不关心数据存哪张表各自职责清晰。2.2 关键技术栈清单具体选型的时候我沿用了目前个人开发里最稳的一套组合没有追新。技术选择不求最火但求生态成熟、遇到问题搜得到答案。层级技术选型理由后端框架Spring Boot自动配置、内嵌容器、社区资料多适合快速搭建业务后端持久层MyBatis-Plus单表 CRUD 几乎不用写 SQL分页和一二级缓存都有现成方案数据库MySQL业务关系清晰订单、用户、评价都有强一致性要求缓存Redis存登录态、热点数据、稍后要讲的接单并发控制前端Vue 3 Element Plus组件生态成熟做管理端和中后台页面效率很高HTTP 客户端Axios拦截器机制方便统一处理 token 和错误码认证方案JWT无状态、不依赖 Session适合前后端分离部署Nginx jar 包前端静态资源归 Nginx后端接口反向代理过去简单可靠这套技术栈组合最大的优势是“可替换性”。就算你没有用过 MyBatis-Plus换成 MyBatis 或者 JPA也不会影响整体架构因为 Service 层已经把数据访问封装住了。2.3 项目结构与代码分层后端我习惯按包结构分层而不是按功能模块分。因为这种单体项目规模不大按功能模块拆包反而会让公共代码无处安放。实际目录大概是这样的com.project.petcare ├── controller // 接口入口只做参数接收和结果返回 ├── service // 业务逻辑层订单流转、权限校验都在这层 ├── mapper // MyBatis-Plus 数据访问层 ├── entity // 数据库表对应的实体类 ├── dto // 接口出入参对象避免直接暴露实体字段 ├── config // 跨域配置、拦截器注册、MyBatis-Plus 分页插件 ├── common // 统一返回结果、异常枚举、工具类 └── interceptor // JWT 登录拦截器分层的好处在于出问题的时候不用从 Controller 一路翻到 SQL可以先根据异常类型定位到层。比如参数校验错误大概率在 Controller业务规则不对在 ServiceSQL 语法错误在 Mapper。这套思路放到任何后端项目里都一样。3. 数据库设计与关键业务模型3.1 核心数据表与字段说明数据库设计阶段多花一小时后面写代码能省一天。这套系统的核心表不算多但每张表的字段都有它的用意。先看最重要的一张订单表CREATE TABLE care_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 业务订单号对外展示用, user_id BIGINT NOT NULL COMMENT 下单人ID, caregiver_id BIGINT DEFAULT NULL COMMENT 接单照看人ID, pet_id BIGINT NOT NULL COMMENT 宠物档案ID, service_type TINYINT NOT NULL COMMENT 服务类型1上门喂养 2遛狗 3寄养, city VARCHAR(64) NOT NULL COMMENT 所在城市同城匹配的核心字段, address VARCHAR(255) NOT NULL COMMENT 服务地址脱敏后展示给照看人, expect_start_time DATETIME NOT NULL COMMENT 期望开始时间, expect_end_time DATETIME NOT NULL COMMENT 期望结束时间, amount DECIMAL(10,2) NOT NULL COMMENT 订单金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0待接单 1已接单 2服务中 3已完成 4已取消 5已评价, remark VARCHAR(500) DEFAULT NULL COMMENT 备注, create_time DATETIME DEFAULT NULL, update_time DATETIME DEFAULT NULL, KEY idx_user_id (user_id), KEY idx_status (status), KEY idx_city_service (city, service_type) );这里有几个容易被忽略的设计点。第一个是order_no为什么要单独存一个对外订单号而不是直接用主键 id。因为主键 id 会暴露平台的订单量竞对爬数据很方便而且订单号需要加随机因子防止被猜到。第二个是服务类型、状态这些固定字典值用TINYINT而不是字符串存储查询更快逻辑判断也更清晰。第三个是索引设计同城搜索必然高频按城市和服务类型查所以建了联合索引。用户表、宠物表、评价表的设计相对常规。宠物表要注意存疫苗信息和性格备注这是后续照看人接单时判断“敢不敢接”的重要参考。评价表一定冗余存caregiver_id这样查询某个照看人的历史评分时只需查评价表不用关联订单表。3.2 订单状态机与流转规则状态机设计是这套系统的灵魂。如果只把状态定义成一堆数字、代码里到处if status 1后面需求一改就会乱成一团。我建议在 Service 层封装一个状态流转工具明确哪些状态可以转到哪些状态。正常路径是0 待接单 → 1 已接单 → 2 服务中 → 3 已完成 → 5 已评价。异常路径是0 待接单直接到 4 已取消已接单之后如果确实无法履约需要管理员介入不能随手改状态。为什么取消操作只允许在待接单阶段因为一旦照看人接单就说明他已经为此预留了时间、可能推掉了其他订单这时候用户单方面取消对照看人是不公平的。当然真实业务里可以支持“服务开始前 N 小时免费取消”但那样就要增加一个取消费用计算逻辑。做毕业设计或者第一版 MVP先锁死规则把核心链路跑通比追求功能大全更重要。3.3 唯一订单号生成策略订单号我采用了“日期 随机数 用户ID后四位”的组合没有引入雪花算法。毕竟这个平台规模初期不大雪花算法虽然能跑得好但会带来序列号依赖和集群时钟回拨的额外问题。自己生成订单号时要注意两点一是随机数部分不能太短避免并发场景下撞号二是必须给order_no加唯一索引数据库层面兜底代码里即使出了问题也不会生成重复订单号。生成逻辑大致是String.format(%s%s%s, yyyyMMddHHmmss, 四位随机数, userId % 10000)。判断唯一性前先查一次插入失败再捕获 DuplicateKeyException 重试一次。这里最忌讳的是只靠代码判断不靠数据库约束一旦并发上来就会出现脏数据。4. 后端核心实现与调试实录4.1 JWT 认证与登录拦截前后端分离项目里Session 方案会遇到跨域和移动端适配麻烦所以这套系统用的是 JWT。登录成功后后端生成一个 token里面只放 userId 和 role前端拿到之后存在 localStorage 里。之后每个请求都在请求头带上Authorization: Bearer xxxx后端拦截器解析 token、查到用户信息后放入 ThreadLocal。拦截器里有两个容易踩的细节。第一白名单要放行登录、注册、刷新验证码、首页公开的搜索接口其他接口一律拦截。第二token 解析失败要返回 401而不是直接抛 500这样前端才能识别“登录失效”并跳转登录页。注意 ThreadLocal 用完要 remove否则线程池复用线程时会把上一个用户的信息带到下一个请求里这是并发场景下很隐蔽的 Bug。JWT 的有效期不能太长否则用户被禁用后 token 还能继续访问。我在项目里设置 24 小时过期并且把登录时间存到 Redis。管理员封禁用户时直接删掉 Redis 里的登录记录比分布式黑名单方案简单很多。4.2 接单并发怎么控制照看人端的高频操作是“接单”。如果两个照看人同时抢同一个订单普通代码里先查询 status0再更新 status1很容易出现两个人同时读到待接单状态然后都执行更新最后订单被两个人同时接走的脏数据。我当时用的方案是乐观锁加 SQL 条件更新。核心就是一句 UPDATEUPDATE care_order SET caregiver_id #{userId}, status 1 WHERE id #{orderId} AND status 0执行update后检查影响行数。如果返回 1说明当前用户抢单成功如果返回 0说明订单已经被别人接走直接提示用户“手慢了订单已被接”。这种方案在小并发下完全够用不需要引入 Redis 分布式锁。当然如果订单量大到同一秒有几千人抢单可以考虑 Lua 脚本加 Redis 扣减但第一版没必要。这个案例特别适合写进简历面试官问“你怎么处理并发下的数据一致性”时你可以讲清楚为什么选数据库条件更新、为什么不选查询后代码判断。4.3 文件上传与访问路径处理宠物档案要传照片照看人也要传资质图片。上传功能虽然简单但路径问题经常让人抓狂。后端把文件存储到服务器本地目录uploads/avatar文件名用UUID 原扩展名重命名避免中文文件名和重名问题。需要特别注意的是文件不能放到 Spring Boot 打成 jar 包后的内部目录里否则每次重新部署文件就丢了。正确做法是在application.yml里配置一个外部目录然后用资源映射把http://ip:8088/upload/**映射到本地磁盘路径。部署时再用 Nginx 把/uploads/直接定位到同一目录图片访问完全不经过后端压力小很多。spring: web: resources: static-locations: file:/home/app/uploads/4.4 调试实录三个来回折腾的问题第一个问题是 LocalDateTime 序列化。后端返回 “2025-01-01T12:00:00” 这种带 T 的格式前端展示特别难看。原因是我只在实体字段上加了JsonFormat但没加全局配置。后来在配置里给 ObjectMapper 设置了统一日期格式和时区才算彻底根治。第二个问题是跨域。后端单独跑在 8080前端跑在 5173开发时浏览器直接拦截。我一开始在 Spring 里配置allowedOrigins(*)结果发现带 token 的请求还是会报跨域。查了半天才发现浏览器的预检请求是 OPTIONS后端需要放行 OPTIONS 请求。这个问题的正确解法是给跨域配置加上allowedOriginPatterns(*)并且允许Authorization头。第三个问题出在 MyBatis-Plus。我想把一个订单的备注字段更新为 null直接调用updateById结果发现字段根本没变。原因是 MyBatis-Plus 默认的字段策略是 NOT_NULLnull 值不会拼进 SQL。如果确实需要更新为 null要么在字段上加TableField(updateStrategy FieldStrategy.IGNORED)要么用 UpdateWrapper 显式 set。这三个问题都不是高深理论但都足以让刚上手的人卡上一整天。5. 前端页面与接口联调细节5.1 工程结构与路由规划Vue 端我按业务模块拆分了 views公共组件单独建一个 components 目录。页面层面分成了几个主题登录注册、首页搜索、用户订单列表、照看人工作台、个人中心和管理后台。路由配置全部用懒加载首屏只加载当前页面需要的组件。路由守卫是前端必须做的一层控制。我封装了一个简单的全局 beforeEach 逻辑确保页面刷新时能同步用户状态通过/login校验用户是否已登录已登录用户再访问登录页就自动跳回首页。另外通过路由 meta 字段标记页面需要什么角色比如meta: { roles: [caregiver] }进入前先用后端返回的当前用户角色做一次校验。5.2 Axios 封装与统一异常处理Axios 不能直接用一定要二次封装。我封装了一个请求模块baseURL指向环境变量里的接口前缀。请求拦截器里统一加 token不需要每个页面自己手动塞 header。响应拦截器里做了三层处理成功后直接返回后端 data 部分业务错误弹统一提示401 则清空本地登录态并跳转登录页。核心代码大概是这样的const request axios.create({ baseURL: /api, timeout: 15000 }) request.interceptors.request.use(config { const token localStorage.getItem(pet_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data.data, error { if (error.response.status 401) { localStorage.removeItem(pet_token) router.push(/login) } Message.error(error.response.data.message || 请求失败) return Promise.reject(error) } )这样做之后页面里的业务代码非常干净几行就能调一个接口。之前见过不少项目每个请求单独写catch还各自弹不同的错误提示纯属重复劳动。5.3 订单列表加载与分页搜索订单列表这块如果直接把所有数据一次性返回数据量稍微一涨页面就卡。我在后端给查询接口加了分页参数前端用 Element Plus 的el-pagination组件同步维护当前页和每页条数。搜索维度包括订单状态、服务类型、城市后端用 MyBatis-Plus 的 LambdaQueryWrapper 动态拼条件。这里有个容易忽略的细节查询条件发生变化时要重置页码为 1。比如用户在第 5 页筛选了“已完成”订单如果页码没有重置很可能得到一场空数据。前端做这个联调的时候尤其要注意很多 React 和 Vue 新手都会漏掉这个点。5.4 状态展示如何做得更友好订单状态在数据库里是数字页面上不能直接显示数字。我写了一个状态映射函数把 05 转成对应的中文标签同时给不同状态配上不同颜色的 tag 组件灰色待接单、蓝色已接单、橙色服务中、绿色已完成。列表页的操作按钮也是根据状态动态控制的待接单时用户能取消已接单时能联系照看人服务中时能查看进度。这种“状态驱动页面”的做法让页面上几乎看不到大段的v-if嵌套逻辑。你只需要维护一张状态配置表把每个状态对应的文案、颜色、可选操作配置好页面代码会清爽得多。6. 项目部署与上线流程6.1 本地打包与环境参数后端打包我用 Maven执行mvn clean package -DskipTests产出可运行的 jar 包。前端构建执行npm run build生成 dist 静态目录。打包过程中最容易遇到的是环境差异本地连接的是本地数据库服务器上要连线上数据库。我的处理方式是区分配置文件application-dev.yml和application-prod.yml启动命令里用--spring.profiles.activeprod指定环境。环境变量里最需要注意的是数据库地址、Redis 地址、JWT 密钥和文件上传路径。密钥这类信息不能写死在代码里在服务器上用环境变量传入避免侧面泄露。我见过有人把数据库密码写在 git 仓库里的 application.yml 中这种习惯非常危险。6.2 Nginx 配置前后端分离生产环境我用 Nginx 同时托管前端静态文件和反向代理后端接口。前端资源直接交给 Nginx 处理后端接口统一走/api前缀转发到 8088 端口。比如用户上传的图片目录单独用 alias 映射到磁盘路径。这样一个域名全部搞定不用跨域是前后端分离最省心的部署方式。server { listen 80; server_name yourdomain.com; root /home/app/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8088/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /uploads/ { alias /home/app/uploads/; } location / { try_files $uri $uri/ /index.html; } }try_files $uri $uri/ /index.html这一行很重要Vue 单页应用的路由跳转是前端控制的用户刷新/order/detail?id1如果真的按这个路径找文件一定 404。有了这行配置所有前端路由都会回退到 index.html再由 Vue Router 接管。6.3 运行时常见注意事项启动顺序上先保证 MySQL 和 Redis 可以连接再启动后端 jar。用nohup java -jar app.jar --spring.profiles.activeprod app.log 21 后台运行。如果后端启动报 Redis 连接失败大概率是服务器 Redis 没有配置protected-mode或者密码问题可以先本地连接排查再启动。mysqld 的时区问题也容易碰到。连接 URL 里加上serverTimezoneAsia/Shanghai否则日期查询会和你本机时间对不上订单时间晚八小时的诡异情况就是这么来的。6.4 Docker 部署的轻量方案如果不想在服务器上手动安装 MySQL、Redis、Java 环境可以用 Docker Compose 一次性编排。数据库、缓存、后端、前端挨个声明好服务依赖关系用depends_on表示。不过我个人建议初学阶段先手动部署一次搞清楚每个组件是怎么启动的再用 Docker 提高效率。直接跑 docker-compose出了问题很难定位上下文不如先把逻辑走一遍。7. 常见问题速查与避坑心得7.1 高频问题排查速查表现象可能原因解决办法前端请求全部 404Nginx 没有正确代理 /api 路径校验接口前缀与 location 是否一致浏览器报跨域错误后端未允许 Authorization 头配置 allowedOriginPatterns 和允许请求头登录成功后刷新又跳登录页本地 token 未在处理请求时携带检查 Axios 请求拦截器是否生效上传图片刷新后消失文件存储在 jar 内部目录改成外部存储目录并映射列表数据不更新刷新了页面但查询条件参数丢了用装载路径或参数统一管理查询条件状态显示数字而非文字前端状态映射函数未兼容所有状态统一用 statusCode - 配置对象这类问题最大的特点是不会报错只是行为不符合预期。排查的时候最快的路径不是狂点页面而是先打开浏览器 Network 面板看接口返回再决定问题在前端还是后端。7.2 我个人的实操心得这套系统做下来的全流程我有几个体会比较深。第一数据库状态机提前定好代码尽量不直接写魔法数字用枚举统一管理。第二接单并发问题看起来难一句带条件的 update 就解决了不要一上来就上重量级方案。第三部署文档一定要提前写我反复强调过本地跑通不算完部署时参数出错才最费时间。项目里还有一个可以再扩展的点就是订单提醒。现在完全靠用户自己刷新看订单状态体验一般。后面可以加 WebSocket 或者轮询用户下单后照看人接单时给前端推一条通知这样会更贴近真实业务。不过这是第二版的事了第一版先把核心链路跑稳比什么都重要。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。