简介:这份源码面向Java后端初学者与需要课程设计、毕业设计参考的开发者,提供一套宠物店猫咖管理系统的后端实现方案,可用于学习Java Web项目结构、MVC分层与数据库交互等核心技能。压缩包共38个文件,约52KB,以19个Java源文件承载业务逻辑,8个XML配置文件负责框架与参数配置,另有5个class文件、2个properties属性文件、1个JSP页面及iml、gitignore等工程辅助文件,整体结构紧凑,便于快速导入IDE运行与二次开发。系统围绕宠物信息管理、客户管理、预约服务、库存管理与销售统计等模块展开,覆盖资料录入查询、消费记录跟踪、服务资源安排与销售报表分析等典型场景。目前已有433人学习下载,适合作为中小型管理系统的入门范本,帮助读者理解后端分层设计、配置组织方式与功能模块拆分思路。
1. 宠物店猫咖管理系统后端:从排班混乱到数据打通的落地路径
一家同时经营宠物洗护、猫咖计时和活体零售的门店,最容易崩在排班上。上午十点猫咖满座、洗护间排队、收银台还在手工记会员卡余额,三个岗位用三套本子,晚上对账对到怀疑人生。宠物店猫咖管理系统后端设计要解决的,就是把会员、宠物档案、服务工单、猫咖台位、库存和营收这几条线收进同一套数据模型里,让前台一次操作能同时扣台位时长、生成洗护工单、记会员消费。这套后端适合谁做?一是接门店定制外包的 Java 开发者,二是想给自己店做一套自用系统的店主兼程序员,三是拿它当 Spring Boot 综合练手项目的在校生。源码层面的价值不在界面多花哨,而在业务表怎么拆、状态怎么流转、并发台位怎么不超卖。下面按我实际搭过的一套结构,从选型一路讲到能跑起来的最小闭环。
2. 后端技术选型与数据模型:为什么是 Spring Boot 加 MyBatis
2.1 选型理由:别一上来就上微服务
宠物店猫咖这类单店或三五家连锁的场景,日订单量撑死几百单,QPS 峰值出现在周末下午,也就几十。这种量级上 Spring Cloud 那一套注册中心、网关、配置中心,纯属给自己找运维负担。我一般会选 Spring Boot 单体 + MyBatis + MySQL,理由很实在:部署就一个 jar,门店一台小主机或一台云服务器就能跑;MyBatis 对复杂查询和手写 SQL 友好,宠物店的报表统计(比如某只猫这个月洗了几次、某会员卡剩余次数)用 XML 写比 JPA 拼条件清爽得多。
技术栈落到具体版本,我常用的是 JDK 17 + Spring Boot 3.x + MyBatis-Plus + MySQL 8 + Redis。Redis 只干两件事:猫咖台位计时用的临时状态、登录 token。别拿它当主存储,门店断电重启后 Redis 数据丢了,台位时长还得能从数据库恢复,所以计时结果要定期落库。
提示:如果团队只有你一个人,别碰分库分表和消息队列。先把单表索引和事务边界理清楚,比什么架构都值钱。
2.2 核心表结构:六张表撑起一个店
数据模型是这套后端的骨架,拆错了后面全是补丁。我踩过的坑是早期把「服务」和「商品」混在一张表,结果洗护要记宠物、零售要记库存,字段互相打架。后来拆成下面这几张核心表,逻辑就顺了。
| 表名 | 作用 | 关键字段 |
|---|---|---|
| member | 会员账户 | id, phone, balance, card_times, level |
| pet | 宠物档案 | id, member_id, name, breed, weight, note |
| service_item | 服务/商品定义 | id, type(洗护/零售/猫咖), name, price, duration |
| order_main | 订单主表 | id, member_id, total, status, pay_type, created_at |
| order_item | 订单明细 | id, order_id, item_id, pet_id, qty, price |
| cafe_seat | 猫咖台位 | id, seat_no, status, start_time, order_id |
这里有个设计取舍:猫咖计时到底算订单还是算台位状态?我的做法是两者都要。cafe_seat 记录当前占用和开始时间,用于实时展示和超时提醒;结算时再生成一条 order_main,把时长换算成金额写进 order_item。这样即使计时中途系统重启,也能从 start_time 重新算,不会丢单。
2.3 用 MyBatis-Plus 生成基础 CRUD
建表之后,实体和 Mapper 不用手写。MyBatis-Plus 的代码生成器能省掉大量重复劳动,配置一次就能把六张表的增删改查全生成出来。
// CodeGenerator.java —— 一次性生成实体、Mapper、Service public class CodeGenerator { public static void main(String[] args) { FastAutoGenerator.create( "jdbc:mysql://localhost:3306/petcafe?useUnicode=true&characterEncoding=utf8", "root", "your_password") .globalConfig(builder -> builder .author("dev") .outputDir(System.getProperty("user.dir") + "/src/main/java") .disableOpenDir()) .packageConfig(builder -> builder .parent("com.petcafe") .entity("entity") .mapper("mapper") .service("service")) .strategyConfig(builder -> builder .addInclude("member", "pet", "service_item", "order_main", "order_item", "cafe_seat") .entityBuilder().enableLombok() .mapperBuilder().enableBaseResultMap()) .execute(); } }逻辑说明:addInclude里列出要生成的表,避免把无关表也扫进来。enableLombok让实体自动带 getter/setter,省掉几百行样板代码。参数上,outputDir指向你的源码根目录,parent是基础包名,改这两处就能适配自己的工程。生成完先别急着写业务,把生成的实体字段和数据库列对一遍,尤其是created_at这类下划线命名,确认驼峰映射开了没有,否则查出来全是 null,这种玄学问题排查起来很费时间。
3. 会员、宠物与订单:三条业务线的接口实现
3.1 会员与宠物档案的绑定关系
会员和宠物是一对多,一个会员可以带好几只猫来洗护。接口设计上,新增宠物时必须校验 member_id 存在,删除会员时不能级联删宠物(历史订单还要引用),只能标记会员失效。这是血泪经验:早期做了物理删除,结果老订单查不到宠物信息,对账直接翻车。
@PostMapping("/pet/add") public Result addPet(@RequestBody Pet pet) { Member m = memberMapper.selectById(pet.getMemberId()); if (m == null || m.getStatus() == 0) { return Result.fail("会员不存在或已停用"); } pet.setCreatedAt(LocalDateTime.now()); petMapper.insert(pet); return Result.ok(pet.getId()); }逻辑说明:先查会员再插宠物,保证外键语义。status == 0表示会员已停用,停用会员不允许再挂新宠物。参数上pet.getMemberId()由前端传入,别信前端传的会员等级或余额,那些字段一律后端查库覆盖,防止被改包。
3.2 下单接口:一次事务里干完四件事
下单是整套后端最核心也最容易出并发问题的地方。一次洗护下单,要同时:扣会员卡次数或余额、生成订单主表、写订单明细、如果是猫咖还要占台位。这四步必须在一个事务里,任何一步失败全部回滚。
@Transactional(rollbackFor = Exception.class) public Long createOrder(OrderDTO dto) { // 1. 悲观锁锁定会员,防止并发扣款 Member member = memberMapper.selectForUpdate(dto.getMemberId()); if (member.getCardTimes() < dto.getNeedTimes()) { throw new BizException("会员卡次数不足"); } // 2. 扣次数 member.setCardTimes(member.getCardTimes() - dto.getNeedTimes()); memberMapper.updateById(member); // 3. 写订单主表 OrderMain order = new OrderMain(); order.setMemberId(member.getId()); order.setTotal(dto.getTotal()); order.setStatus(1); // 1=已支付 orderMapper.insert(order); // 4. 写明细 for (OrderItemDTO it : dto.getItems()) { OrderItem oi = new OrderItem(); oi.setOrderId(order.getId()); oi.setItemId(it.getItemId()); oi.setPetId(it.getPetId()); oi.setQty(it.getQty()); oi.setPrice(it.getPrice()); orderItemMapper.insert(oi); } return order.getId(); }逻辑说明:selectForUpdate走的是数据库行锁,同一会员并发下单时后一个请求会等前一个提交,避免次数被扣成负数。rollbackFor = Exception.class保证任何异常都回滚,默认只回滚运行时异常,受检异常不回滚,这个坑很多人踩过。参数上needTimes是本次消耗的卡次数,total是金额,两者分开是因为有的项目按次卡、有的按储值,别混成一个字段。
3.3 猫咖台位计时:Redis 加定时落库
猫咖按小时收费,台位状态要实时。我用 Redis 存seat:no:{seatNo}的占用开始时间戳,前台开台时写入,结账时读出算时长。同时每五分钟有个定时任务把占用中的台位同步到 cafe_seat 表,防止 Redis 丢数据。
// 开台 public void openSeat(String seatNo, Long orderId) { String key = "seat:no:" + seatNo; Boolean ok = redis.opsForValue() .setIfAbsent(key, String.valueOf(System.currentTimeMillis())); if (Boolean.FALSE.equals(ok)) { throw new BizException("台位已被占用"); } cafeSeatMapper.updateStatus(seatNo, 1, orderId); }逻辑说明:setIfAbsent就是 SETNX,保证同一台位只能被一个人开,天然防超卖。参数上存的是毫秒时间戳,结账时(now - start) / 3600000向上取整算小时。注意别用set覆盖写,那样并发开台会互相顶掉,台位就乱了。
4. 并发与数据一致性:台位超卖和余额扣负怎么防
4.1 台位超卖的三种防法对比
猫咖周末满座,两个前台同时点同一个台位开台,如果不做控制就会一台两卖。常见做法有三种,我实际都用过,对比如下。
| 方案 | 实现 | 优点 | 缺点 |
|---|---|---|---|
| 数据库唯一索引 | cafe_seat 加占用唯一约束 | 简单可靠 | 冲突时抛异常要捕获 |
| Redis SETNX | 开台前抢锁 | 快,适合高并发 | 依赖 Redis 可用性 |
| 悲观锁 select for update | 锁行再改状态 | 一致性强 | 并发高时排队 |
单店场景我推荐 Redis SETNX 加数据库兜底:Redis 挡掉绝大部分并发,数据库唯一索引作为最后一道防线。别只靠应用层判断「查一下没占用就开」,那中间有时间窗口,必翻车。
4.2 余额扣负的排查思路
有次上线后发现某个会员余额变成负数,查了半天是退款接口没加锁。退款时先读余额再写回,两个退款请求同时进来,都读到 100,各自加 50 写回,结果少了 50 的账。解决办法和下单一样,退款也走selectForUpdate锁会员行。排查这类问题有个笨办法但很有效:把所有涉及金额变动的接口列出来,逐个检查有没有在事务里先锁行。漏一个就是一个隐患。
注意:金额字段一律用 DECIMAL 或分为单位的整数,别用 float/double,浮点误差累积起来对账能差出几块钱,查起来毫无头绪。
4.3 事务失效的几个隐蔽场景
@Transactional不是万能的,下面几种情况它不生效,我全踩过:同类内部方法直接调用(this 调用不走代理)、方法不是 public、异常被 catch 吞掉没抛出、数据库引擎是 MyISAM 不支持事务。最隐蔽的是第一种,你把下单逻辑抽成一个 private 方法在同类里调,事务注解形同虚设。解决要么把方法挪到另一个 Bean,要么注入自己代理调用。
5. 避坑与排查:上线后最容易翻车的五个点
5.1 时间字段时区不一致
现象:前台显示开台时间是下午三点,数据库里存的是早上七点,差八小时。原因:JVM 时区和 MySQL 时区没对齐,一个 UTC 一个东八区。解决:连接串加serverTimezone=Asia/Shanghai,JVM 启动参数加-Duser.timezone=Asia/Shanghai,两边统一。这个坑不报错,只是数据悄悄错,对账时才发现。
5.2 分页查询总数不准
现象:订单列表第一页显示 10 条,总数显示 8 条。原因:MyBatis-Plus 分页插件没配,或者 count 语句被自定义 SQL 覆盖了。解决:注册PaginationInnerInterceptor,自定义查询时确认 count 逻辑和 list 逻辑的 where 条件一致。别小看这个,前台看到总数比列表还少会直接投诉。
5.3 定时任务重复执行
现象:台位落库任务在集群部署时跑了两遍,数据被写重。原因:多实例都触发了定时任务。解决:单店单实例无所谓,一旦多实例,用 Redis 分布式锁或数据库唯一约束保证同一时刻只有一个实例执行。别用@Scheduled裸跑就上多节点。
5.4 会员卡次数并发扣减
现象:一张 10 次卡,两个前台同时核销,最后扣了 11 次。原因:读改写没加锁。解决:见 3.2 的selectForUpdate,或者用update member set card_times = card_times - 1 where id = ? and card_times >= 1这种带条件的原子更新,判断影响行数是否为 1。
5.5 日志里打印了敏感信息
现象:日志文件里能看到完整手机号和会员余额。原因:调试时随手log.info打了整个对象。解决:用@JsonIgnore或自定义 toString 屏蔽敏感字段,日志级别上线调成 INFO 以上。这个不算功能 bug,但门店数据泄露是实打实的风险。
6. 从能跑到好用:接口压测与灰度上线的具体做法
系统能跑通只是及格线,门店真用起来还得扛住周末高峰。我一般会做两件事:压测和灰度。
压测不用上 JMeter 那么重,用 wrk 或 ab 对下单接口打一轮就够。命令是wrk -t4 -c50 -d30s --latency http://localhost:8080/api/order/create,重点看 P99 延迟和错误率。单店场景 P99 控制在 200ms 以内、错误率为零就算过关。压测时把日志级别调高,否则 IO 会拖慢结果,测出来的数不准。
灰度上线我的习惯是先在门店打烊后部署,第二天早班只让一个前台用新系统,老系统并行留着。观察一天没问题再全量切。别在周末下午高峰期上线,那是拿生意做实验。
再给一个验证数据一致性的小技巧:写个对账脚本,每天凌晨跑一次,比对会员卡次数变动总和与订单消耗次数总和是否相等,不等就告警。这个脚本帮我抓到过两次并发扣减的漏网之鱼,比事后人工翻账本高效得多。
-- 对账:会员卡消耗次数 vs 订单实际核销次数 SELECT m.id, m.card_times AS remain, (SELECT COUNT(*) FROM order_item oi JOIN order_main o ON oi.order_id = o.id WHERE o.member_id = m.id AND o.status = 1) AS used FROM member m WHERE m.card_times < 0;这条 SQL 专门揪出余额或次数为负的会员,跑出来有结果就说明并发控制有漏洞,赶紧回去查锁。
我自己的习惯是每接一个门店系统,先把并发扣减和对账脚本这两块做扎实,界面丑点没关系,账不能错。宠物店老板最在意的就是钱和卡次数对不对得上,这两样稳了,其他都好谈。希望帮到你。
本文还有配套的精品资源,点击获取