☰
基于Java的宠物管理系统:从建表到接口的落地路径
2026/10/25 11:39:25 网站建设 项目流程

简介:这是一套面向Java Web初学者与课程设计需求的宠物管理系统完整项目源码,基于SSM(Spring、SpringMVC、MyBatis)框架搭建,配合Layui前端与MySQL数据库,在MyEclipse环境下开发,可用于宠物店或动物保护机构的信息化管理,也适合作为毕业设计或实训参考。压缩包共1442个文件,约136.6MB,涵盖js、css、jsp等前端资源,java、class、jar等后端与依赖文件,以及png、gif、jpg等界面素材,另含sql脚本、xml配置与说明文档,结构完整。目前已有2172人学习下载。项目覆盖宠物信息、用户资料、领养记录等模块,包含需求分析、数据库设计、编码实现与测试调试全流程,读者可借此理解SSM分层架构、MyBatis持久化操作与Layui组件化界面开发,快速掌握Java Web项目从搭建到部署的实践思路。

1. 基于Java的宠物管理系统:从建表到接口,一套能跑通的落地路径

宠物医院前台还在用Excel登记疫苗时间,宠物店老板靠微信群接龙预约洗澡,这是很多中小型宠物服务门店的真实状态。基于Java的宠物管理系统要解决的就是这件事:把宠物档案、主人信息、预约记录、疫苗提醒、消费流水这些散落的数据收进一个后台,用Spring Boot + MyBatis-Plus搭出一套能查、能改、能提醒的服务端。它适合两类人:一是正在找Java毕业设计或练手项目的开发者,二是想给自家门店做一套轻量管理工具的小团队。整套系统的技术栈不复杂,核心难点不在框架本身,而在表结构怎么设计、宠物和主人的关联怎么处理、疫苗到期提醒怎么定时触发。下面按建表、搭工程、写接口、排坑的顺序,把这条路走一遍。

2. 表结构设计:宠物管理系统最容易翻车的地方

2.1 为什么宠物和主人不能塞进一张表

新手最常见的做法是建一张pet表,里面直接放owner_name、owner_phone、owner_address。这个设计在单宠物家庭还能跑,一旦遇到一个主人养三只猫、两只狗,数据就会重复三到五倍。更麻烦的是主人换了手机号,你得更新所有宠物记录,漏一条就是脏数据。

正确的做法是拆成owner和pet两张表,用owner_id做外键关联。这是面向对象编程里「一个主人对应多个宠物」的一对多关系在数据库层面的直接映射。宠物管理系统里还有几个关系要提前想清楚:一只宠物可以有多条疫苗记录(一对多)、一条预约对应一只宠物和一个服务项目(多对一)、消费流水挂在预约下面(一对多)。把这些关系画出来再建表,比先写代码后改表省事得多。

2.2 建表SQL与字段说明

下面这套SQL覆盖了核心的五张表,字段类型按MySQL 8.0来定。注意pet表里的birthday用DATE而不是DATETIME,因为宠物的出生日期精确到天就够了,用DATETIME反而会在计算年龄时引入时区问题。

-- 主人表 CREATE TABLE `owner` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `name` VARCHAR(50) NOT NULL COMMENT '主人姓名', `phone` VARCHAR(20) NOT NULL COMMENT '手机号', `address` VARCHAR(200) DEFAULT NULL COMMENT '地址', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宠物主人表'; -- 宠物表 CREATE TABLE `pet` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `owner_id` BIGINT NOT NULL COMMENT '主人ID', `name` VARCHAR(50) NOT NULL COMMENT '宠物名', `species` VARCHAR(20) NOT NULL COMMENT '物种:猫/狗/其他', `breed` VARCHAR(50) DEFAULT NULL COMMENT '品种', `gender` TINYINT DEFAULT 0 COMMENT '性别:0未知 1公 2母', `birthday` DATE DEFAULT NULL COMMENT '出生日期', `weight` DECIMAL(5,2) DEFAULT NULL COMMENT '体重(kg)', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_owner_id` (`owner_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宠物表'; -- 疫苗记录表 CREATE TABLE `vaccine_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `pet_id` BIGINT NOT NULL COMMENT '宠物ID', `vaccine_name` VARCHAR(100) NOT NULL COMMENT '疫苗名称', `vaccine_date` DATE NOT NULL COMMENT '接种日期', `next_date` DATE DEFAULT NULL COMMENT '下次接种日期', `remark` VARCHAR(255) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`id`), KEY `idx_pet_id` (`pet_id`), KEY `idx_next_date` (`next_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疫苗记录表'; -- 服务项目表 CREATE TABLE `service_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `name` VARCHAR(100) NOT NULL COMMENT '项目名称', `price` DECIMAL(10,2) NOT NULL COMMENT '价格', `duration` INT DEFAULT 30 COMMENT '预计耗时(分钟)', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务项目表'; -- 预约表 CREATE TABLE `appointment` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `pet_id` BIGINT NOT NULL COMMENT '宠物ID', `service_id` BIGINT NOT NULL COMMENT '服务项目ID', `appoint_time` DATETIME NOT NULL COMMENT '预约时间', `status` TINYINT DEFAULT 0 COMMENT '状态:0待确认 1已确认 2已完成 3已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_pet_id` (`pet_id`), KEY `idx_appoint_time` (`appoint_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约表';

