简介:这是一套面向计算机相关专业学生的JavaWeb宠物医院管理系统完整项目,源自大四毕业设计,经导师指导并获评审99分认可,适合正在准备毕设、课程设计或期末大作业的学习者,也便于需要项目实战练习的开发者参考。压缩包共113个文件,约750KB,以40个JSP页面、39个Java类、11个XML配置为主,另含CSS、JS、图片资源及1个SQL数据库脚本,覆盖前端展示、后端业务逻辑与数据持久化的完整链路。目前已有102人学习下载。项目围绕宠物医院日常业务展开,包含用户、医生、宠物等核心模块的Servlet实现,代码结构清晰、可直接运行,小白也能按说明完成部署。读者可获得一套可复用的赛题方案、规范的目录组织方式与数据库建表脚本,便于快速理解JavaWeb分层开发思路,并在此基础上进行功能扩展或二次开发。
1. 宠物医院管理系统到底在管什么:从挂号到库存的一条完整链路
宠物医院管理系统,本质上是把一家线下宠物诊所的日常运转搬到浏览器里:前台挂号、医生开方、药房扣库存、收银结账、院长看报表。标题里“javaweb”限定了技术栈——Servlet、JSP、Filter、JDBC 这一套,配合 MySQL 做持久化,用 Tomcat 跑起来。它解决的不是“给猫狗看病”这种医学问题,而是“一只狗从进门到出门,中间产生的挂号单、病历、处方、收费记录怎么不丢、不乱、可追溯”的信息流问题。
这套东西适合谁?一是计算机专业做毕设的同学,需要一份结构完整、能跑通、能讲清楚分层设计的 JavaWeb 项目;二是刚学完 Servlet/JSP 想找个真实业务练手的人,宠物医院比“学生管理系统”多出库存联动和病历关联,复杂度刚好卡在“有挑战但不至于劝退”的区间。热搜里“javaweb项目完整案例mysql”“基于jsp的毕设选题”反复出现,说明大家要的就是一个能本地跑起来、数据库脚本能直接导入、代码分层看得懂的完整案例。下面从环境搭建一路讲到库存扣减的坑,中间给可抄的配置和 SQL。
2. 环境搭建与数据库脚本导入:让项目在本地先跑起来
2.1 JDK、Tomcat、MySQL 的版本对齐
JavaWeb 项目翻车,十次有六次死在版本不对齐。常见做法是 JDK 8 + Tomcat 8.5 + MySQL 5.7 或 8.0,这套组合在 Servlet 3.1/JSP 2.3 规范下最稳。JDK 别上 17,老项目的javax.servlet包在 Jakarta EE 9 之后改名成jakarta.servlet,一升级满屏红。MySQL 8.0 要注意驱动包用mysql-connector-java-8.0.x,连接串加serverTimezone=Asia/Shanghai,否则时区报错。
IDEA 里配置 Tomcat 的步骤:Run → Edit Configurations → 加 Tomcat Server → Local → 在 Deployment 标签页加 Artifact(选war exploded)→ Application context 设成/pet。war exploded支持热更新,改 JSP 不用重启,改 Java 代码才需要。这一步热搜里“idea运行javaweb项目配置”问得最多,核心就是 Artifact 别选错,选成war每次都要重新打包。
2.2 数据库脚本的导入顺序与字符集
数据库脚本一般分建库、建表、插初始数据三段。导入顺序不能乱,外键约束会让插数据失败。常见做法是先执行建库建表,再插字典表(如科室、药品分类),最后插业务测试数据。
-- 建库,字符集必须 utf8mb4,否则宠物昵称里的 emoji 存不进去 CREATE DATABASE pet_hospital DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE pet_hospital; -- 宠物主人表 CREATE TABLE owner ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL UNIQUE, address VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 宠物表,owner_id 外键关联主人 CREATE TABLE pet ( id INT PRIMARY KEY AUTO_INCREMENT, owner_id INT NOT NULL, pet_name VARCHAR(50) NOT NULL, species VARCHAR(20), breed VARCHAR(50), birth_date DATE, CONSTRAINT fk_pet_owner FOREIGN KEY (owner_id) REFERENCES owner(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;建表时ENGINE=InnoDB必须写,MyISAM 不支持外键和事务,后面库存扣减会出大问题。utf8mb4而不是utf8,因为 MySQL 的utf8其实是阉割版,只存三字节,emoji 和部分生僻字会截断。导入用命令行mysql -u root -p pet_hospital < pet_hospital.sql,或者 Navicat 右键运行 SQL 文件。热搜里“idea导出数据库脚本”对应的反向操作:在 IDEA 的 Database 面板右键 schema → SQL Scripts → Generate DDL,能导出建表语句,但初始数据要单独导。
2.3 连接池配置与 JDBC 工具类
项目里别每次DriverManager.getConnection,连接开销大。常见做法是用 Druid 或 C3P0,毕设级别用 Druid 足够。在src下放druid.properties:
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/pet_hospital?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username=root password=你的密码 initialSize=5 maxActive=20 maxWait=3000maxWait=3000是拿不到连接最多等 3 秒,超时抛异常,避免请求全堆死。工具类里用静态代码块加载配置:
public class JDBCUtils { private static DataSource ds; static { try { Properties p = new Properties(); // 从类路径加载,注意路径别写成绝对路径 p.load(JDBCUtils.class.getClassLoader().getResourceAsStream("druid.properties")); ds = DruidDataSourceFactory.createDataSource(p); } catch (Exception e) { throw new ExceptionInInitializerError("连接池初始化失败"); } } public static Connection getConnection() throws SQLException { return ds.getConnection(); } public static void close(Connection c, Statement s, ResultSet r) { // 关闭顺序:ResultSet → Statement → Connection,反了会报错 try { if (r != null) r.close(); } catch (SQLException e) {} try { if (s != null) s.close(); } catch (SQLException e) {} try { if (c != null) c.close(); } catch (SQLException e) {} } }参数说明:initialSize是启动就建的连接数,maxActive是并发上限。毕设演示环境 5 到 20 够用,设太大反而拖慢启动。关闭顺序必须 ResultSet 先关,因为 Statement 关闭时可能还会引用结果集。这里用getResourceAsStream从 classpath 读,打包后配置文件在WEB-INF/classes下,路径不会错。
3. 分层架构与核心业务实现:挂号、病历、库存怎么串起来
3.1 三层架构的职责边界
JavaWeb 项目讲分层,常见是 Controller(Servlet)→ Service → DAO。Controller 只做参数接收和页面跳转,Service 写业务规则和事务,DAO 只碰 SQL。很多毕设把业务逻辑写在 Servlet 里,结果一个doPost三百行,答辩时被问“事务加在哪”就卡住。
以挂号为例:Controller 收到ownerId、petId、doctorId,调 Service 的register(),Service 里先查该医生当天号源是否满,再插挂号记录,最后返回挂号单号。DAO 层只提供insertRegistration和countByDoctorAndDate。事务边界在 Service,用Connection.setAutoCommit(false)手动控制,成功commit,异常rollback。
public class RegistrationService { public String register(int ownerId, int petId, int doctorId) throws SQLException { Connection conn = null; try { conn = JDBCUtils.getConnection(); conn.setAutoCommit(false); // 开启事务 RegistrationDao dao = new RegistrationDao(conn); int todayCount = dao.countByDoctorAndDate(doctorId, new Date()); if (todayCount >= 30) { throw new RuntimeException("该医生今日号源已满"); } String regNo = "REG" + System.currentTimeMillis(); dao.insert(regNo, ownerId, petId, doctorId); conn.commit(); return regNo; } catch (Exception e) { if (conn != null) conn.rollback(); throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } } }关键点:DAO 的构造方法接收外部传入的Connection,而不是自己从连接池拿。这样多个 DAO 操作能共享同一个连接,事务才生效。如果每个 DAO 各自getConnection,事务根本管不住,这是血泪经验。finally里恢复autoCommit再关连接,因为连接池回收的连接如果还是false状态,下一个借到的人会莫名其妙不提交。
3.2 病历与处方的关联插入
病历(medical_record)和处方(prescription)是一对多:一次就诊一条病历,病历下多条处方明细。插入时要先插病历拿到自增主键,再拿这个主键插处方。JDBC 里用Statement.RETURN_GENERATED_KEYS:
String sql = "INSERT INTO medical_record(pet_id, doctor_id, diagnosis, visit_time) VALUES(?,?,?,NOW())"; PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); ps.setInt(1, petId); ps.setInt(2, doctorId); ps.setString(3, diagnosis); ps.executeUpdate(); ResultSet rs = ps.getGeneratedKeys(); int recordId = 0; if (rs.next()) recordId = rs.getInt(1); // 再用 recordId 批量插处方明细RETURN_GENERATED_KEYS是 MySQL 自增主键回填的标准做法,比再查一次SELECT LAST_INSERT_ID()安全,因为并发下LAST_INSERT_ID是连接级别的,虽然也安全但多一次往返。处方明细用addBatch()批量插,减少网络往返:
String psql = "INSERT INTO prescription(record_id, medicine_id, quantity, dosage) VALUES(?,?,?,?)"; PreparedStatement pps = conn.prepareStatement(psql); for (PrescriptionItem item : items) { pps.setInt(1, recordId); pps.setInt(2, item.getMedicineId()); pps.setInt(3, item.getQuantity()); pps.setString(4, item.getDosage()); pps.addBatch(); } pps.executeBatch();批量插入时如果某条失败,默认整个批次抛异常,前面的可能已执行。要精确控制就设conn.setSavepoint(),但毕设级别用整体事务回滚即可。
3.3 库存扣减的并发问题
药房库存是最容易出 bug 的地方。两个医生同时开同一种药,如果代码写成“先查库存再更新”,中间有时间窗,会超卖。错误写法:
// 错误示范:查和更新分离,并发下超卖 int stock = dao.getStock(medicineId); if (stock >= quantity) { dao.updateStock(medicineId, stock - quantity); }正确做法是用 SQL 的原子更新,把判断和扣减合成一条:
UPDATE medicine SET stock = stock - ? WHERE id = ? AND stock >= ?然后看executeUpdate()返回的影响行数,返回 0 说明库存不足,抛异常回滚整个事务。这样数据库行锁保证了原子性,不需要应用层加 synchronized(加在单机也没用,多实例部署照样超卖)。参数说明:stock >= ?里的问号是购买数量,stock - ?也是购买数量,两个问号要设成同一个值。
public boolean deductStock(Connection conn, int medicineId, int qty) throws SQLException { String sql = "UPDATE medicine SET stock = stock - ? WHERE id = ? AND stock >= ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setInt(1, qty); ps.setInt(2, medicineId); ps.setInt(3, qty); return ps.executeUpdate() > 0; // false 表示库存不足 }这个方法必须在 Service 的事务里调用,扣减失败就rollback,保证病历、处方、库存三者要么全成要么全败。热搜里“javaweb项目完整案例mysql”真正值钱的就是这种业务一致性处理,而不是 CRUD 堆砌。
4. 避坑与排查:那些让项目跑不起来的常见问题
4.1 中文乱码:POST 和 GET 要分开治
现象:表单提交后数据库里存的是问号。原因:Tomcat 8 默认 GET 请求用 ISO-8859-1 解码,POST 才看request.setCharacterEncoding。解决:POST 在 Servlet 最前面加request.setCharacterEncoding("UTF-8");GET 要么在 Tomcat 的server.xml里给 Connector 加URIEncoding="UTF-8",要么手动new String(name.getBytes("ISO-8859-1"), "UTF-8")。数据库连接串的characterEncoding=utf8和建库的utf8mb4也要对上,三层缺一不可。
4.2 连接池耗尽:连接没关或事务没提交
现象:跑一会儿报GetConnectionTimeoutException。原因:DAO 里getConnection后异常路径没关连接,或者事务里setAutoCommit(false)后忘了commit,连接一直占着。解决:所有连接操作放try-finally,或者用 try-with-resources。排查时在 MySQL 执行SHOW PROCESSLIST,看有多少Sleep状态的连接,正常应该接近 0。Druid 配removeAbandoned=true和removeAbandonedTimeout=180能兜底回收泄漏连接,但这是后悔药,根上还是要关。
4.3 外键约束导致删数据失败
现象:删除宠物主人报Cannot delete or update a parent row。原因:pet表有外键指向owner,主人还有宠物就不能删。解决:业务上先删宠物再删主人,或者建表时外键加ON DELETE CASCADE。但级联删除要谨慎,病历、处方跟着删会丢数据。常见做法是逻辑删除,加is_deleted字段,查询时过滤,物理数据保留。
4.4 JSP 页面取不到 Session 值
现象:登录后跳转页面显示null。原因:session.setAttribute("user", user)存了,但 JSP 里用${user}取不到,可能是 EL 表达式没开启,或者存的时候用了request作用域。解决:确认web.xml的web-app版本是 2.4 以上(EL 默认开启),存用session.setAttribute,取用${sessionScope.user}明确作用域。跨重定向传值必须用 session,request 作用域在重定向后就没了。
4.5 Tomcat 启动报 ClassNotFound
现象:java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因:MySQL 驱动 jar 没放进WEB-INF/lib,只加到了 IDEA 的 Libraries 里。IDEA 的 Libraries 只影响编译,运行时 Tomcat 只认WEB-INF/lib下的 jar。解决:Project Structure → Artifacts → 确认驱动在WEB-INF/lib输出目录里,或者直接把 jar 复制到web/WEB-INF/lib。
5. 从能跑到能讲:答辩演示与二次开发的取舍
项目跑通只是及格线,毕设要拿高分得能讲清楚设计决策。演示时按“挂号 → 就诊开方 → 药房扣库存 → 收银 → 报表”走一遍完整链路,比逐个页面点更有说服力。报表用一条聚合 SQL 就能出彩:
-- 近 7 天各医生接诊量和营收 SELECT d.name AS doctor_name, COUNT(r.id) AS visit_count, IFNULL(SUM(b.total_amount), 0) AS revenue FROM doctor d LEFT JOIN registration r ON d.id = r.doctor_id AND r.visit_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) LEFT JOIN bill b ON r.id = b.registration_id GROUP BY d.id, d.name ORDER BY revenue DESC;LEFT JOIN保证没接诊的医生也显示,IFNULL把 NULL 营收转成 0,DATE_SUB算时间窗口。这条 SQL 能讲出“为什么用左连接”“聚合函数和分组的关系”,比背概念强。
二次开发方向,常见的是加预约提醒(定时任务扫明天的挂号记录)、加会员折扣(在收银 Service 里按累计消费算折扣率)、加药品效期预警(查expire_date在 30 天内的批次)。每个方向都对应一个具体的 Service 方法扩展,不用动架构。我自己的习惯是先把web.xml里的欢迎页和错误页配好,404 和 500 跳自定义页面,演示时不会因为一个手误的 URL 露出 Tomcat 默认报错页,这个细节答辩老师会注意到。数据库脚本每次改表结构后重新导出一次,和代码一起版本管理,别等到答辩前一天发现本地库和脚本对不上。希望帮到你。
本文还有配套的精品资源,点击获取