资讯详情 基于SSM+Java的宠物领养网站实战:从数据库设计到部署避坑全解析
📅 2026/10/13 3:43:06
“宠物领养网站”这个题目在Java Web方向的课设和毕业设计里确实是出现频率最高的选题之一。它不像商城系统那样体量大也不像博客系统那样太单薄功能边界清晰、业务流程完整、技术栈覆盖面又广非常适合拿来检验SSM框架的掌握程度。但现实情况是很多人拿到这个题目从网上找了一堆零散代码最后勉强跑通却完全讲不清楚“为什么这么写”。这篇文章我不打算把每个页面截图贴一遍而是想把基于SSMJava的宠物领养网站从技术选型、数据库设计、核心代码落地到部署避坑完整过一遍。文章适合正在做课设或毕设的Java初学者也适合想系统梳理SSM三层架构的开发者。我会把当初踩过的坑、后来带人时看到的高频错误全部揉进去。1. 先别急着写代码SSM这套组合拳到底是怎么分工的1.1 为什么一个老掉牙的技术栈现在还在被大量用作教学项目SSM是Spring、SpringMVC、MyBatis三个框架的合称。在Spring Boot还没有流行起来的那几年这套组合基本就是Java Web开发的事实标准。现在看它确实“老”但它的教学价值恰恰体现在“老”三个框架的职责边界被拆得清清楚楚几乎没有自动化的“魔法”可言。用宠物领养网站来练手时你会真实感受到一个请求从浏览器出发经过Controller、Service、Mapper层层传递到最后落库的完整流程。这个体验是Spring Boot给不了的因为Spring Boot把太多东西做成了“约定大于配置”初学者往往只知道加依赖就能跑却不清楚内部到底发生了什么。我见过不少用Spring Boot做毕设很顺但面试被问到“SpringMVC的核心流程”“MyBatis的动态SQL”就卡壳的人。反过来凡是把SSM项目从头到尾手写一遍的人后面学Spring Boot基本是降维打击。所以如果你正处于学习阶段别嫌弃题目“老”把它当成一门解剖课来做。1.2 一次领养请求从点击到落库在SSM里经历了什么拿用户点击“申请领养”这个动作来说。浏览器会发起一个POST请求到TomcatTomcat根据web.xml里配置的DispatcherServlet把请求拦截下来。DispatcherServlet并不直接处理业务它先通过HandlerMapping找到对应的Controller方法。Controller收到参数后把参数整理成表单对象调用Service接口。注意Controller里面不写SQL、不写JDBC它只做三件事接收参数、调用Service、决定跳转哪个页面。Service层接手后开始处理业务规则。比如判断这只宠物是否存在、状态是否可领养、当前用户有没有重复提交过申请。业务规则判断完才轮到Mapper接口出场。Mapper是MyBatis的抽象入口它负责把Java方法调用翻译成SQL语句再通过JDBC打到MySQL数据库。数据从数据库返回时是逆着这条路走的Mapper返回Java对象Service拿到对象后组织结果Controller把结果放进ModelAndView视图解析器根据返回的逻辑视图名找到对应的JSP页面渲染成HTML最终回到浏览器。这套流程看似繁琐但每一层都有明确职责。宠物领养项目里最典型的业务规则是“一只宠物只能被成功领养一次”这个规则必须放在Service层判断而不能写在Controller或JSP页面里。理解了这个分工后面写代码就不会到处乱塞逻辑。1.3 版本选型清单照着配能省掉一半烦恼SSM项目最让人头疼的就是版本兼容。我整理了一份自己用着很稳的组合你可以直接照抄。组件推荐版本说明JDK1.8稳定、生态成熟禁止用JDK 17以上跑旧项目Spring / SpringMVC5.1.9.RELEASESpring的webmvc和tx等模块必须同版本MyBatis3.5.33.5.x都比较稳mybatis-spring2.0.4必须和MyBatis版本匹配不能随手乱搭MySQL5.7项目教学首选8.x需要额外处理时区MySQL驱动5.1.49配MySQL 5.7几乎无坑Tomcat8.5支持Servlet 3.1配合Java EE的javax命名空间Maven3.6.x太老版本可能拉不到新依赖如果你的数据库已经是MySQL 8.x驱动要换com.mysql.cj.jdbc.DriverURL里还必须加serverTimezoneAsia/Shanghai否则时间字段会乱掉。这个细节我在第5章还会重点说。2. 业务分析与数据库设计宠物领养网站的核心其实是“状态管理”2.1 前台后台功能清单画清楚才动手宠物领养网站从使用角色上分两类普通用户和管理员。前台面向普通用户功能包括注册登录、宠物浏览与分类筛选、宠物详情查看、提交领养申请、个人中心查看我的申请、发布我发现的宠物、管理个人资料、查看公告、给宠物留言咨询。后台面向管理员功能包括登录验证、宠物审核待审宠物上架、已上架宠物下架或删除、领养申请审核同意或拒绝、用户管理禁用/启用、公告发布管理、留言回复。这些功能列表看着不复杂但每一个都对应数据库里的字段和状态。我见过很多人先建表再想功能结果表设计完发现很多场景覆盖不了比如“宠物下架后原来的领养申请怎么办”“用户重复申请怎么办”全都要回头加字段。正确的顺序是先梳理业务规则再设计表结构。2.2 五张核心表与字段设计整个项目我建议从这五张表起步后续有余力再扩展收藏、疫苗记录之类的表。user表用户表存账号、密码、联系方式、角色。CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT MD5加密后存储, nickname VARCHAR(50), phone VARCHAR(20), email VARCHAR(100), address VARCHAR(200), role TINYINT DEFAULT 0 COMMENT 0普通用户 1管理员, avatar VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );pet表宠物表这是整个系统的核心。CREATE TABLE pet ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, category VARCHAR(20) COMMENT 猫/狗/其他, breed VARCHAR(50) COMMENT 品种, age VARCHAR(20) COMMENT 年龄段如1岁以内, sex TINYINT DEFAULT 0 COMMENT 0公 1母, health_status VARCHAR(200) COMMENT 疫苗、驱虫等健康描述, description TEXT, image VARCHAR(255), status TINYINT DEFAULT 0 COMMENT 0待审核 1待领养 2已领养 3下架, publisher_id BIGINT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );application表领养申请表记录谁在什么时候想领养哪只宠物。CREATE TABLE application ( id BIGINT PRIMARY KEY AUTO_INCREMENT, pet_id BIGINT NOT NULL, user_id BIGINT NOT NULL, applicant_name VARCHAR(50), applicant_phone VARCHAR(20), applicant_address VARCHAR(200), reason VARCHAR(500) COMMENT 申请理由, status TINYINT DEFAULT 0 COMMENT 0待审核 1已通过 2已拒绝 9已失效, review_remark VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, review_time DATETIME );notice表和message表公告、宠物留言咨询。字段就是常规的title/content、content/reply这里不展开。关于application表有个很多人不理解的设计为什么申请表里要冗余申请人的姓名、电话、地址而不是直接关联user表去查因为领养申请是有业务时效性的用户可能会修改自己的手机号但这条申请记录必须保留提交那一刻的联系方式。这叫“业务快照”。如果你在答辩时能把这个设计原因讲出来评委通常会认可你确实理解业务。2.3 pet.status与application.status的联动逻辑宠物领养系统最核心的其实是状态管理。宠物状态我设计了四个0待审核、1待领养、2已领养、3下架。发布宠物的人提交之后默认是0管理员审核通过变成1有人领养成功变成2管理员主动下架变成3。申请状态对应四层0待审核、1已通过、2已拒绝、9已失效。为什么需要9这个“已失效”状态因为一只宠物被某个人成功领养之后这只宠物下还挂着另外好几个“待审核”的申请。如果不把它们改成失效申请用户登录后看到的状态就是“审核中”但宠物其实已经被人领走了这会带来很差的体验也容易产生纠纷。管理员审核通过时要把同一宠物下其余待审申请统一批量置为9。这两个状态的联动逻辑是整个业务里最关键的地方。设计表的时候先把这个想明白后续代码写起来会顺很多。3. 三层架构落地的关键从Controller到Mapper的职责划分与代码边界3.1 包结构与职责边界SSM项目的包结构看起来很简单但实际很多人会写乱。我的习惯是com.xxx.pet ├── controller ├── service │ ├── interface │ └── impl ├── mapper ├── entity ├── vo └── commonentity放数据库表对应的实体类vo放页面提交或展示用的对象。Controller层只做参数接收和页面跳转Service接口定义业务动作Service实现类写具体规则Mapper只做最基础的增删改查和简单查询。记住一个原则在Controller里看到直接调用Mapper的地方基本都是不合格的写法。哪怕是一个简单的查询也应该走Service再转一次。原因是Service是事务边界如果Controller绕过Service事务注解就无法生效。这个过程前期会感觉多写了很多代码但对理解分层有巨大帮助。3.2 提交领养申请的核心代码Service层是业务主战场我以“用户提交领养申请”为例把三层代码怎么配合讲清楚。ControllerController RequestMapping(/application) public class ApplicationController { Autowired private ApplicationService applicationService; PostMapping(/submit) public String submit(ApplicationForm form, HttpSession session, Model model) { User loginUser (User) session.getAttribute(loginUser); if (loginUser null) { return redirect:/login; } try { applicationService.submit(form.getPetId(), loginUser.getId(), form); model.addAttribute(msg, 申请已提交请等待管理员审核); } catch (BizException e) { model.addAttribute(error, e.getMessage()); } return apply/result; } }Service接口public interface ApplicationService { void submit(Long petId, Long userId, ApplicationForm form) throws BizException; void review(Long applyId, Integer result, String remark); }Service实现里就是业务判断的核心Transactional public void submit(Long petId, Long userId, ApplicationForm form) throws BizException { Pet pet petMapper.selectById(petId); if (pet null || pet.getStatus() ! 1) { throw new BizException(该宠物当前不可领养); } int count applicationMapper.countByPetIdAndStatus(petId, 0); if (count 0) { throw new BizException(你已经提交过申请请等待审核结果); } Application app new Application(); app.setPetId(petId); app.setUserId(userId); // 表单里允许用户补充地址和理由姓名电话默认取登录用户资料 app.setApplicantName(loginUser.getNickname()); app.setApplicantPhone(loginUser.getPhone()); app.setApplicantAddress(form.getAddress()); app.setReason(form.getReason()); app.setStatus(0); applicationMapper.insert(app); }这里有个容易想拧的地方提交申请时要不要同时把宠物状态改成类似“申请中”我的建议是不改。宠物处于“待领养”状态时允许多个用户同时提交申请这些申请进同一个审核池管理员从中挑选最合适的领养人。如果提交申请就锁定宠物管理员反而失去了选择空间。宠物状态的变更只发生在管理员最终审核通过的那一刻我把它叫“申请池模式”。这个设计和现实中的领养流程是一致的别人问起来也说得通。3.3 MyBatis的动态SQL写法和一个容易踩的坑宠物列表页通常要支持多条件筛选按分类、按名字模糊查询、按状态过滤。这种场景最适合MyBatis的动态SQL。select idlistByCondition resultTypecom.xxx.pet.entity.Pet select * from pet where if testcategory ! null and category ! and category #{category} /if if teststatus ! null and status #{status} /if if testkeyword ! null and keyword ! and (name like concat(%, #{keyword}, %) or description like concat(%, #{keyword}, %)) /if /where order by create_time desc /selectwhere标签会自动处理第一个and比手工写where 11干净得多。再强调一下concat的写法不要用% #{keyword} %这种字符串拼接容易因为空格和注入问题翻车。动态SQL的坑主要出在resultType上。如果数据库字段是create_time实体类字段是createTimeMyBatis默认不会自动映射。最简单的解决方式是在mybatis-config.xml里开启驼峰映射settings setting namemapUnderscoreToCamelCase valuetrue/ /settings开完这个create_time自动映射到createTime字段省掉一整个resultMap的重复编写。很多人项目跑起来发现“查出数据但字段全是null”八成就是漏了这个配置。3.4 事务边界为什么注解要写在Service实现类上宠物领养最典型的并发场景是管理员同时收到两只宠物的申请审核通过时正好都点在“同意”按钮上。如果没有事务控制可能出现申请状态已通过、宠物状态没改成已领养的脏数据。Transactional必须写在ServiceImpl类的public方法上原因有两个。第一Spring的事务是通过AOP代理实现的。代理只会拦截通过Spring容器拿到的bean实例而对同一个类内部的方法调用this.xxx()无法拦截。写在Controller上的事务注解如果在同一个Controller里调用另一个方法很可能不会生效。第二事务粒度要匹配业务粒度。一个事务应该覆盖一次完整的业务操作而不是一次HTTP请求。所以提交申请、审核申请这种动作才需要加事务获取列表这种查询通常不需要强制加。审核方法的事务是这样的Transactional public void review(Long applyId, Integer result, String remark) throws BizException { Application app applicationMapper.selectById(applyId); if (app null || app.getStatus() ! 0) { throw new BizException(该申请已被处理过); } if (result 1) { // 关键条件更新防止并发重复领养同一只宠物 int rows petMapper.updateStatusIfStatusEquals(app.getPetId(), 2, 1); if (rows 0) { throw new BizException(该宠物已被其他用户领养); } applicationMapper.updateStatusById(applyId, 1, remark); // 把这只宠物其他待审申请统一置为失效 applicationMapper.updateStatusByPetId(app.getPetId(), 0, 9); } else { applicationMapper.updateStatusById(applyId, 2, remark); } }同一个事务里先更新宠物状态再更新申请状态再批量失效其他申请。任何一个步骤抛异常全部回滚。这种写法出现脏数据的可能性才真正降到了最低。4. 领养审核这条主链路从用户提交到管理员确认的完整实现4.1 前端表单完整性与后端校验的兜底申请领养的表单通常包含姓名、联系电话、居住地址、申请理由。前端要做基本校验后端必须再做一次兜底否则别人直接绕过页面构造HTTP请求什么脏数据都能塞进来。前端校验这里不展开用原生JS或jQuery的校验插件都行。后端校验的重点是不能信任前端传过来的任何值。我自己都是写一个简单的工具方法或者直接手写判断if (StringUtils.isEmpty(form.getAddress())) { throw new BizException(居住地址不能为空); } if (StringUtils.isEmpty(form.getReason())) { throw new BizException(请填写申请理由); } if (!Pattern.matches(^1[3-9]\\d{9}$, form.getPhone())) { throw new BizException(手机号格式不正确); }如果项目后面引入了Hibernate Validator可以用NotBlank这类注解做声明式校验但SSM阶段我不建议过早依赖它。手写判断虽然“土”但它能让你清楚知道每一条规则是怎么校验的答辩时也更容易讲明白。4.2 用户重复申请的边界我在3.2节里已经写了一个判断同一用户对同一宠物只能有一条待审核状态的申请。这个判断在提交时做一次但这里要提醒一个问题——理论上存在两个相邻请求同时通过判断的极端情况。比如同一用户开两个浏览器标签同时点击提交两个请求都查到count 0都通过了校验最终就会插入两条待审核申请。要彻底杜绝最稳妥的做法是在数据库层面加唯一约束但SSM项目阶段很少做这么细致的并发控制。实际项目中这个缺陷影响不大最多是管理员看到两条重复申请点掉一条即可。但如果你在文章或答辩里能主动说出这个隐患和解决方案观感会很好。4.3 管理员审核通过后的批量失效前一章的review方法已经展示了审核的核心SQL。这里的重点在于petMapper.updateStatusIfStatusEquals这个条件更新。它的SQL本质是UPDATE pet SET status 2 WHERE id #{petId} AND status 1为什么要写成带AND status 1的条件更新假设管理员A和管理员B同时在处理两位申请人的申请这两份申请恰好都是同一只宠物下的。A先执行更新影响行数是1B再执行时因为宠物的status已经变成2条件不再成立影响行数是0。B就可以明确知道“宠物已经被领养”然后提示管理员。这就是用数据库的行锁配合条件更新解决并发抢资源的问题也是整个项目里最漂亮的一个设计点。管理员拒绝某个申请时只需要把这条申请的状态改成2拒绝即可不需要动宠物的状态。这样宠物还能继续留在领养池里等下一个申请。4.4 个人中心的数据展示用户登录后的个人中心包含“我的申请”和“我发布的宠物”两个核心页面。“我的申请”列表如果直接查application表看到的是petId根本不知道宠物叫什么名字。比较自然的做法是用JOIN一次性查出来避免在Java代码里循环再查一次宠物表也就是所谓的N1查询问题。SELECT a.*, p.name AS pet_name, p.image AS pet_image FROM application a LEFT JOIN pet p ON a.pet_id p.id WHERE a.user_id #{userId} ORDER BY a.create_time DESC在SQL里JOIN一次就能拿到申请信息和宠物摘要一条语句搞定比Service里循环查询高效得多。如果项目规模变大列表数据量大查询慢再考虑分页和索引优化。5. 部署上线前的避坑清单乱码、路径、图片上传、JSON序列化这章全部是我实际遇到过的问题也是检查代码时最容易“看哪都对、跑哪都错”的地方。5.1 中文乱码的三个来源和一次解决中文乱码在SSM项目的排查难度不高但来源确实多。我见过的情况有三种。第一种是数据库本身乱码。建库时没用utf8mb4或者表字段默认不是utf8。数据库和表都要显式指定DEFAULT CHARSETutf8mb4这是治本。第二种是请求参数乱码。特别是POST请求SpringMVC需要配置CharacterEncodingFilter放在web.xml的最前面让编码过滤器优先于其他过滤器执行。filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping第三种是JSP页面乱码。每个JSP头部都要写pageEncodingUTF-8和contentTypetext/html;charsetUTF-8。用Maven创建项目时模板自带的JSP可能不是UTF-8全改一遍就好。一次解决的标准流程是先检查数据库字符集再检查连接串是否带characterEncodingUTF-8最后确认Filter和JSP页面。按这个顺序查最多五分钟定位。5.2 绝对路径与${pageContext.request.contextPath}SSM项目里的JSP页面经常出现CSS样式加载不出来、图片404、点击链接404这种问题根源基本都是用了相对路径。如果你的项目部署在Tomcat的webapps/下面访问地址可能是http://localhost:8080/pet/其中/pet就是上下文路径。页面上如果写css/style.css浏览器解析时把它当成了http://localhost:8080/css/style.css自然就找不到。正确做法是每个页面开头统一声明c:set varctx value${pageContext.request.contextPath}/引用资源时全部用${ctx}/css/style.css表单action写成${ctx}/application/submit。这个细节虽然基础但能解决大半的“部署后页面错乱”问题。5.3 图片上传物理路径与虚拟路径映射宠物发布时的图片上传是必做功能。我建议把所有上传文件放到一个独立目录而不是项目的webapp目录下。放webapp下有个缺点每次重新部署war包上传的图片就会丢因为Tomcat会重新解压并覆盖目录。以Windows为例把图片存到D:/pet-upload/然后在SpringMVC配置里增加资源映射mvc:resources mapping/upload/** locationfile:D:/pet-upload//这样数据库里存的图片地址是/upload/2025/01/xxx.jpg浏览器访问时SpringMVC会自动去磁盘对应目录读取。部署到Linux时把这个绝对路径换成Linux路径即可代码结构不用改。上传文件本身还需要配置multipart解析器。SSM里常用的是CommonsMultipartResolver记得引入commons-fileupload和commons-io两个jar包。很多项目在这里漏掉依赖启动就报错。5.4 MyBatis懒加载和JSON序列化的相爱相杀如果你在宠物列表里用了MyBatis的懒加载比如查询宠物时关联查询留言页面或接口在关闭SqlSession之后去访问关联对象就会报LazyInitializationException。这个异常在SSM项目里有几种缓解方式。最省事的方案是不使用懒加载把关联查询改为JOIN或者立即加载。项目规模不大时这完全够用并且不会出问题。如果你确实要用懒加载可以在web.xml配置Spring的OpenSessionInViewFilter让SqlSession在整个请求周期内保持打开但这会带来长事务、连接占用等问题我不推荐新手这么做。一句话总结SSM阶段老老实实关闭MyBatis的懒加载所有关联数据在事务方法内取好再返回给页面。5.5 依赖冲突与JDBC驱动版本Maven的多模块和依赖传递有时会引入两个不同版本的Spring模块最常见的报错是方法找不到或类找不到。整改方法只有一个检查pom.xml里所有Spring相关依赖版本是否完全一致用mvn dependency:tree查看传递依赖把冲突的版本用exclusion排除掉。JDBC驱动则是另一个高频坑。用MySQL 5.7配了8.x驱动或者用8.x数据库配了5.x驱动都会出问题。最简单的办法是确定数据库版本后去Maven仓库拉一个完全匹配的驱动版本URL同步调整。不要图省事随便复制网上的配置。我把这些坑整理成一张速查表方便以后直接对照问题现象常见根因解决思路中文全是问号数据库/连接串/JSP编码不统一统一utf8mb4加Filter页面加pageEncodingCSS图片404JSP用了相对路径所有路径用${ctx}拼重新部署图片丢失上传在项目内目录独立目录mvc:resources映射JSON序列化报懒加载关联对象在session外访问关闭懒加载或事务内取值启动时ClassNotFoundSpring版本不一致dependency:tree排查并固定版本时间字段错乱MySQL 8.x驱动没配时区URL加serverTimezoneAsia/Shanghai6. 做完之后还能怎么改迈向Spring Boot的一步之遥6.1 配置代码化从XML到注解到自动装配SSM项目里最繁琐的就是三个配置文件spring-mvc.xml、spring-mybatis.xml、spring-service.xml。改造成Spring Boot后这些全部用注解和配置类替代。比如MyBatis的配置Spring Boot只需要两步在启动类或配置类上加上MapperScan(com.xxx.pet.mapper)在application.yml里配置数据源。连事务管理都可以靠EnableTransactionManagement一行开启。对已经写过SSM项目的人来说这个过程是水到渠成的因为你知道每个配置项原本是干嘛的换成Spring Boot只是换了个载体。6.2 Mapper可以保留Controller响应改成JSON改造时最不用动的就是MyBatis的Mapper层因为MyBatis-Spring-Boot-Starter兼容原生MyBatis写法。JSP则建议直接替换掉前端可以换成Thymeleaf模板或者直接把后端改造成纯JSON接口前端用Vue这类框架来渲染页面。把Controller从返回ModelAndView改成返回Result对象重点是把错误处理统一。原来页面跳转里的model.addAttribute(error, e.getMessage())在纯接口方案里变成统一的JSON错误结构。这个改造会让整个项目前后端彻底分离也更好扩展。6.3 一个更有意思的扩展方向如果做完基础功能还有余力我推荐几个方向。给宠物列表增加Redis缓存把点击量高的宠物详情页放进缓存降低数据库压力。这个方向能涉及缓存一致性、过期策略这些知识点。把本地图片上传换成对象存储摆脱磁盘路径的依赖这个方向能让你接触云服务相关概念。再给宠物增加疫苗记录、回访记录这种运营功能也是很好的业务扩展能让项目在答辩时更有“落地感”。不管选哪个方向都建立在SSM阶段已经把基础链路跑通的前提下。基础不打牢扩展就是空中楼阁。最后说一点个人体会。这个项目做下来我最大的收获不是学会了SpringMVC注解怎么用、MyBatis SQL怎么写而是理解了业务状态的一致性到底有多重要。pet.status和application.status这两个字段背后是整套领养流程的逻辑。你把状态流转想清楚了事务边界自然就清楚了事务边界清楚了代码该写在哪一层也自然就清楚了。如果重新让我做一遍这个项目我会先画一张宠物状态和申请状态的流转图把自己的操作顺序理清楚再动手写Mapper。这个习惯我现在做任何带状态机的业务时都还在用也确实帮我少走了很多弯路。