几个参数要重点说。owner表的phone加了唯一索引,因为手机号是主人身份的自然标识,重复注册会导致后续查询混乱。pet表的owner_id和vaccine_record表的pet_id都建了普通索引,这两个字段是关联查询的入口,没索引的话数据量上到几千条就会明显变慢。vaccine_record表的next_date单独建索引,是为了后面做疫苗到期提醒时能快速筛出「未来7天内需要接种」的记录。

注意:外键约束在互联网项目里通常不建物理外键,靠应用层保证一致性。上面SQL只建了索引没建FOREIGN KEY,这是常见做法,避免级联操作锁表。

2.3 用MyBatis-Plus根据实体类反向生成建表SQL

热搜里有人问「mybatisplus根据java实体类生成创建表的sql语句」,这个需求在宠物管理系统里很实用——先写实体类再生成表,比手写SQL快。MyBatis-Plus本身不直接提供这个功能,但可以借助它的TableInfoHelper拿到实体类的元信息,再拼SQL。下面是一个简化版的工具类思路:

// 核心逻辑:读取实体类注解,拼出CREATE TABLE语句 public class TableSqlGenerator { public static String generate(Class<?> entityClass) { TableInfo tableInfo = TableInfoHelper.getTableInfo(entityClass); StringBuilder sql = new StringBuilder("CREATE TABLE `") .append(tableInfo.getTableName()).append("` (\n"); // 遍历字段,取@TableField注解的value作为列名 for (TableFieldInfo field : tableInfo.getFieldList()) { sql.append(" `").append(field.getColumn()).append("` ") .append(field.getSqlType()).append(" COMMENT '") .append(field.getComment()).append("',\n"); } sql.append(" PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); return sql.toString(); } }

这段代码的关键在于TableInfoHelper.getTableInfo(),它要求实体类已经被MyBatis-Plus扫描过,所以调用时机要放在Spring容器启动之后。field.getSqlType()返回的是Java类型映射后的数据库类型,但精度和长度需要自己根据@TableField里的配置补全。实际用的时候,我一般会把这个生成结果打印到日志里,人工核对一遍再执行,直接跑容易因为类型映射不精确导致字段长度不对。

3. Spring Boot工程搭建与核心接口实现

3.1 依赖选型与工程结构

宠物管理系统的后端用Spring Boot 3.x + MyBatis-Plus 3.5.x是当前比较稳的组合。Spring Boot 3要求JDK 17起步,如果本地环境还是JDK 8,要么升JDK,要么把Spring Boot降到2.7.x。热搜里「java环境变量使用多个jdk」和「java启动失败怎么解决」这两个问题,多半就是JDK版本和Spring Boot版本对不上导致的。

pom.xml里核心依赖就三个:spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-j。如果要做疫苗提醒的定时任务,再加一个spring-boot-starter-quartz或者直接用Spring自带的@Scheduled。工程结构按标准的分层来:controller、service、mapper、entity、dto,别把所有类塞一个包,后面改起来会疯。

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

application.yml里配好数据源和MyBatis-Plus的日志:

spring: datasource: url: jdbc:mysql://localhost:3306/pet_manager?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true

map-underscore-to-camel-case必须开,否则数据库的owner_id映射不到Java的ownerId,查出来全是null。serverTimezone要显式指定,不写的话MySQL 8会报时区错误。

3.2 宠物档案的增删改查接口

实体类用MyBatis-Plus的注解标注,主键策略用ASSIGN_ID(雪花算法)还是AUTO(数据库自增)看场景。宠物管理系统数据量不大,用AUTO更直观。

@Data @TableName("pet") public class Pet { @TableId(type = IdType.AUTO) private Long id; private Long ownerId; private String name; private String species; private String breed; private Integer gender; private LocalDate birthday; private BigDecimal weight; private LocalDateTime createTime; }

