简介:这是一份基于SpringBoot的宠物领养系统毕业设计完整源码包,面向计算机相关专业学生与初级Java开发者,用于快速搭建在线宠物领养平台,覆盖用户管理、宠物信息展示、领养申请等核心业务,尤其适合课程设计、毕业设计或SpringBoot入门实训。资源压缩包共135个文件,以46个Java源码文件为主,配合17个HTML页面、18个JavaScript脚本、11个CSS样式表,另有XML配置、数据库初始化文件、图片演示素材及项目说明文档,整体约2.51MB,目录层级清楚,导入IDE后即可对照学习。系统后端基于SpringBoot自动配置与RESTful API设计,数据库采用MySQL存储用户、宠物与领养记录,并通过Spring Security实现登录认证与权限控制;前端用Thymeleaf模板引擎和Bootstrap构建页面,兼顾功能完整性与开发效率。目前已有128人学习下载,适合需要一套可运行、可讲解、易扩展的实战项目来提升Java Web开发能力的读者。
1. 基于SpringBoot的宠物领养系统:业务难点不在CRUD,在状态竞争
拿到PetAdoption-master这套源码,先别急着开IDE。扫一眼resources下的静态文件——bootstrap.min.css、dataTables.bootstrap.css、custom-styles.css、iconfont.css——基本就能判断出交互形态:前端用Bootstrap + DataTables渲染服务端返回的JSON,后端是典型的Spring Boot单体应用。这类系统的CRUD并不难写,真正拉开距离的地方在领养流程的状态竞争:同一只宠物被多个用户同时申请,如何保证数据库层面不出现一猫两主;审核员重复点击通过,如何保证状态不漂移。这个项目把领养申请独立成状态表,用唯一索引加状态机控制并发,恰好踩中了Spring Boot业务系统最常见的边界。适合正在做Java毕业设计、或想快速搭一套带权限和文件上传的Web后台的人参考。
2. 数据模型与MyBatis-Plus持久层:三张表撑起领养闭环
宠物领养的业务链路是注册、浏览、申请、审核、领养成功,数据层并不需要复杂的表结构,真正决定系统质量的是表怎么拆分、索引怎么建、状态怎么流转。这套项目在数据建模上有一个值得抄的点:领养申请没有被塞进宠物表的status字段,而是单独成表。这样审核历史、申请理由、联系方式都留得下来,后续做统计报表也不缺数据。
2.1 建表SQL:唯一索引是并发的第一道防线
MySQL 5.7+环境下,核心三张表的建表语句可以精简成这样:
CREATE TABLE `sys_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt密文', `phone` VARCHAR(20) DEFAULT NULL, `role` TINYINT NOT NULL DEFAULT 2 COMMENT '1管理员 2领养人', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` TINYINT DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `pet` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `name` VARCHAR(30) NOT NULL, `breed` VARCHAR(30) DEFAULT NULL COMMENT '品种', `age_months` INT DEFAULT NULL, `gender` CHAR(1) DEFAULT NULL, `vaccinated` TINYINT DEFAULT 0, `photo_url` VARCHAR(200) DEFAULT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待领养 1已领养 2下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` TINYINT DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `adoption_apply` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `pet_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `apply_reason` VARCHAR(500) DEFAULT NULL, `contact_phone` VARCHAR(20) DEFAULT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1已通过 2已拒绝 3已过期', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` TINYINT DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_pet_user` (`pet_id`, `user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_pet_user这个唯一索引是整个并发控制的地基。它保证同一用户对同一只宠物最多只有一条申请记录。如果没有这层约束,前端按钮禁用可以被绕过,Service层查询也可能遇到时间差,最终还是靠数据库兜底。
2.2 版本选型:Spring Boot 2.7.x 是JDK8环境的安全牌
如果你的机器装的是JDK8,就不要选Spring Boot 3.x。3.x强制要求JDK17,很多教材和网上的依赖写法会直接跑不起来。这套项目用2.7.x配合MyBatis-Plus 3.5.x,是当前兼容性比较稳的组合。pom.xml关键依赖如下:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.13</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> </dependencies>application.yml里的核心配置:
spring: datasource: url: jdbc:mysql://localhost:3306/pet_adoption?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: root servlet: multipart: max-file-size: 5MB max-request-size: 20MB location: /data/upload_tmp thymeleaf: cache: false mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0spring.servlet.multipart.location这个参数容易被忽略。Spring Boot内嵌Tomcat默认把上传临时文件写到/tmp,Linux服务器重启后/tmp被清理,项目运行一段时间后上传就会莫名失败。指定一个业务目录,提前mkdir -p /data/upload_tmp,能避开这个经典坑。
2.3 实体类与逻辑删除:注意唯一索引在逻辑删除下的冲突
MyBatis-Plus的实体类用注解映射表和字段:
@Data @TableName("pet") public class Pet { @TableId(type = IdType.AUTO) private Long id; private String name; private String breed; private Integer ageMonths; private String gender; private Boolean vaccinated; private String photoUrl; private Integer status; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableLogic private Integer deleted; }驼峰命名默认自动转下划线,ageMonths对应age_months,不需要额外配置。@TableLogic标记逻辑删除字段后,MyBatis-Plus的selectById、deleteById都会自动追加deleted=0条件。
这里有一个新手很难察觉的坑:adoption_apply表有uk_pet_user唯一索引,用户第一次申请被拒绝后,如果走逻辑删除把deleted置为1,唯一索引里那条(pet_id, user_id)记录仍然占位。用户再次申请时,普通INSERT会直接撞唯一索引报DuplicateKeyException。处理方式不是删索引,而是在申请逻辑里先查历史记录,命中后UPDATE回deleted=0并重置状态,第四条会给出代码。
3. Spring Security认证授权与MultipartFile上传的落地细节
带后台管理的Web系统绕不开两件事:登录鉴权和文件上传。这个项目的做法是Spring Security管理会话,MultipartFile接收图片,存储到本地目录后通过URL映射对外提供访问。整体方案偏向传统服务端渲染,比前后端分离多了一层模板渲染,但安全模型的底子是通用的。
3.1 认证选型:页面应用用Session,不用JWT
纯页面应用如果硬上JWT,刷新令牌、过期策略、服务端注销全都要自己写,收益不高。Spring Security默认的Session方案配合BCrypt密码加密,对毕设和内部后台足够。判断标准很简单:页面是Thymeleaf渲染的还是Vue/React独立部署的,前者用Session,后者考虑JWT或OAuth2。
3.2 SecurityConfig:静态资源放行与BCrypt加密
Spring Security 5.7之前继承WebSecurityConfigurerAdapter是主流写法,5.7之后官方推荐SecurityFilterChainBean。Spring Boot 2.7.x两种都兼容,下面是不过时的写法:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeRequests() .antMatchers("/login", "/register", "/css/**", "/js/**", "/images/**", "/upload/**").permitAll() .antMatchers(HttpMethod.GET, "/api/pets/**").permitAll() .antMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() .and() .formLogin() .loginPage("/login") .defaultSuccessUrl("/index") .permitAll() .and() .logout() .logoutUrl("/logout") .invalidateHttpSession(true); return http.build(); } }.antMatchers("/upload/**").permitAll()是为了让上传的宠物图片能被匿名访问,否则详情页图片全部403。/api/admin/**限定管理员角色,seed数据里把管理员账号的role设为1,普通用户的role设为2即可。
3.3 图片上传:存储路径与URL映射
上传接口接收MultipartFile,生成UUID文件名,落盘到自定义目录:
@PostMapping("/api/upload") public Result upload(@RequestParam("file") MultipartFile file) { String original = file.getOriginalFilename(); String ext = original.substring(original.lastIndexOf(".")); String filename = UUID.randomUUID().toString().replace("-", "") + ext; File dir = new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, filename)); return Result.ok("/upload/" + filename); }uploadDir通过@Value("${file.upload-dir}")注入,比如配置为/data/pet_upload。UUID.randomUUID().toString().replace("-", "")生成32位无横线文件名,避免中文名和特殊字符带来的乱码和路径穿越问题。
为了让/upload/**能映射到磁盘目录,需要注册WebMvcConfigurer:
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir + "/"); } }注意addResourceLocations必须以file:开头,路径末尾要带/。这两个细节漏掉任意一个,浏览器访问图片都会404。
4. 领养业务闭环:申请幂等、审核状态机与定时兜底
从用户点下"申请领养"到管理员审核通过,中间涉及三个状态:申请状态、宠物状态、用户权限。这个项目的核心代码主要集中在这一段。设计思路上,把申请写成独立状态机,宠物表的status只是冗余快照,一切以申请表的status为准。
4.1 API路由划分
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | /api/pets | 分页查询宠物列表,支持品种、状态筛选 | 匿名 |
| GET | /api/pets/{id} | 宠物详情 | 匿名 |
| POST | /api/pets/{id}/apply | 提交领养申请 | 登录用户 |
| PUT | /api/apply/{id}/approve | 审核通过 | 管理员 |
| PUT | /api/apply/{id}/reject | 审核拒绝 | 管理员 |
| PUT | /api/apply/{id}/cancel | 用户撤销申请 | 登录用户 |
| POST | /api/upload | 上传图片 | 登录用户 |
读接口放行,写接口走认证,管理端写接口走角色鉴权。这个粒度对中小型项目是合理的——列表页不需要登录,用户点申请时才要求认证,转化路径最短。
4.2 提交申请:复用历史记录,绕开唯一索引冲突
申请接口的Service实现:
@Transactional public void apply(Long petId, Long userId, ApplyForm form) { LambdaQueryWrapper<AdoptionApply> wrapper = new LambdaQueryWrapper<AdoptionApply>() .eq(AdoptionApply::getPetId, petId) .eq(AdoptionApply::getUserId, userId); AdoptionApply exist = applyMapper.selectOne(wrapper); if (exist != null) { if (exist.getDeleted() == 1) { // 逻辑删除的历史记录仍占用唯一索引,直接UPDATE回滚状态 exist.setDeleted(0); exist.setStatus(0); exist.setApplyReason(form.getReason()); exist.setContactPhone(form.getContactPhone()); applyMapper.updateById(exist); } else { throw new BizException("你已经申请过这只宠物,请等待审核"); } } else { Pet pet = petMapper.selectById(petId); if (pet == null || pet.getStatus() != 0) { throw new BizException("宠物不存在或已被领养"); } AdoptionApply apply = new AdoptionApply(); apply.setPetId(petId); apply.setUserId(userId); apply.setApplyReason(form.getReason()); apply.setContactPhone(form.getContactPhone()); applyMapper.insert(apply); } }两个关键点说明。selectOne能查出逻辑删除的记录,因为MyBatis-Plus只是在生成的SQL里追加deleted=0吗?不是——逻辑删除字段加的是查询条件,deleted=1的记录是查不到的。这里selectOne默认不查deleted=1的数据,所以命中历史记录时返回的是当前生效的那条。若存在则直接更新,不存在则insert,整套逻辑依赖uk_pet_user防止并发下重复插入,insert时若有两条请求同时进来,数据库会拒绝第二条,捕获DuplicateKeyException返回友好提示。这个需求点也正好解释了为什么2.3节说逻辑删除是优势。
4.3 审核通过:UPDATE条件里带status,吃掉并发
管理员审核是最容易出现重复提交的环节。两个人同时打开后台,同时点通过,最终结果必须是:状态只被更新一次,宠物只被标记一次已领养。
@Transactional public void approve(Long applyId) { AdoptionApply apply = applyMapper.selectById(applyId); if (apply == null || apply.getStatus() != 0) { throw new BizException("申请不存在或已处理"); } int rows = applyMapper.update(null, new LambdaUpdateWrapper<AdoptionApply>() .eq(AdoptionApply::getId, applyId) .eq(AdoptionApply::getStatus, 0) .set(AdoptionApply::getStatus, 1)); if (rows == 0) { throw new BizException("申请状态已变更,请刷新后重试"); } // 同一宠物的其他待审申请批量置为拒绝 applyMapper.update(null, new LambdaUpdateWrapper<AdoptionApply>() .eq(AdoptionApply::getPetId, apply.getPetId()) .eq(AdoptionApply::getStatus, 0) .set(AdoptionApply::getStatus, 2)); // 宠物状态置为已领养 petMapper.update(null, new LambdaUpdateWrapper<Pet>() .eq(Pet::getId, apply.getPetId()) .set(Pet::getStatus, 1)); }UPDATE ... WHERE id = ? AND status = 0在InnoDB下走行锁,两个并发事务同时执行时,第二个事务的rows一定是0,直接抛出异常。比起先SELECT再UPDATE的check-then-act,这属于用数据库行锁解决,代码量最少。注意@Transactional必须加上,否则第二步批量拒绝和第三步宠物状态更新不在同一事务里,中间异常会导致数据不一致。
4.4 超时未审核的申请:@Scheduled定时兜底
业务上还有一个边界:申请提交后管理员一直不审核,宠物就长期处于"待领养"状态,但其实已经被某个用户占位申请了。需要定时任务把超过48小时未处理的申请置为过期,同时把宠物状态还原,让其他用户可以申请。
@Component public class ApplyTimeoutJob { @Resource private ApplyMapper applyMapper; @Scheduled(cron = "0 0/30 * * * ?") public void expireTimeoutApplies() { // 查出所有超时待审申请关联的pet_id List<AdoptionApply> expired = applyMapper.selectList(new LambdaQueryWrapper<AdoptionApply>() .eq(AdoptionApply::getStatus, 0) .lt(AdoptionApply::getCreateTime, LocalDateTime.now().minusHours(48))); if (expired.isEmpty()) { return; } List<Long> applyIds = expired.stream().map(AdoptionApply::getId).collect(Collectors.toList()); List<Long> petIds = expired.stream().map(AdoptionApply::getPetId).distinct().collect(Collectors.toList()); // 申请置为过期 applyMapper.update(null, new LambdaUpdateWrapper<AdoptionApply>() .in(AdoptionApply::getId, applyIds) .set(AdoptionApply::getStatus, 3)); // 宠物恢复为待领养 petMapper.update(null, new LambdaUpdateWrapper<Pet>() .in(Pet::getId, petIds) .eq(Pet::getStatus, 0) .set(Pet::getStatus, 0)); } }启动类上需要加@EnableScheduling。cron表达式0 0/30 * * * ?表示每30分钟执行一次,lt条件用create_time和now()-48h比较,扫出超时记录。这里的宠物状态更新带上status=0条件,避免把已完成领养的宠物误改。
5. 部署到服务器:jar包、外置配置与heapdump风险排查
5.1 打包与启动命令
项目在Windows 10/11下测试正常,部署到Linux服务器时直接用Maven打包:
mvn clean package -DskipTests nohup java -jar target/pet-adoption-0.0.1-SNAPSHOT.jar \ --spring.profiles.active=prod \ --server.port=8080 \ > /data/logs/app.log 2>&1 &--spring.profiles.active=prod激活生产环境配置,数据库连接、上传目录、日志级别都放在application-prod.yml里。--server.port可以覆盖打包时的默认端口,适合服务器上8080被占用的场景。如果公司强制要求替换内嵌Tomcat为宝兰德这类国产中间件,Spring Boot 2.7.x的项目改成war包,排除内嵌Tomcat依赖后部署到外部容器即可。
5.2 actuator端点暴露与heapdump泄露排查
很多Spring Boot项目会引入actuator做健康检查,但默认配置下/actuator/heapdump接口会把整个JVM堆内存快照暴露出去。堆快照里包含用户名、密码明文、Session ID、数据库连接串,任何人都能下载后拿MAT分析,属于不应该出现在生产环境的高危暴露。
management: endpoints: web: exposure: include: health,info endpoint: health: show-details: never endpoint: heapdump: enabled: false只暴露health和info,关闭heapdump端点。如果监控系统确实需要heapdump,也要通过Nginx对/actuator/**做IP白名单限制,不能直接裸奔到公网。这里重点检查Spring Boot 2.x的management.endpoints.web.exposure.include默认值,2.x默认只暴露health,但很多教程会让人配成*,一放开就全露了。
5.3 换库换环境排查清单
| 改动点 | 位置 | 易踩坑 |
|---|---|---|
| 数据库密码 | application-prod.yml | 密码含特殊字符要加引号 |
| 上传目录 | file.upload-dir | 目录不存在导致transferTo报错,先mkdir -p |
| 时区 | datasource.url | serverTimezone漏配导致时间差8小时 |
| 端口 | server.port | 防火墙放行后还要检查云安全组 |
| 静态资源缓存 | thymeleaf.cache | 生产建议true,开发建议false |
换到新环境后验证顺序是:启动日志无异常、登录页能打开、上传一张图片并访问URL、用两个账号申请同一只宠物确认唯一索引生效、用管理员账号审核通过确认宠物状态流转正常。这套走完,系统基本就是可用的状态。最后提醒一句:外置配置里别把数据库账号密码硬编码,用环境变量注入,${DB_PASSWORD}比明文写在yml里安全得多。
本文还有配套的精品资源,点击获取