又到毕业设计选题季和各路程序员找项目练手的节点,宠物店管理系统在Java方向的热度一直居高不下。我自己做毕设辅导和全栈项目交付这些年,基于Java SpringBoot/SSM + Vue + uniapp这套组合的宠物店系统,前前后后落地了不少。这篇文章把这类项目的完整设计过程、核心模块实现、移动端适配和部署细节整体复盘一遍,从数据库设计到接口鉴权,从后台管理到微信小程序端,最后再聊聊那些不写到文档里但一定会遇到的坑。不管你是拿它做毕业设计,还是想练手完整的全栈项目,这篇内容都能给你一份可以直接参考的路线。
1. 项目定位与总体设计思路
1.1 这系统到底解决了什么问题
先捋清楚需求。宠物店管理系统的核心业务并不复杂,但涉及的角色和流程比普通单表CRUD要丰富很多。日常运营里,一家宠物店要处理的事情大致有这么几块:宠物用品和粮食的商品售卖、洗澡美容寄养这类服务项目的预约安排、客户宠物档案的维护、会员充值折扣、员工排班和业绩统计。传统的做法是前台手写登记、Excel管库存、微信聊天约时间,数据散落各处,老板想看一眼这个月美容项目做了多少单都费劲。
系统的目标就是把这些零散的信息统一收拢到一个平台上。我的项目里把使用者拆成三个端:管理员端、员工端、客户移动端。管理员用PC后台管理商品、服务项目、订单、会员、员工信息和经营数据;美容师或店员用同一套PC后台处理自己的工作台,也可以单独配一个员工端页面;客户通过微信小程序完成注册登录、宠物档案维护、服务预约、商品下单和会员充值。
这三个角色对应的技术落点分别是:Vue搭建的PC管理后台、uniapp开发的移动端小程序、Java后端统一提供RESTful接口。系统最终覆盖的完整链路是:客户在小程序看服务项目和商品 → 提交预约或下单 → 后端校验库存和预约时间 → 生成订单 → 店员在后台接单处理 → 完成后更新状态 → 前端实时可查。
如果你是第一次做这类全栈项目,这个业务闭环并不复杂,但涉及了权限控制、时间冲突校验、库存一致性、多端联调这些经典技术点,做完一遍对应届生面试或者毕设答辩来说,素材是相当够用的。
1.2 为什么选SpringBoot/SSM + Vue + uniapp这套组合
技术选型是这类项目第一个要回答的问题。现在网上很多资料把SpringBoot和SSM说成两个对立的东西,其实严格讲,SpringBoot本身整合了Spring和SpringMVC,它对SSM(Spring + SpringMVC + MyBatis)是封装和自动配置的关系。我这边的实践惯例是:持久层用MyBatis-Plus,框架基础用SpringBoot,服务层和控制器保持SpringMVC的分层思路,对外可以统一叫SpringBoot/SSM技术栈。
选SpringBoot最大的好处是配置量大幅下降。相比以前要用XML配置数据源、事务、扫描包,SpringBoot通过starter依赖加application.yml就能把环境拉起来,对毕设和中小型项目来说,开发效率提升的幅度是肉眼可见的。如果你在学校课程里先学了SSM那套XML配置,再上手SpringBoot会有一个明显感受:原来调半天的配置,现在几行搞定,底层的容器、懒加载、代理机制并没有变,变的只是封装方式。
管理后台选Vue之前可以先想清楚一个问题:为什么要SPA(单页面应用)?管理后台的操作密集,商品列表翻页、订单状态切换、会员充值时希望页面不刷新就更新数据,Vue的响应式数据绑定和组件化在这里非常顺畅。我用的搭配是Vue 3 + Vite + Element Plus,Vite启动速度快,Element Plus的表格、表单、弹窗组件对后台开发来说几乎全覆盖。
移动端用uniapp的核心原因是多端复用。宠物店的客户入口最合适的形态是微信小程序,但店主往往还想以后出个App或者在H5里也能访问。uniapp一套代码可以编译到微信小程序、App、H5三端,业务代码不用重写,只有少量平台差异需要处理。这比单独用原生小程序开发或者单独写一套Android要划算得多,也是现在很多实际项目的选型逻辑。
1.3 角色权限和路由设计怎么拆
权限设计上我没有引入Spring Security那套重家伙,对于这个体量的系统,用JWT + 拦截器 + 前端路由守卫就能实现清晰的角色控制。系统内置三个角色:管理员、员工、客户,分别用整数类型标识(1-管理员,2-员工,3-客户)。后端拦截器校验token的同时把角色信息取出来,接口粗粒度分为三类:管理端接口统一带/admin前缀,员工端和管理员共享部分接口,小程序端接口带/app前缀。
前端路由这块,Vue管理后台用路由守卫配合meta信息控制页面访问权限。管理员能看到全部菜单,员工只看到自己的工作台和订单处理页。客户的小程序端则完全走另一套页面结构,业务上不交叉。这个设计在实际开发中省了不少事——不用在每个接口里都写一段权限判断,拦截器统一把关,路由层再兜一次,基本不会有越权访问的情况。
2. 数据库设计与核心表结构
2.1 核心表怎么拆
数据库设计决定了这个项目的上限。我常用的表结构大致有十张左右,按业务模块划分:用户表、宠物档案表、商品表、服务项目表、预约表、订单表、订单明细表、会员等级表、充值记录表、轮播图表。下面把核心字段列出来讲。
用户表包含id、用户名、密码(BCrypt加密)、昵称、手机号、头像、角色、会员等级id、余额/积分、创建时间。宠物档案表包含id、用户id、宠物昵称、品种、性别、生日、体重、是否绝育、疫苗接种情况、备注。商品表包含id、商品名、图片、分类、价格、库存、上架状态、销量、描述。服务项目表包含id、项目名、图片、适用宠物类型、服务时长、价格、库存(美容师每日可接单量间接控制)、描述。预约表包含id、用户id、宠物档案id、美容师id、服务项目id、预约日期、开始时间、结束时间、状态。订单表包含id、订单号、用户id、总金额、实付金额、支付方式、订单类型(商品/服务)、状态、创建时间。
订单明细表把商品订单拆成多行,因为一单可能买了狗粮又买了玩具,每个商品单独一行方便核对。充值记录表则记录会员储值操作,和余额字段联动。整体关系上,用户对宠物档案是一对多,一张订单对应多个订单明细,一次预约关联一个宠物和一个服务项目。
2.2 宠物档案和预约表是设计重点
宠物档案单独建表是我在这个项目里特别强调的点。很多新手容易把宠物信息直接做成用户表的一个字段或者一张简单表,但实际业务里一个客户可能养了三只狗,每只狗的品种、疫苗记录、体重都不一样。美容服务前要核对疫苗情况,寄养时要记录喂养习惯,这些信息挂在宠物维度上才合理。所以用户和宠物必须拆成一对多,预约和服务记录都通过宠物档案id关联。
预约表是整个系统里业务规则最复杂的一张表。字段上我要求同时存预约日期、开始时间、结束时间,而不是只存一个预约日期。因为美容服务是按时间段排的,同一个美容师在同一个时间段不能同时服务两位客户。假设上午十点到十一点安排了一只泰迪的造型,那这个时段就不能再接别的单。如果表里只存一个日期,这个时间冲突校验完全没法做。
状态字段我设计成整数枚举,常用的值有:1-待确认、2-已确认、3-进行中、4-已完成、5-已取消。这里有一个容易忽略的点:待确认和已确认的状态都必须参与时间冲突校验,因为不管商家有没有确认,这个时间段已经被占用了。如果只认为“已确认”才算占用,那客户狂提单子就能把美容师的时间全部锁死。
2.3 这些字段设计最容易踩坑
字段命名和类型选择上,有几个位置是高频踩坑点。金额字段一律用DECIMAL(10,2),千万别用FLOAT或DOUBLE,浮点数在做金额累加时会有精度丢失,订单金额显示成176.999999这种事情一旦发生,客户第一反应就是系统有毛病。折扣率用DECIMAL(3,2)存0.85这类比例值,计算完的最终金额在下单时直接落库快照,不要再动态去算,避免后续改价导致历史订单金额对不上。
时间字段用DATETIME,在Java侧用LocalDateTime接收。这里需要注意前端传参格式,uniapp和Vue默认传出来的是2025-05-01T10:00:00这种ISO格式,后端如果直接拿@RequestBody映射到LocalDateTime会报格式错误。解决方法是统一配置Jackson的全局日期格式,或者在前端序列化时把T替换成空格。这个问题在联调阶段几乎必现一次,先有个心理准备。
还有一个细节是软删除。商品下架和用户注销我推荐用逻辑删除字段而不要物理删除,MyBatis-Plus的@TableLogic注解可以让你在查询时自动过滤已删除数据。原因很简单:订单表和商品表有外键关系,物理删除会导致历史订单里查不到商品名称,对账和业绩统计都会缺数据。
3. 后端核心模块与关键接口
3.1 JWT登录鉴权与拦截器
登录模块我用的是JWT(JSON Web Token)。流程是:客户端把用户名密码传给后端,后端校验通过后用密钥签发一个token返回,客户端存起来,后续每次请求在header里带上,后端拦截器验证token有效则放行,无效则返回401。
具体实现上,引入jjwt依赖,生成token时把用户id、角色放进claims,设置过期时间。我这里设置的是24小时,小程序端用户不用频繁登录,后台管理端的安全性要求更高,可以单独给管理端token设短一些。
String token = Jwts.builder() .setSubject(userId.toString()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();拦截器这边写一个AuthInterceptor,继承HandlerInterceptorAdapter,在preHandle里从请求头获取token,解析失败或者过期就返回统一的JSON错误提示。注册拦截器时要注意放行路径:登录接口、注册接口、小程序首页的商品和服务列表这类公开接口需要排除,其他路径全部拦截。
密码存储用BCryptPasswordEncoder加密。明文密码入库是毕设里最常见的扣分点,一旦被老师或者面试官问到,回答“没加密”就直接暴露了安全意识薄弱。BCrypt是单向散列加随机盐的实现,同一个密码每次加密结果都不同,比MD5加固定盐更安全,而且Spring Security框架里可以直接用这个类,不用引额外依赖。
3.2 预约时间冲突校验的实现
预约冲突校验是这个系统最有技术含量的一个点。业务规则是:同一个美容师、同一天、同一段时间内不能有两个预约。在数据库查询层,用一个条件组合就可以判断区间是否有交集。
SELECT COUNT(*) FROM appoint_record WHERE beautician_id = #{beauticianId} AND appoint_date = #{appointDate} AND status IN (1, 2, 3) AND start_time < #{endTime} AND end_time > #{startTime}这里的逻辑要细品一下:两个时间段[a, b)和[c, d)如果没有交集,条件必然是b <= c或者d <= a。那有交集的否定条件就是start_time < 新结束时间并且end_time > 新开始时间。这个写法比单独比较“开始时间是否在已有区间内”或者“结束时间是否在已有区间内”要严谨得多,因为新预约可以完全包含在已有区间里,也可以横跨已有区间,那两种简单比较都覆盖不了。
实际代码中,我做完查询后如果count > 0就直接返回业务异常,提示“该时间段已被预约”。同时还需要注意边界情况:假设已有预约是10:00到10:30,新预约是10:30到11:00,这两个区间是相邻不重叠的,用严格小于是正确的。如果美容师设置的服务时长是30分钟,预约开始时间按半小时粒度取,那这个校验已经覆盖了常见场景。
3.3 库存扣减与数据一致性
商品下单时的库存扣减是一个经典并发问题。常规做法是先查库存,判断库存够不够,够的话再执行UPDATE减库存。但两个请求同时查到库存为1时,都会通过判断,然后各自减1,最后库存变成-1,这就是超卖。
解决超卖最简单的做法是使用条件更新语句,把库存判断放进SQL的WHERE条件里。
UPDATE goods SET stock = stock - #{num} WHERE id = #{id} AND stock >= #{num}这条SQL的语义是:只有当当前库存大于等于购买数量时才执行扣减。数据库行锁会保证同一时刻只有一个事务能成功更新这一行,另外一个事务更新时发现影响行数为0,就说明库存不足。代码层面判断受影响行数,为0则抛出“库存不足”的提示。这种方式不需要引入Redis分布式锁,也不需要乐观锁版本号,单库场景下性能足够好,代码又少,非常适合这个体量的系统。
订单生成时还要保证和库存扣减在同一个事务里。在Service方法上加@Transactional注解,先插入订单记录,再执行更新库存,任何一个步骤失败整个事务回滚,保证不会出现“订单生成成功但库存没扣”或者“库存扣了但订单没有”的不一致状态。
3.4 会员折扣计算逻辑
会员模块很多人做成摆设,无非建了一张会员表,没实际参与价格计算。我这里让折扣真正生效。方式是在用户表里存等级id和折扣率,下单时读取用户的折扣率,把商品原价乘以折扣率得到实付金额。
这里有一个细节:服务项目和商品的折扣逻辑要区分。商品可能本身在参加满减活动,此时会员折扣不一定叠加;服务项目则直接按会员折扣走。我在订单表里增加一个discount_amount字段,记录本次订单优惠了多少钱,方便对账。比如一件商品原价120元,会员九折,优惠金额就是12元,实付108元。
充值模块和折扣要联动处理。常见的模式是充300送50、充500送100,这种活动的结算逻辑用一个独立的充值规则方法处理,保存到充值记录表,同时更新用户余额字段。还要防止充值金额同时享受折扣的歧义——充值的金额本身不参与打折,赠送金额计入余额但不可提现。这些规则不复杂,但在需求阶段没理清的话,写代码的时候会反复改。
4. 管理后台与移动端实现要点
4.1 Vue管理后台的权限路由与请求拦截
PC管理后台我用Vue 3 + Element Plus + Vite实现,页面结构按后台系统的经典布局来:左侧菜单栏、顶部导航、主要内容区。路由分为公共路由和权限路由,公共路由只有登录页,登录后根据角色动态添加路由。
实现上,在路由守卫的beforeEach里判断是否登录,未登录跳转登录页。已登录用户根据角色拼接菜单数组,用router.addRoute动态注册。这里不要把全部路由都静态声明然后靠隐藏来控制显示,因为那样用户直接在地址栏输路径就能访问未授权页面。虽然后端接口有鉴权兜底,但前端路由层面的拦截能省掉很多无效请求。
axios请求封装是一个必须认真处理的部分。所有请求统一走一个实例,请求拦截器里从localStorage取出token加到请求头,响应拦截器里判断HTTP状态码,如果返回401说明token失效,清理本地登录信息并跳回登录页。这样写的好处是业务代码里不用每个接口都处理鉴权逻辑,统一在一个地方解决问题。
service.interceptors.response.use( response => response.data, error => { if (error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )4.2 uniapp小程序端的请求封装与多端适配
小程序端是客户使用的主要入口,也是整个项目联调工作量最大的部分。我用uniapp开发的页面包括:首页(展示服务项目、轮播图、公告)、商品列表与详情、预约页面、宠物档案管理、订单列表、个人中心。页面跳转用uni.navigateTo,底部TabBar配置四到五个入口。
请求封装要特别处理一个问题:不同端的baseURL不一样。在H5开发时用http://localhost:8080,在微信开发者工具里用局域网IP,真机预览时也要改成电脑的局域网IP。我的做法是用条件编译区分。
// #ifdef H5 const BASE_URL = 'http://localhost:8080' // #endif // #ifdef MP-WEIXIN const BASE_URL = 'http://192.168.1.100:8080' // #endif这个改起来很琐碎,但确实没有一步到位的办法。开发阶段微信小程序必须关闭“合法域名校验”,不然请求会被拦截。正式上线时要把后端接口配到HTTPS域名下,并在小程序后台把这个域名加入request合法域名列表。
预约页面是移动端交互最复杂的页面。用户先选宠物(从宠物档案里选),再选服务项目,再选日期和时段。日期选择用picker组件的mode="date",服务项目列表加载后显示服务时长和价格,选择完调用查询接口获取该美容师当前日期剩余的可用时段。这里前端要配合后端的时间冲突校验,如果后端返回冲突就提示用户换一个时间。
4.3 从开发调试到上架的关键步骤
小程序端的调试和上架流程有几个容易卡住的环节。开发阶段我建议用微信开发者工具,它会自动监听uniapp的编译输出。每次在HBuilderX里点击“运行到小程序模拟器”,项目会被编译到dist/dev/mp-weixin目录,开发者工具打开这个目录即可调试。
打包发布时,在HBuilderX里选择“发行 -> 小程序”,生成发布模式的包,然后在微信开发者工具里上传代码。上传前要填好manifest.json里的小程序AppID,不然上传不成功。这里还有一个很典型的坑:本地调试时一切正常,发布后请求全部失败。原因就是上一条说的域名校验——开发者工具调试模式可以关闭校验,正式版必须配置合法域名。所以我建议在开发时就把接口地址写成正式环境的HTTPS域名,本地通过代理或者直接修改host的方式联调,上线时就不用到处找代码改地址了。
5. 常见问题与踩坑复盘
5.1 跨域、时区、日期格式化三连坑
这三个问题虽然不是很大,但各能卡住开发进度一阵子。
跨域问题出现在Vue管理后台调用后端接口时。前端运行在localhost:5173,后端运行在localhost:8080,浏览器默认是不允许跨域请求的。解决办法是在后端写一个CORS配置类,允许指定来源访问。
registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowCredentials(true);需要注意的是,如果前端请求带了token这种自定义header,allowedHeaders要设为*或者明确列出,否则预检请求直接失败。
时区问题比较隐蔽。数据库连接配置里如果serverTimezone没有设置,本地和服务器跨时区部署时可能会出现日期字段相差八小时。我一般统一在application.yml里配成serverTimezone=Asia/Shanghai,同时JDBC连接串用useSSL=false&characterEncoding=utf8,避免中文乱码。
日期格式化问题前面提过,前端传给后端的ISO时间格式需要后端统一处理。我在启动类或者配置类里加一个Jackson全局配置,把LocalDateTime的反序列化格式设为yyyy-MM-dd HH:mm:ss,前端传2025-05-01 10:00:00就能直接映射成功。
5.2 真机调试连不上、打包后行为不一致
真机预览小程序时,请求后端接口经常出现“网络不给力”的提示。这里有一个容易忽略的点:手机和电脑必须在同一个局域网内,而且后端服务要监听0.0.0.0而不是默认的localhost。SpringBoot可以通过在application.yml里配置server.address=0.0.0.0来监听所有网卡,这样局域网内其他设备才能访问到。
打包后行为不一致,比较典型的是图片显示不出来。开发时后端图片上传到本地目录,数据库存的是/upload/xxx.jpg这类相对路径。开发环境前端访问http://localhost:8080/upload/xxx.jpg没问题,一旦部署到服务器,如果没做静态资源配置或没把目录映射到nginx,图片全部404。解决办法是后端配置资源映射,把/upload/**映射到真实的物理存储目录,或者用nginx把/upload路径指到指定文件夹。我的习惯是提前把上传目录做成可配置项,部署时只要改配置文件路径就行,不用动代码。
5.3 分页失效、版本不兼容这类隐蔽问题
MyBatis-Plus的分页功能需要手动注入分页插件,这个配置遗漏了,Page对象查出来的数据是全量而不是分页结果。这个问题几乎每个用MyBatis-Plus的新手都会踩一次,现象是前端列表页全显示出所有数据,数据量大时页面卡死。记得在配置类里加上PaginationInnerInterceptor,分页才能生效。
版本不兼容是个大坑。SpringBoot 3.x要求JDK17以上,如果你的本机环境是JDK8,直接新建SpringBoot 3项目会有一堆编译错误。做毕设和大部分企业项目,我推荐用SpringBoot 2.7.x搭配JDK8,生态成熟,网上资料也多,各种依赖不会出现找不到适配版本的问题。如果确实要用SpringBoot 3,那JDK版本必须同步升级到17,同时要注意部分旧版依赖(比如某些javax开头的包要改成jakarta)需要跟着调整。
还有一个关于mp-html的使用细节。小程序端富文本内容渲染不能直接用v-html,需要在控制器里引入mp-html组件。这个组件能解析富文本编辑器里生成的HTML内容(比如服务项目的图文详情介绍),用的时候记得在小程序页面配置里插入对应节点,不然页面上只会显示一串HTML源码,看起来很掉价。
个人经验与收尾
做了这么多套管理系统项目,我最大的体会是:这类项目真正拉长开发周期的不是某个技术难点攻克不下来,而是前后端联调阶段那些零零碎碎的接口字段对不齐、状态码约定不一致、时间格式互相不认识的小问题。所以我现在的习惯是动手写代码之前,先把接口文档用表格理清楚,每个接口的入参、出参、状态码定义好,前后端各拿一份照着做,联调时的摩擦能减少一大半。
如果你准备拿这个项目去做答辩或者面试,我建议重点把两块代码吃透:预约时间冲突校验和库存扣减。这两个逻辑最能体现工程思维,面试官问起并发、幂等、数据一致性这类问题,都能从这里找到实际案例来展开回答。另外部署文档不要放到最后才补,边开发边记录环境和步骤,最后整理起来会轻松很多。
有一说一,这类系统做完,你基本就把Java后端、Vue管理端、uniapp移动端这三条主线的开发套路全走了一遍。后面不管换什么业务场景,核心的架构和联调经验都是通用的。