Mapper接口继承BaseMapper<Pet>就自动有了单表CRUD。Service层写业务逻辑,比如新增宠物时要先校验ownerId是否存在:

@Service public class PetService { @Autowired private PetMapper petMapper; @Autowired private OwnerMapper ownerMapper; public boolean addPet(Pet pet) { // 校验主人是否存在,避免孤儿宠物记录 if (ownerMapper.selectById(pet.getOwnerId()) == null) { throw new BizException("主人不存在,请先录入主人信息"); } return petMapper.insert(pet) > 0; } // 分页查询某主人的所有宠物 public Page<Pet> listByOwner(Long ownerId, int page, int size) { return petMapper.selectPage(new Page<>(page, size), new LambdaQueryWrapper<Pet>().eq(Pet::getOwnerId, ownerId)); } }

LambdaQueryWrapper的好处是字段名用方法引用写,编译期就能发现拼写错误,比字符串写"owner_id"安全。分页查询记得配MyBatis-Plus的分页插件,不配的话selectPage不会真正分页,会把全表查出来再内存分页,数据一多就OOM。

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

3.3 疫苗到期提醒的定时任务

疫苗提醒是这个系统里最有业务价值的功能。思路是每天凌晨跑一次任务,查出next_date在「今天到未来7天」之间的记录,然后给对应主人发通知(短信、站内信或日志)。

@Component public class VaccineReminderTask { @Autowired private VaccineRecordMapper vaccineMapper; // 每天凌晨2点执行 @Scheduled(cron = "0 0 2 * * ?") public void remind() { LocalDate today = LocalDate.now(); LocalDate deadline = today.plusDays(7); List<VaccineRecord> list = vaccineMapper.selectList( new LambdaQueryWrapper<VaccineRecord>() .between(VaccineRecord::getNextDate, today, deadline) .isNotNull(VaccineRecord::getNextDate)); for (VaccineRecord record : list) { // 这里替换成实际的短信或推送逻辑 log.info("宠物{}的{}疫苗将于{}到期,请提醒主人", record.getPetId(), record.getVaccineName(), record.getNextDate()); } } }

cron表达式0 0 2 * * ?表示每天2点整执行。between查询用到了next_date上的索引,几千条数据毫秒级返回。要注意的是,如果系统部署了多个实例,定时任务会重复执行,这时候要么用分布式锁,要么把任务单独部署。单机部署的话,在启动类上加@EnableScheduling就能跑起来。

4. 接口联调与数据一致性排查

4.1 预约状态流转的并发问题

预约表里的status字段有四个状态,从「待确认」到「已确认」再到「已完成」,中间还可能被取消。如果两个管理员同时操作同一条预约,一个点确认一个点取消,最后状态就乱了。常见做法是在更新时加状态条件:

// 只有当前状态是待确认时才能改成已确认 boolean updated = appointmentMapper.update(null, new LambdaUpdateWrapper<Appointment>() .eq(Appointment::getId, id) .eq(Appointment::getStatus, 0) .set(Appointment::getStatus, 1)) > 0; if (!updated) { throw new BizException("预约状态已变更,请刷新后重试"); }

这段代码利用MySQL的行锁,update语句带status=0条件,如果状态已经被别人改了,影响行数为0,直接抛异常。这比先查再改的两步操作可靠,避免了并发窗口。

4.2 宠物删除时的关联数据处理

删除一只宠物时,它的疫苗记录和预约记录怎么办?三种策略:级联删除、软删除、禁止删除。宠物管理系统的业务场景下,我一般用软删除——在pet表加deleted字段,删除时只改标记,查询时过滤掉。这样历史预约和消费记录还能关联到宠物信息,不会出现「预约记录里宠物是null」的情况。

// 软删除:MyBatis-Plus的@TableLogic注解自动处理 @TableLogic private Integer deleted;

加了@TableLogic之后,deleteById会自动变成UPDATE pet SET deleted=1 WHERE id=?,所有查询自动带上deleted=0条件。这个注解省事,但要注意它和自定义SQL的配合,手写的XML查询不会自动加过滤条件,得自己写。

5. 避坑与常见问题排查

5.1 启动报OutOfMemoryError但堆内存明明够

现象:IDEA里启动项目报java.lang.OutOfMemoryError: GC overhead limit exceeded,但看任务管理器内存占用不高。原因通常是IDEA编译时的堆大小不够,不是运行时。热搜里「idea 编译时,进程堆大小调整为8000,还是报错」说的就是这个。解决:在IDEA的Settings > Build > Compiler > Shared build process heap size里调到2048以上,同时检查pom.xml里有没有引入冲突的依赖导致编译期加载过多类。

5.2 手机号唯一索引导致插入失败但没提示

现象:新增主人时接口返回500,日志里是Duplicate entry异常。原因:owner表的phone字段有唯一索引,重复插入触发约束。解决:在Service层先selectOne查一下手机号是否存在,存在就返回友好提示;同时全局异常处理器里捕获DuplicateKeyException,统一转成业务异常返回给前端。

5.3 疫苗提醒任务查不到数据

现象:定时任务日志显示执行了,但一条记录都没查到。原因排查三步:先确认next_date字段有没有值,很多录入时忘了填;再确认between的边界,between a and b是闭区间,包含两端;最后确认时区,LocalDate.now()取的是服务器时区,如果数据库存的是UTC时间,差8小时就会漏掉当天的记录。解决:统一用Asia/Shanghai时区,next_date用DATE类型存,避免时间部分干扰。

5.4 分页查询返回总数不对

现象:selectPage返回的total是0或者明显偏小。原因:分页插件没配,或者配了但DbType写错。另一个常见原因是查询条件里用了or但没加括号,导致WHERE条件优先级出错。解决:确认MybatisPlusInterceptor里加了PaginationInnerInterceptor,DbType和实际数据库一致;复杂条件用LambdaQueryWrapper的and(w -> w.eq(...).or().eq(...))显式分组。

5.5 打包成tar包部署后连不上数据库

现象:本地跑得好好的,打成tar包扔到服务器上启动就报连接超时。原因:application.yml里的url写的是localhost,服务器上MySQL不在本机。解决:把数据库地址改成实际IP,或者用环境变量注入。热搜里「怎么把java项目打成tar包」的做法是mvn clean package后把jar和启动脚本一起打包,启动脚本里用-Dspring.datasource.url=xxx覆盖配置,比改配置文件再打包灵活。

6. 用MyBatis-Plus的代码生成器把开发速度提上来

前面手写实体类和Mapper是为了讲清楚原理,实际做宠物管理系统的时候,五张表对应的实体、Mapper、Service、Controller如果全手写,光样板代码就得写半天。MyBatis-Plus自带的代码生成器能根据表结构反向生成这四层代码,省下来的时间可以花在业务逻辑上。

配置代码生成器主要改三个地方:数据源、包名、要生成的表名。下面是一个可复用的配置片段:

public class CodeGenerator { public static void main(String[] args) { FastAutoGenerator.create( "jdbc:mysql://localhost:3306/pet_manager?serverTimezone=Asia/Shanghai", "root", "your_password") .globalConfig(builder -> builder .author("your_name") .outputDir(System.getProperty("user.dir") + "/src/main/java") .disableOpenDir()) .packageConfig(builder -> builder .parent("com.example.pet") .entity("entity") .mapper("mapper") .service("service") .controller("controller")) .strategyConfig(builder -> builder .addInclude("owner", "pet", "vaccine_record", "service_item", "appointment") .entityBuilder() .enableLombok() .logicDeleteColumnName("deleted") .controllerBuilder() .enableRestStyle()) .execute(); } }

addInclude里列出要生成的表,不写就生成全库的表,容易把无关表也生成出来。enableLombok()让实体类用@Data代替getter/setter。logicDeleteColumnName("deleted")自动给实体类加@TableLogic注解,前提是表里真有这个字段,没有的话生成出来会报错。enableRestStyle()让Controller用@RestController而不是@Controller,省得每个方法再加@ResponseBody。

生成完之后有几处要手动调。一是实体类里的LocalDate和LocalDateTime类型,生成器默认可能给成Date,要按字段实际含义改。二是Controller里的接口路径,生成的是/pet这种,实际项目里一般会加版本号或者模块前缀,比如/api/v1/pet。三是Service层的业务校验,生成器只给空壳方法,主人是否存在、预约时间是否冲突这些逻辑得自己补。

用生成器还有一个好处是字段注释会带到实体类的JavaDoc里,前端对接的时候直接看Swagger文档就知道每个字段什么意思。如果项目里集成了knife4j或者springdoc,在Controller上加个@Tag注解,接口文档自动就出来了,比手写Markdown接口文档省事,也不会出现文档和代码不一致的情况。

我自己的习惯是:先用生成器跑一遍,把生成的代码提交到Git,然后再在生成的代码上改业务逻辑。这样万一改乱了,还能git diff看看原始生成的是什么样。另外生成器配置类不要留在主代码目录里,放到test目录或者单独的tools模块,避免打包的时候把生成器依赖也打进去。这个方案值不值得做,取决于你的表数量——三张表以下手写更快,五张表以上生成器能省至少半天。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询