1. 这个宠物服务平台到底解决了什么问题,技术栈又是怎么权衡出来的
先说我自己的背景。断断续续接了不少类似“XX管理系统”“XX服务平台”的外包和毕设辅导,这类项目最大的特点是:业务不复杂,但功能面非常杂。宠物服务平台尤其典型,它不像电商系统那样围绕“商品—订单—支付”一条主线就能扯清楚,它涉及用户注册登录、宠物档案维护、服务预约、订单状态流转、资讯/社区内容展示、后台管理等多个互相独立的业务域。如果一开始没有把技术架构理清楚,做到一半必然这改一下那改一下,最后项目代码变成一团乱麻。
这个项目核心面向两类使用者:C端用户(养宠人)和B端管理者(平台运营或门店店员)。C端能逛宠物知识文章、给自家宠物建档案、下单预约洗澡美容寄养等服务;B端管理后台则维护服务项目、排班、处理订单状态。整个系统的目标用一句话概括:让“养宠人找服务”和“门店管订单”两条业务流程在一个系统里闭环跑通。
技术选型上,我最终定了SpringBoot + Vue前后端分离的方案,这也是当前Java技术栈里最主流、资料最多、踩坑成本最低的组合。为什么不用传统的Thymeleaf服务端渲染?原因很现实:这个项目的前端交互密度不低,用户端有预约时间选择、宠物档案卡片、订单进度时间轴,管理端有数据表格、弹窗表单、状态标签,这些都是典型的前后端交互场景。用服务端模板做,前端逻辑和后端模板混在一起,后期每改一个交互都要动Controller和HTML,维护成本太高。而前后端分离之后,后端只需要把接口定义清楚,前端专注交互,两个人可以并行开发,一个人做的话逻辑边界也会更清晰。
还有一点必须提的是SpringBoot版本的选择。现在很多人一上手就选最新版SpringBoot 3.x,但我做这个项目用的是SpringBoot 2.7.18。原因有两个:一是很多教学资源、插件、第三方starter对2.x的兼容性验证最充分,出了问题搜解决方案效率高;二是2.7.x是2.x系列最后一个稳定维护版本,安全性没有问题,而且对JDK8的兼容非常友好。如果自学或做毕设,没必要为了“新”去折腾兼容性问题。
提示:如果你选定SpringBoot 2.7.x,JDK保持1.8即可;如果非要用SpringBoot 3.x,那JDK至少17起步,MyBatisPlus、Druid等组件的依赖版本也要配套升级,这是个连环过程。
2. 数据库表设计:从宠物档案到服务订单,这几张表把业务撑起来了
很多做这类项目的人会犯一个通病:拿到需求直接开建表,边写代码边加字段。我一开始也这样,结果就是订单表里冒出一个pet_notes(宠物备注)字段,排在order_status后面,看代码的人一脸懵,连我自己过两周再看都想不起来当初为什么加它。所以这次我老老实实从“业务对象—关系—状态”三个维度先做了表结构梳理,后面写代码的速度反而快了很多。
2.1 用户体系与宠物档案:为什么一定要拆成两张表
用户表(user)是最基础的一张表,字段包括:id、username、password(BCrypt加密后的密文)、nickname、avatar、phone、role(用来区分C端用户和管理员)、status、create_time。
关键点在宠物档案表(pet)的设计上。一张宠物档案应该归属于某个用户,所以设计为owner_id外键关联user.id。宠物本身的属性包括pet_name、pet_type(猫/狗/其他,用数字字典或字符串枚举都行)、breed(品种)、birthday、gender、weight、avatar、is_neutered(是否绝育)、vaccine_status(疫苗接种情况)。
为什么用户和宠物必须拆开而不是在用户表里存一个“宠物名”的字段?我见过有同学图省事这么干,后续做功能时立刻翻车:预约服务时需要显示宠物列表让用户选,如果宠物信息存在用户表,要么一个用户只能养一只宠物,要么就得搞出pet_name1、pet_name2这种反人类设计。拆成两张表后,用户和宠物是一对多关系,天然支持多宠物,扩个宠物数量上限也就是加个字段的事。
2.2 服务项目、预约单、订单三张核心表的状态流转设计
服务项目表(service_item)相对简单:id、service_name(如“猫咪洗澡SPA”)、service_type(洗澡/美容/寄养/医疗)、price(用decimal(10,2))、duration(服务时长,分钟为单位)、cover_image、description、status(上下架状态)。
真正的核心是预约单表(appointment)。它承担了用户与门店之间的“预订”动作,关键字段包括:
iduser_id:预约人pet_id:哪只宠物service_id:预约的服务appointment_date:预约日期appointment_time_slot:时间段(如“09:00-10:00”)status:0待确认,1已确认,2已完成,3已取消remark:备注create_time
这里我需要重点解释一下time_slot字段的设计。预约功能天然有时间冲突校验的需求,同一个时间段不能同时被两个用户约走。存一个字符串类型的time_slot,业务校验时直接比对“同一天 + 同一个时间段 + 状态为待确认/已确认”是否存在记录,简单高效。如果你要做更精细的时段并发控制,可以额外建一张service_schedule表来维护每个服务项目的可约时段和剩余名额,但毕设和一般实战项目用appointment表自带日期+时段字段的方式足够。
订单表(order)是预约确认后生成的支付单据,字段:id、order_no(唯一订单号,用时间戳加随机数生成)、appointment_id(关联预约单)、user_id、total_amount、pay_status(0未支付,1已支付)、pay_time、create_time。预约和订单分开的好处是:用户可以“预约后到店再付款”,而不是线上支付流程强绑定预约流程。如果一开始就把支付硬塞进预约流程,支付环节出现异常会导致预约状态不可控,实际做项目时这是最常见的状态混乱来源。
2.3 社区资讯与留言评论的表设计
内容分享模块(资讯文章、养宠心得)是大多数宠物平台的标配。文章表(article)字段:id、title、cover_image、content(比如用长文本类型存富文本HTML)、author_id、view_count、create_time。评论表(comment):id、article_id、user_id、content、create_time。这两张表要关联查询,写文章详情接口时连表查出作者昵称和评论列表即可。
一个我踩过的坑:富文本内容直接存HTML时,如果你用MyBatis-Plus的自动填充功能,
create_time字段可能因为格式化问题在插入报错。建议实体上用LocalDateTime,数据库字段用datetime,MyBatis配置里加一个全局的jdbcTypeForNull=NULL,避免空值插入时驱动报错。
3. 后端接口落地的几个关键实现:认证、校验、状态机一个都不能少
后端技术栈我选了SpringBoot 2.7.18 + MyBatis-Plus + MySQL 8.0 + Redis。MyBatis-Plus帮我把单表CRUD的样板代码压缩到了最低,复杂查询用它的LambdaQueryWrapper也能写出比较清晰的API。Redis在这个项目里主要用来存验证码和做接口缓存,JWT也用它做黑名单控制(注销登录时让token失效)。
3.1 JWT登录认证的完整链路:拦截器到底该怎么设计
登录流程很多人已经写过无数遍了:用户传用户名密码,后端校验通过后用JWT生成token返回给前端,前端把token存localStorage,之后每次请求在请求头加Authorization: Bearer <token>,后端拦截器统一解析。这个流程本身没毛病,但实际落地时有几个细节很容易翻车。
先说拦截器的设计。我新建了一个JwtInterceptor实现HandlerInterceptor接口,在preHandle方法里做三件事:取请求头token、解析token、把用户信息塞进ThreadLocal。需要注意:拦截器要排除登录注册接口、首页轮播图、文章列表这些无需登录的公开接口,否则用户还没登录就调不通基础页面。排除名单我维护在WebMvcConfig里,用一个字符串数组统一配置:
registry.addInterceptor(jwtInterceptor()) .addPathPatterns("/**") .excludePathPatterns( "/api/user/login", "/api/user/register", "/api/article/list", "/api/article/detail/**", "/api/home/banner", "/error" );然后是ThreadLocal的使用。一个请求从进入拦截器到Controller再到Service,如果在每个方法里都手动传一遍当前登录用户的id,代码里全是这种参数,非常丑。我用了一个UserContext工具类,内部放ThreadLocal存储LoginUser对象(包含用户id、用户名、角色),在拦截器解析token后写入,请求结束时在afterCompletion里remove掉。这样Controller直接UserContext.getUserId()就能拿到当前用户。
注意:ThreadLocal用完必须remove,尤其是在线程池场景下,否则线程复用会导致用户数据串号。这个问题线上出过事故,排查了一下午才发现是漏了remove操作。
JWT在SpringBoot里主流方案是io.jsonwebtoken:jjwt,0.9.1版本对应JDK8,如果你在JDK8环境引了0.12.x,启动时大概率会遇到NoClassDefFoundError,就是因为jjwt新版本把依赖拆分了,需要额外引入jjwt-api、jjwt-impl、jjwt-jackson三个模块。这是很典型的SpringBoot新手踩坑点。
3.2 预约服务的时间冲突校验:并发场景下的一个隐形Bug
预约功能的核心接口是“提交预约”:前端传petId、serviceId、appointmentDate、timeSlot、remark。后端要做三层校验:
- 用户是否登录、宠物是否属于当前用户(防止提交别人的宠物id)
- 服务项目是否处于上架状态
- 该日期时间段是否已被预约占用
第三层的冲突校验是误伤最多的地方。如果只是简单地SELECT * FROM appointment WHERE date= ? AND time_slot= ? AND status IN (0,1),查出来发现没记录就允许插入,这在并发情况下(两个用户同时抢最后一个时段)会同一时段同时插入两条。要根治的话,先给appointment表加一个唯一索引,索引字段是appointment_date+time_slot+service_id,数据库层面兜底。然后再做业务判断,先查再插,如果插入时捕获到DuplicateKeyException,就说明被并发抢占了,直接提示“该时段已被预约”。这套方案在单机部署的毕设和小型项目里够用了。
3.3 订单状态机:别用一堆if-else硬撑
订单状态我定了一个简单状态机:
0待支付:预约确认后创建1已支付:用户点击支付后更新2已完成:门店确认服务完成后更新3已取消:用户取消或超时未支付由后端定时任务更新
这个状态机虽然简单,但必须规定好“谁可以变更到哪个状态”。我写了一个OrderStatusService,里面只有一个transition方法,传入当前状态、目标状态、操作类型,用switch判断合法流转路径,非法流转直接抛业务异常。这样订单状态相关代码集中在一处,前端在订单列表显示状态流转按钮时,也可以根据当前状态动态决定显示哪些操作按钮,前后端的状态逻辑完全对齐。
有个细节:用户取消订单的操作,要区分“未支付”和“已支付”两种情况。未支付直接取消即可;已支付的订单取消后要触发退款(这里一般对接微信/支付宝退款接口,毕设阶段可以只做状态流转并注释说明退款对接点)。我把这个逻辑放在Service层,而不是在Controller里写if判断,目的是让状态变更逻辑不分散、可复用、可测试。
3.4 MyBatis-Plus分页查询与条件构造器使用心得
分页是管理端List页面的刚需。MyBatis-Plus内置了分页插件,但很多人引入后不生效,原因是在配置类中没有把PaginationInnerInterceptor加入MyBatis-Plus拦截器链。正确的配置:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }配置完成后,Service里直接page(new Page<>(current, size), queryWrapper)就能拿到分页数据。返回给前端时,我一般包装一个统一响应体R<T>,里面包含code、msg、data三个字段,分页时data是PageResult对象,里面放records(列表数据)、total(总条数)、current(当前页)、size(每页数量)。前端拿total渲染分页组件的总页数,拿records渲染表格。
条件构造器建议用Lambda版本,也就是LambdaQueryWrapper<T>,好处是字段名用方法引用User::getUsername,而不是字符串"username"。这样即使将来字段重命名,编译器能帮你检查出来,不至于运行时报SQL错误。
4. 前后端联调中的真实痛点:跨域、日期格式、图片上传路径
前后端分离的第一步,是把前端Vue开发服务器(默认8080端口)和后端SpringBoot接口服务(默认8081端口)联起来。这个环节是大多数项目卡壳的第一个点,翻来覆去就那几个问题。
4.1 跨域问题:别只靠注解,还要理解它为什么发生
后端接口返回正常,前端浏览器控制台报Access-Control-Allow-Origin错误,这是跨域。前后端分离最大的坑就在这——浏览器同源策略限制了Script发起的跨域请求,而Vue的devServer(localhost:8080)和SpringBoot接口(localhost:8081)端口不同,属于不同源。
解决方式也有几种:最简单粗暴的是在Controller上加@CrossOrigin,但这样每个接口都要加,或者类上加了但全局配置没跟上,容易漏。我更推荐在SpringBoot全局配置类里统一允许跨域:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }注意:如果你同时用了Spring Security,跨域配置一定要放在Security的授权链之前,否则Security的过滤器先拦截,CORS跨域配置根本进不来,报错依然是跨域。这个坑我在一个项目里踩了整整半天。
更让人崩溃的是,本地联调好了,部署到服务器后跨域又出现了。因为Vue打包后的静态文件放在Nginx里,和SpringBoot接口不在同一个域。我最后采用的方案是不在后端做跨域放开,而是通过Nginx反向代理,把/api/路径的请求转发到后端的localhost:8081,这样前端所有请求都是同源的,从根本上规避了跨域。
4.2 日期时间格式问题:前端显示Invalid Date的排查经历
列表接口返回订单数据后,前端表格里时间那一列显示Invalid Date。一开始怀疑是前端组件问题,查了半天发现是后端返回的时间格式不是前端能直接处理的格式。
后端实体类如果用LocalDateTime,默认序列化出来是"2025-06-01T12:00:00",中间带个T,前端new Date("2025-06-01T12:00:00")在某些浏览器可能OK,但如果你前端用组件库的时间格式化函数识别不了带T的格式,就会显示Invalid Date。解决办法是在application.yml里统一配置时间格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8但这里有个坑是:这个配置只对java.util.Date生效,对LocalDateTime不生效。如果你实体用的是LocalDateTime,需要额外引入jackson-datatype-jsr310依赖(SpringBoot 2.x已经自带了),并在配置里加:
spring: jackson: serialization: write-dates-as-timestamps: false或者更省事的方式,在实体类的日期字段上用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),一个个指定。我实际项目里就是所有数据库时间字段都用LocalDateTime+@JsonFormat统一标注,虽然啰嗦了一点,但胜在直观不会出错。
4.3 图片上传:路径配置的经典大坑
宠物服务平台的宠物头像、服务项目封面、资讯配图都涉及文件上传。SpringBoot做上传接口本身很顺畅,接收MultipartFile然后transferTo保存到本地指定目录就完了。但接下来前端访问图片时,问题来了:图片保存到了D:/pet-platform/upload/avatar/20250601/xxx.jpg,前端怎么通过URL访问到它?
办法是做一个静态资源映射,把/api/upload/**的请求映射到本地磁盘目录:
@Configuration public class WebResourceConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/api/upload/**") .addResourceLocations("file:D:/pet-platform/upload/"); } }这里我踩过一个隐藏坑:路径里的file:协议前缀不能丢,否则映射不生效;另外路径末尾的反斜杠/也必须带上,导致我排查了很久。上传后的图片地址存数据库时,我习惯只存相对路径/api/upload/avatar/20250601/xxx.jpg,访问时前端拼上站点域名。这样部署到Linux服务器时,只需要改实体类对应目录映射配置就行,数据库里的数据完全不用动。
5. 前后端分离项目的部署上车指南:从打包到Nginx反向代理
前面所有开发工作完成后,最终要让这个项目跑在服务器上,而不是只停留在本地IDE里。部署环节是很多人的知识盲区,因为本地跑通和服务器跑通之间有大量环境性问题要处理。
5.1 SpringBoot后端打包与配置文件分离
后端打包前要做两件事:检查application.yml里的数据库连接、Redis连接、文件上传路径等配置是否为服务器环境预留了调整空间。我习惯把配置文件拆成三份:
application.yml:公共配置(服务端口、Jackson格式等)application-dev.yml:本地开发环境配置application-prod.yml:生产环境配置
启动时用--spring.profiles.active=prod指定使用哪套配置。这样本地开发用dev,部署用prod,互不干扰。
打包命令在项目根目录执行:
mvn clean package -Dmaven.test.skip=true执行完在target/目录下生成pet-platform-0.0.1-SNAPSHOT.jar。用java -jar pet-platform-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod启动。在服务器上为了管理方便,我写了简单的Shell脚本做启动、停止、重启,本质就是记录PID然后kill -9。
5.2 Vue前端打包与Nginx配置
前端在vue.config.js里配置了开发环境的proxy代理,把/api请求转发到后端8081端口。但打包上线后,devServer的proxy不生效了,需要靠Nginx做真正的代理转发。
前端打包:
npm run build生成dist/目录,把dist目录下的静态文件上传到服务器Nginx的/usr/share/nginx/html/pet-platform/目录下面。Nginx配置的关键块:
server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html/pet-platform; index index.html; try_files $uri $uri/ /index.html; # 解决Vue Router history模式刷新404问题 } location /api/ { proxy_pass http://127.0.0.1:8081; 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 $uri $uri/ /index.html是Vue Router使用history模式(即URL中没有**#**)时必需的配置。如果不加这行,用户在/pet/list页面刷新一下,Nginx会去找服务器上根本不存在的/pet/list文件,返回404。我第一次部署时就栽在这上面,前端页面点击路由正常,一刷新就404,后来查了Nginx文档才明白是history模式需要同步到Nginx配置。
5.3 部署后的接口安全加固:不止是登录鉴权
项目跑起来只是第一步,真正上线前有几项安全加固我认为不能省。
第一,密码传输。虽然数据库存的是BCrypt加密后的密码,但如果前端通过HTTP明文传输密码,中间人可以截获。我建议在Nginx层启用HTTPS(用Let's Encrypt免费证书),至少保证传输链路加密。
第二,接口防刷。登录接口如果不做限制,容易被脚本暴力破解。我用了简单的Redis计数器:同一个IP每分钟最多请求登录接口5次,超过就返回“操作过于频繁”。这个逻辑写在SpringBoot的拦截器或者AOP切面里,很实用。
第三,参数校验。后端接口接收的参数要做好校验,尤其是用户传入的id和petId,必须验证归属权。比如用户A登录了,如果他直接改请求体里的petId来提交一只不属于他的宠物,后端不做校验就会产生脏数据。我统一在Service层的最前面做:“查询实体 → 判断是否存在 → 判断归属者”三步校验,宁可多写几行代码,也不要让接口裸奔。
第四,HTML富文本内容存储常见的XSS问题。发布文章时,如果用户提交的HTML内容里包含<script>标签,前端解析时会执行脚本,形成存储型XSS。我参照社区里常见的做法,写了一个全局过滤器,对请求参数做转义处理,把<替换为<、>替换为>等,但要注意富文本场景会误伤正常的HTML标签。更稳妥的方案是后端用Jsoup库进行白名单过滤,只允许p、img、strong等安全标签存在,其余一律剔除。
6. 关于性能优化和后续扩展,说说我实操中的具体取舍
这个项目做完后,我又盘了盘哪些地方可以优化。说实话,毕设和小型实战项目不需要追求极致的并发和微服务,但有些优化做起来成本低、收益直观,值得动手。
MySQL慢查询优化是我第一个看的。预约列表页如果数据量涨到几万条,没有索引的话全表扫描会很慢。我在appointment表的user_id和appointment_date字段上建了普通索引,查询用户自己的预约时性能立竿见影。另外订单表的order_no字段必须建唯一索引,既保证业务唯一性,又能加速按订单号查找。
Redis缓存这块,我把首页的轮播图数据、热门文章列表缓存到Redis里,缓存时间5分钟,过期后从数据库重新加载并回填。整体代码量不大,但接口响应时间从120ms降到20ms左右,体验提升明显。缓存更新策略上,后台编辑文章内容或上下架服务项目时,手动调用一次删除缓存的方法,这样用户端下一次请求就会重新加载最新数据。
后续如果要扩展功能,我的建议是按“基础模块—增强模块”的思路来:先保证用户、宠物档案、预约、订单、文章评论这几个核心模块的稳定,再考虑接入宠物寄养日历、在线支付、微信小程序端。小程序端和后端接口重用的成本并不高,因为后端接口本来就是无状态的JWT鉴权,前端换一套框架接同一套接口即可。
文档和接口测试方面,我维护了一份Swagger接口文档,SpringBoot 2.7.x配合springfox-boot-starter(3.0.0版本)即可,但要注意SpringBoot 2.6之后的PathPatternMatcher和springfox的路径匹配策略冲突,需要在application.yml里设置spring.mvc.pathmatch.matching-strategy=ant_path_matcher。这个坑也是我查了半天的,配置前接口文档直接白屏,加上配置后秒出所有接口列表。
7. 我做完这个项目后,建议你重点关注的三件事
项目收工后我复盘了一下,如果要给后面做类似宠物服务平台或SpringBoot前后端分离项目的朋友一些建议,核心就三条。
第一,状态流转一定要集中管理。预约、订单这些核心业务都有生命周期,把状态判断的逻辑集中在一个Service类里管理,前端展示的按钮根据状态动态渲染。宁可前期多设计几个状态枚举,也不要图省事用零散的if-else分散在Controller、Service各处。等项目做到后期你会发现,状态流转清晰是整个系统可维护性的最大保障。
第二,统一响应体和全局异常处理必须在一开始就建好。我见过很多项目,R类写到一半,有的是返回data,有的是直接返回一个Map,前后端对接时所有人都在问“这个接口返回什么结构”。从第一个接口开始就返回R.success(data),错误时抛BusinessException,全局捕获后返回R.error(code, msg),这个习惯能省掉后续90%的接口联调摩擦。
第三,安全相关的配置不要留到最后。大家很容易把跨域、密码加密、ThreadLocal清理、静态资源映射这些问题当作“旁枝末节”放到最后。真到项目联调阶段,这些问题会一次性涌出来,排查起来非常崩溃。我后面接类似项目时,都是在一开始搭建项目骨架时就把跨域配置、JWT拦截器、全局异常处理、统一响应体全部配好,后面每写一个接口都是在既定框架里填业务代码,体验和在烂代码上打补丁是完全两回事。
如果你正准备动手做类似的项目,希望这篇记录能帮你避开我已经踩过的坑,少走几步弯路。做完一个完整项目最大的意义,不是代码量写了多少,而是从头到尾把“用户状态、业务单据、状态流转、接口协议、部署方案”这一整条链路跑通一遍。这份经验,在只看理论知识点的时候是永远学不到的。