SpringBoot实战:宠物寄领养网站系统设计与核心实现指南 📅 发布时间:2026/9/9 14:44:42 👁 浏览次数: 做宠物寄领养网站这个题目说实话在SpringBoot全家桶项目里算是比较典型的一类了。它不像电商那样重订单、重支付也不像后台管理系统那样全是CRUD它恰好卡在中间——有真实业务流发布、申请、审核、状态流转有用户体系普通用户、管理员、宠物店家还有一堆容易踩坑的细节图片上传、富文本、跨域、事务。这个题目拿来练手或者做毕设性价比非常高。我这次从设计到落地完整走了一遍用的是SpringBoot作为主框架前端搭配Vue做管理端和用户端整个项目跑下来有几个地方的思考方式和实现细节值得拿出来说说。1. 项目整体设计与思路拆解1.1 核心需求解析这不是一个简单的CRUD系统很多人拿到宠物寄领养网站这个题目第一反应就是不就是发布宠物信息、申请领养、后台审核吗真做下去才发现业务逻辑的复杂程度远超预期。先说寄养和领养这两条线它们天然是两个不同的业务场景。寄养更像一个短期的服务交易宠物主人把宠物托付给寄养家庭或宠物店有时间周期、有费用结算、有对寄养方资质的审核。领养则是公益性质的匹配流浪动物或原主人无法继续饲养的宠物发布出来有领养意愿的人提交申请原主人或平台管理员审核申请人的条件。如果把这两条线揉在一个模块里做代码会非常混乱。所以我做的第一个决定是领域模型分开底层数据结构独立设计但共用用户中心和审核中心。寄养走服务单逻辑PetBoardingOrder领养走申请单逻辑PetAdoptionApplication。这样做的好处是后续扩展时不用动底层表结构也符合实际业务流程的差异。再来梳理角色体系。一个完整的寄领养平台至少需要三类角色普通用户既是宠物主人也是领养申请者、寄养服务方可以是个人也可以是机构、平台管理员负责审核和内容管理。用户角色权限用Spring Security JWT来做基础的三张表管理用户、角色、权限关系没有过度设计但足够撑起前端的路由权限控制和后端接口的粒度划分。1.2 技术选型与系统架构技术栈方面我采用了目前SpringBoot生态里最主流、也最适合教学演示的一套组合层次技术选型选型理由后端框架SpringBoot 2.7.x稳定成熟资料多遇到问题容易搜到答案安全认证Spring Security JWT无状态认证适合前后端分离架构持久层MyBatis Plus单表CRUD不用写SQL分页插件好用数据库MySQL 8.x免费性能稳定支持JSON字段做附加属性缓存Redis做验证码存储、热点宠物信息缓存文件存储本地磁盘 Nginx映射简单可靠不需要额外搭OSS前端Vue 2 Element UI管理端开发效率高用户端配合H5流程引擎Flowable预留用于寄养订单状态流转的可视化管理为什么不用更新潮的JWT Sa-Token、为什么不用Spring Cloud微服务那套原因很简单这个项目规模下微服务只有坏处没有好处。单体应用 良好分层已经是维护成本最低的方案了。SpringBoot的优势恰恰在这——内置Tomcat、自动装配、大量Starter能把精力集中在业务逻辑而不是环境配置上。1.3 数据库设计的几个关键决策数据库是整个项目的基石设计得好后面开发会很顺设计不好后面全是补丁。我的核心表设计如下user用户表存储账号、密码BCrypt加密、昵称、头像、手机号、角色IDpet_category宠物分类表比如猫、狗、兔子、鸟等pet_info宠物信息表这是核心表字段比较多——宠物名称、品种、年龄、性别、疫苗状态、绝育状态、描述、图片URL列表、发布人ID、状态待审核/已发布/已下架boarding_service寄养服务表包含服务标题、服务类型家庭寄养/机构寄养、可接收宠物类型、价格、服务时间、还有评分boarding_order寄养订单表关联服务方和宠物主人有时间段有状态待支付/已确认/进行中/已完成/已取消adoption_application领养申请单关联宠物ID和申请人ID附带申请理由、居住情况、养宠经验等表单字段状态待审核/通过/拒绝adoption_record领养记录表审核通过后生成用于后续回访audit_log审核日志表记录管理员的操作轨迹有几个细节值得说明第一宠物信息表里的图片字段我用了JSON字符串存储而不是单独建一张宠物图片关联表。对于这个量级的项目来说查询时减少一次表关联性能收益更明显也方便MyBatis Plus直接映射成List 。第二寄养订单的状态流转我设计了专门的状态字段并且用Flowable预留了流程定义的能力。实际用的时候发现寄养订单状态虽然看着简单但涉及取消退款超时确认这些边界情况时if-else判断会非常臃肿。这里引入Flowable或者状态机设计模式是个加分项面试时也很有说头。第三为提高查询效率宠物列表页的查询条件品种、年龄、地区、分页必须走组合索引我在pet_info表上建了(status, category_id, create_time)的联合索引。当时测试了5万条模拟数据加了索引后查询从毫秒级优化到真正的毫秒级索引的作用立竿见影。2. 核心功能实现与关键技术细节2.1 宠物信息发布模块的实现宠物发布是所有业务的开端做得不好直接影响用户体验。这个模块的前端是一个多步骤表单第一步选宠物类别和基础信息第二步上传图片和填写详细描述第三步确认联系方式。后端接收时用了Validated注解做参数校验自定义了一个PetAddDTO类在字段上标注NotBlank、NotNull、Size等校验规则。图片上传走的是单独接口前端先把图片传到临时目录提交表单时再和宠物信息一起落库。这里有个实测的坑Element UI的el-upload组件默认会在选择文件后立即上传而后端如果校验用户登录态用的是JWT的Header参数前端需要在headers属性里手动带上Authorization。不然就会出现图片传上去了但后端不知道是哪个用户传的这种尴尬。宠物信息的审核状态我用了简单的枚举类public enum PetStatus { PENDING(0, 待审核), APPROVED(1, 已发布), REJECTED(2, 已驳回), OFF_SHELF(3, 已下架); private final int code; private final String desc; PetStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } }新发布的宠物默认是PENDING只有管理员审核通过后才在前台列表展示。这样设计灰产内容很难直接刷到用户面前也为审核日志提供了数据基础。2.2 图片上传与静态资源映射的完整方案图片上传是这种内容型网站绕不开的技术点。我的实现分三步第一步配置文件里设置好上传路径和访问URL前缀# application.yml 配置段 file: upload-dir: /data/pet-platform/images access-prefix: /images/** max-size: 5MB第二步实现一个FileStorageService负责保存MultipartFile到磁盘并生成随机文件名防止文件名冲突Service public class FileStorageService { Value(${file.upload-dir}) private String uploadDir; public String store(MultipartFile file) { String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String newFilename UUID.randomUUID().toString().replace(-, ) ext; String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM/dd)); File targetDir new File(uploadDir File.separator datePath); if (!targetDir.exists()) { targetDir.mkdirs(); } try { file.transferTo(new File(targetDir, newFilename)); } catch (IOException e) { throw new BusinessException(文件保存失败); } return /images/ datePath / newFilename; } }第三步配置WebMvc的静态资源映射Configuration public class WebMvcConfig implements WebMvcConfigurer { Value(${file.upload-dir}) private String uploadDir; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/images/**) .addResourceLocations(file: uploadDir /); } }这套方案的好处是开发环境下不用额外配Nginx就能直接访问图片上线后只需要在Nginx里加一条location /images/的映射规则即可前后端代码一行都不用改。2.3 领养申请流程与状态机的设计领养申请是整个项目里最有门道的部分。申请流程涉及多个状态的切换待审核、初筛通过、家访待安排、已家访、领养完成。如果全部用if-else写在Service里一旦状态多起来就是灾难。我用状态机模式来管理状态流转。核心是一个StateChangeHandler接口不同状态入口匹配不同的处理器public interface AdoptionStateHandler { int fromState(); int toState(); void handle(AdoptionApplication application); }比如审核通过这个动作对应处理器里要做的事情就包括更新申请单状态、生成领养记录、把宠物状态改为已领养、给申请人和宠物发布人发送站内通知。这些操作必须在同一个事务里完成否则会出现状态不一致的问题。实际编码时还有一个坑多状态流转如果用MySQL的UPDATE ... SET status ? WHERE id ? AND status ?这种乐观锁写法可以避免并发重复提交。同一只宠物不能被两个人同时申请成功这个约束在数据层面必须有保障。2.4 订单超时未支付的处理方案寄养服务有个经典问题用户下单后如果不支付会占着服务方的档期导致其他用户无法下单。最简单的处理方案是Redis缓存订单创建时间配合定时任务扫描超时订单。当时的做法是创建订单时同步设置Redis缓存KEY order:timeout: orderId过期时间为30分钟。用户支付成功后删除该KEY。同时用一个Spring的Scheduled定时任务每5分钟扫描一次超时订单把超过30分钟未支付且状态为UNPAID的订单自动取消释放服务方的档期。Scheduled(cron 0 */5 * * * ?) public void autoCancelTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(30); ListBoardingOrder timeoutOrders boardingOrderMapper .selectList(new LambdaQueryWrapperBoardingOrder() .eq(BoardingOrder::getStatus, OrderStatus.UNPAID) .lt(BoardingOrder::getCreateTime, deadline)); for (BoardingOrder order : timeoutOrders) { order.setStatus(OrderStatus.CANCELLED); boardingOrderMapper.updateById(order); // 释放档期、发送通知 boardingScheduleService.releaseSchedule(order.getServiceId(), order.getStartDate(), order.getEndDate()); } }这套Redis过期KEY 定时兜底扫描的双保险方案在生产环境里实测效果很好也不会出现因Redis异常导致订单永久卡在未支付状态的问题。3. 从零搭建项目的实操过程3.1 项目骨架结构与起步配置项目我推荐直接用Spring Initializr生成选好Maven、Java 8或11、SpringBoot 2.7即可。生成后要做的第一件事不是写代码而是先搭好目录结构和异常处理。我的包结构是这样规划的com.pet.adoption ├── controller -- 接口层薄薄的只做参数接收和结果封装 ├── service -- 业务逻辑层核心事务都在这里 ├── mapper -- MyBatis Plus的Mapper接口 ├── entity -- 数据库实体类 ├── dto -- 接收前端参数的类 ├── vo -- 返回前端展示结果的类 ├── config -- 配置类如WebMvcConfig、SecurityConfig ├── common -- 通用结果封装类、异常类、常量类 └── utils -- 工具类统一的返回结果封装很重要前后端分离项目如果接口返回格式不统一前端联调时会很痛苦。我定义了一个ResultT类包含code、msg、data三个字段成功返回200业务异常返回具体的业务码。全局异常处理用RestControllerAdvice把参数校验异常、业务异常、未登录异常、系统异常分别处理前端只要看code就能知道是哪类问题不用每次都从报错堆栈里猜。3.2 Spring Security JWT的配置细节Spring Security配置是这个项目里最容易劝退新手的部分。很多教程把Security配置写得花里胡哨各种继承、各种自定义Filter实际用下来我建议用最简单清晰的写法。密码加密直接用BCryptBean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }JWT工具类负责生成和解析TokenComponent public class JwtUtils { Value(${jwt.secret}) private String secret; Value(${jwt.expiration}) private Long expiration; public String generateToken(Integer userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expiration)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } }自定义的JwtAuthenticationFilter继承OncePerRequestFilter在doFilterInternal中获取Header里的Token解析成功后把用户ID塞进SecurityContextHolderComponent public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtUtils jwtUtils; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); try { Claims claims jwtUtils.parseToken(token); Integer userId claims.get(userId, Integer.class); // 把用户信息放入SecurityContext供后续Controller获取 UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // Token无效时不做处理让Security的鉴权拦截器处理 } } filterChain.doFilter(request, response); } }最后在SecurityConfig里放行登录注册接口、宠物列表查询接口和图片访问路径其余接口统一走认证。不需要复杂的方法级权限控制用一个简单的切面判断当前用户角色即可。3.3 用MyBatis Plus实现宠物列表的分页查询分页查询是宠物列表页的核心操作。MyBatis Plus的分页插件配置非常简单Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后Service里配合LambdaQueryWrapper实现动态条件查询public PagePetInfoVO queryPetList(PetQueryDTO queryDTO) { PagePetInfo page new Page(queryDTO.getPageNum(), queryDTO.getPageSize()); LambdaQueryWrapperPetInfo wrapper new LambdaQueryWrapper(); wrapper.eq(PetInfo::getStatus, PetStatus.APPROVED.getCode()) .eq(queryDTO.getCategoryId() ! null, PetInfo::getCategoryId, queryDTO.getCategoryId()) .like(StringUtils.hasText(queryDTO.getKeyword()), PetInfo::getName, queryDTO.getKeyword()) .ge(queryDTO.getMinAge() ! null, PetInfo::getAge, queryDTO.getMinAge()) .le(queryDTO.getMaxAge() ! null, PetInfo::getAge, queryDTO.getMaxAge()) .orderByDesc(PetInfo::getCreateTime); PagePetInfo result petInfoMapper.selectPage(page, wrapper); // 类型转换、封装VO return convertToVO(result); }eq方法带上条件判断第一个参数为false时不拼接SQL是MyBatis Plus最核心的高效用法能省掉大量拼接SQL的代码。实测3万条宠物数据接口响应时间稳定在120ms以内。3.4 用Flowable扩展寄养订单审核流加分项如果项目基础功能做完之后想增加亮点我推荐引入Flowable工作流引擎来处理寄养订单的审核流程。寄养服务的发布审核、订单取消申请、纠纷处理这些流程如果用状态字段硬写每加一个分支就要改一遍Service层代码。用Flowable之后流程定义在BPMN文件里代码只关心触发节点。当时我的做法是定义了一个简单的三节点流程提交申请startEvent→ 管理员审核userTask→ 通过或拒绝endEvent。流程定义好后用Flowable的的RuntimeService触发public void startAuditProcess(Integer orderId) { MapString, Object variables new HashMap(); variables.put(orderId, orderId); variables.put(applicant, SecurityUtils.getCurrentUserId()); runtimeService.startProcessInstanceByKey(boardingOrderAudit, String.valueOf(orderId), variables); }审核通过时调用taskService.complete(taskId, variables)在variables里加入审核结果流程就会自动流转到对应分支。这个设计比手写状态机更规范提交审核的流程图给老师或者面试官看的时候说服力也更强。4. 常见问题与排查技巧实录4.1 前后端联调时的跨域配置前后端分离项目跨域问题几乎每个人都会遇到。Vue端跑在8080端口SpringBoot跑在9090端口浏览器的同源策略会把请求拦下来。一个容易忽略的坑是Spring Security的跨域配置必须在SecurityConfig里和SpringMVC的跨域配置同时生效否则Security会在进入Controller之前把跨域请求直接拦截。我当时在SecurityConfig里加了一段配置和WebMvcConfig的addCorsMappings配合使用才彻底解决Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); }4.2 图片上传提示文件过大的问题SpringBoot默认的上传大小限制是1MB测试时随便一张手机照片就超了。需要在配置里显式调大spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MBmax-file-size是单个文件大小max-request-size是单次请求总大小。如果前端是多图上传后者的设置特别重要否则会莫名其妙报500。4.3 宠物列表接口慢的排查过程项目联调阶段出现过一个典型的性能问题宠物列表接口在数据量少的时候很快一旦管理员批量导入了上千条测试数据接口响应就超过5秒。排查过程三步走第一步定位是SQL慢还是Java处理慢。在MyBatis Plus里开启SQL日志后发现SQL执行本身只要几十毫秒问题不在数据库。第二步怀疑是循环查询导致N1问题。宠物列表的VO里包含发布人昵称、宠物分类名称当时图省事在循环里逐条查询数据量一多就成了灾难。第三步改成关联查询一次性查出所有需要的数据再通过Map映射到VO。public PagePetInfoVO convertToVO(PagePetInfo page) { ListPetInfo records page.getRecords(); // 批量查分类信息 SetInteger categoryIds records.stream() .map(PetInfo::getCategoryId).collect(Collectors.toSet()); MapInteger, String categoryMap petCategoryMapper.selectBatchIds(categoryIds) .stream().collect(Collectors.toMap(PetCategory::getId, PetCategory::getName)); // 批量查用户信息 SetInteger userIds records.stream() .map(PetInfo::getUserId).collect(Collectors.toSet()); MapInteger, User userMap userMapper.selectBatchIds(userIds) .stream().collect(Collectors.toMap(User::getId, Function.identity())); return page.convert(record - { PetInfoVO vo new PetInfoVO(); BeanUtils.copyProperties(record, vo); vo.setCategoryName(categoryMap.getOrDefault(record.getCategoryId(), 未知分类)); User user userMap.get(record.getUserId()); if (user ! null) { vo.setPublisherName(user.getNickname()); vo.setPublisherAvatar(user.getAvatar()); } return vo; }); }改完之后接口时间降到200ms以内这个优化手段在面试时也是一个很加分的实战案例。4.4 寄养订单状态不同步的坑做寄养订单模块时曾遇到过新增订单时服务方的档期日期已经被人占用的情况。这个问题的根源是典型的并发问题两个请求同时查到某个日期段是可用的同时执行插入导致档期重复。解决办法有两个层面数据库层面在boarding_schedule表对应的日期字段上加了唯一索引从根上杜绝重复。业务代码层面给申请占用档期的操作加了同步锁使用RedisTemplate的分布式锁保证同一时间段只有一个线程能修改档期数据public boolean tryLockSchedule(Integer serviceId, String date) { String lockKey lock:schedule: serviceId : date; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(10)); return Boolean.TRUE.equals(locked); }在同一个事务里完成加锁 → 检查 → 占用三步操作后并发问题才算真正解决。这类问题并不是测试时轻易能发现的往往要压测或者多人同时使用才会暴露。4.5 事务不生效的一个隐蔽原因寄养订单创建时需要同时写入订单表、扣减服务方可用档期、更新服务方的接单统计。这三步操作我起初写在了同一个类的方法内部调用结果发现档期扣减失败后订单居然还能创建成功事务压根没生效。原因其实很简单Spring的声明式事务是基于动态代理实现的同类内部方法调用不会经过代理对象所以Transactional不会起作用。解决方法是把需要事务保证的操作拆分到不同的Bean中通过注入调用来让Spring的代理生效或者直接使用TransactionTemplate手动控制事务边界。这个坑在应届生项目里非常常见能写出来是个加分项说明你真的遇到过问题、并且理解了Spring AOP的底层原理。关于SpringBoot配置和面试题的延伸思考最后聊两个和SpringBoot本身直接相关的话题一个是配置一个是面试。SpringBoot的配置文件看似简单但实际项目里很容易出现配置文件改乱了导致启动失败的情况。建议按照官方规范以application.yml作为主配置把不同的环境配置拆到application-dev.yml和application-prod.yml里通过spring.profiles.active切换环境。再一个经验是不要把所有内容塞进一个配置类。数据库连接、Redis、文件上传路径、JWT参数、Flower相关参数按功能拆分到独立的ConfigurationProperties类里通过EnableConfigurationProperties注册Component ConfigurationProperties(prefix jwt) public class JwtProperties { private String secret; private Long expiration; // 省略getter/setter }这样读配置的代码不散落在各个Service里改配置时也只用改一处。至于SpringBoot面试题这个项目基本能覆盖到90%的常考题。比如SpringBoot自动装配的原理启动类上的SpringBootApplication组合注解、EnableAutoConfiguration、spring.factories里的自动配置类、SpringBoot的starter机制、ConfigurationProperties和Value的对比、SpringBoot怎么打war包部署到外部Tomcat、如何自定义starter等。把项目的交互过程完整走一遍这些基础问题都能对答如流。做这类基于SpringBoot的业务系统我自己最大的体会是框架用法只是入门真正的功夫花在业务模型的拆分、边界情况的处理、性能优化这几个地方。把这些问题想清楚、做扎实比反复刷一万道面试题都管用。如果你准备拿这个题目做参考项目建议按照上面的设计自己重写一遍遇到那些和我的实现不同的地方通常就是你思考加深的地方。