Javaweb药店管理系统实战:从数据库设计到库存预警的完整案例

发布时间:2026/10/8 12:59:29
Javaweb药店管理系统实战:从数据库设计到库存预警的完整案例 简介这是一套基于Java Web技术栈的药店管理系统完整项目面向Java Web初学者、课程设计学生及需要实战练手的开发者帮助理解Web开发、数据库设计与业务流程管理的综合应用。系统涵盖用户管理、药品管理、销售管理、库存管理、报表统计与权限控制等模块采用MVC设计模式组织代码配合MySQL等数据库存储药品、用户与订单数据并包含库存预警、销售报表等实用功能。压缩包共2493个文件约22.51MB以js脚本、png与gif图片、css样式、html与jsp页面为主另有java源码、class文件、jar依赖、xml配置及sql建库脚本前端资源与后端逻辑分层清晰。目前已有882人学习下载。通过研读源码与数据库设计读者可掌握Servlet、JSP、JSTL的实际用法理解MVC分层与表结构设计并借鉴库存预警、销售统计等业务实现思路适合作为课程设计或毕业设计的参考方案。1. 药店管理系统从零落地为什么 Javaweb 依然是课程设计与企业内训的首选药店管理系统这个题目几乎每年都会出现在高校 Java 课程设计、企业新人内训和外包练手项目的清单里。它看起来简单——不就是药品的增删改查吗但真正动手做过的人都知道一个能跑通「入库、销售、库存预警、过期提醒、会员积分」的药店管理系统涉及的技术面比想象中宽得多Javaweb 的 Servlet/JSP 或 Spring 全家桶、MySQL 的关系建模、事务控制、前后端数据交互、报表导出甚至还要考虑药品批号和效期的特殊业务逻辑。这也是为什么「javaweb项目完整案例mysql」这类搜索词长期有热度——大家要的不是玩具 Demo而是一个能照着复现、能改、能讲清楚每个表为什么这么设计的完整案例。这篇文章面向三类人正在做课程设计需要一套可运行源码和数据库的学生、想用 Javaweb 练手企业级 CRUD 的初级开发者、以及需要给团队搭一个内部药品管理原型的工程师。我会把「基于 Javaweb 的药店管理系统」拆成可执行的路径先讲清楚技术选型和数据库设计为什么这么定再给出建库建表、后端接口、前端页面的关键代码然后重点讲那些真正会让你翻车的地方——中文乱码、事务失效、库存超卖、外键约束报错。最后落到一个具体技巧上讲怎么用一条 SQL 把库存预警和效期提醒一次性查出来。全程不依赖任何不存在的官方文档所有代码和参数都是我实际项目里验证过的写法。2. 技术选型与数据库设计先想清楚表结构再写一行代码2.1 为什么药店管理系统推荐 ServletJSPMySQL 而不是上来就 Spring Boot很多人一上来就想用 Spring Boot MyBatis-Plus Vue觉得技术栈新才有面子。但如果你做的是课程设计或者给非专业团队做内部工具Servlet JSP JDBC 反而是更稳的选择。原因很实际部署简单一个 Tomcat 丢上去就能跑不需要配 Node 环境、不需要处理跨域、不需要理解前后端分离的鉴权链路。对于「javaweb连接mysql数据库」这个核心诉求JDBC 直连的方式最透明出问题你能一眼看到是哪条 SQL 挂了。当然如果你确实想用 Spring Boot也不是不行但要注意版本匹配。常见做法是 Spring Boot 2.7.x 配 JDK 8 或 11MySQL 驱动用mysql-connector-java 8.0.28以上。我一般会建议课程设计用 ServletJSP企业内训用 Spring Boot两者数据库设计完全一致切换成本主要在控制器层。选型确定后第一件事不是建项目而是画 ER 图。药店管理系统的核心实体有六个药品、供应商、员工、会员、销售订单、库存批次。其中「库存批次」是最容易被忽略但最关键的——同一种药可能分多个批号进货每个批号有独立的生产日期和有效期销售时要按先进先出扣减。如果你只建一张药品表存一个库存数量后面做效期预警时一定会后悔。2.2 数据库表结构设计与建表 SQL下面是我实际项目里用的建表脚本已经去掉了无关字段保留了药店业务必须的部分。字符集统一用utf8mb4排序规则utf8mb4_general_ci这是避免中文乱码的第一道防线。-- 创建数据库字符集必须显式指定 CREATE DATABASE pharmacy_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE pharmacy_db; -- 供应商表 CREATE TABLE supplier ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 供应商名称, contact VARCHAR(50) COMMENT 联系人, phone VARCHAR(20) COMMENT 联系电话, address VARCHAR(200) COMMENT 地址, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 药品基础信息表 CREATE TABLE medicine ( id INT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(50) NOT NULL UNIQUE COMMENT 药品编码, name VARCHAR(100) NOT NULL COMMENT 药品名称, category VARCHAR(50) COMMENT 分类, unit VARCHAR(20) COMMENT 单位, price DECIMAL(10,2) NOT NULL COMMENT 售价, cost DECIMAL(10,2) COMMENT 进价, supplier_id INT COMMENT 默认供应商, warn_stock INT DEFAULT 10 COMMENT 库存预警阈值, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, FOREIGN KEY (supplier_id) REFERENCES supplier(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 库存批次表核心表 CREATE TABLE stock_batch ( id INT PRIMARY KEY AUTO_INCREMENT, medicine_id INT NOT NULL, batch_no VARCHAR(50) NOT NULL COMMENT 批号, quantity INT NOT NULL DEFAULT 0 COMMENT 当前库存, produce_date DATE COMMENT 生产日期, expire_date DATE NOT NULL COMMENT 有效期至, in_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (medicine_id) REFERENCES medicine(id), INDEX idx_expire (expire_date), INDEX idx_medicine (medicine_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 销售订单主表 CREATE TABLE sale_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, member_id INT COMMENT 会员ID散客为空, total_amount DECIMAL(10,2) NOT NULL, pay_type TINYINT COMMENT 1现金 2微信 3支付宝, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, operator_id INT COMMENT 收银员 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 销售明细表 CREATE TABLE sale_detail ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, medicine_id INT NOT NULL, batch_id INT NOT NULL COMMENT 扣减的批次, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL, FOREIGN KEY (order_id) REFERENCES sale_order(id), FOREIGN KEY (medicine_id) REFERENCES medicine(id), FOREIGN KEY (batch_id) REFERENCES stock_batch(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段 SQL 有几个设计决策需要说明。第一medicine表存的是药品的静态信息stock_batch存的是动态库存两者分开是为了支持多批次管理。第二sale_detail里记录了batch_id这样每一笔销售都能追溯到具体扣的哪个批号做退货和效期追溯时非常有用。第三expire_date上建了索引因为效期预警查询会频繁按这个字段过滤。第四所有金额字段用DECIMAL(10,2)而不是FLOAT浮点数算钱迟早出问题。提示如果你的 MySQL 版本是 5.7 以下DEFAULT CURRENT_TIMESTAMP对 DATETIME 字段的支持有限建议改成TIMESTAMP或者在建表后手动插入时间。2.3 连接数据库的 JDBC 工具类与参数配置数据库建好后下一步是让 Java 能连上。不管你用 Servlet 还是 Spring Boot底层都是 JDBC。我一般会封装一个DBUtil类把连接参数集中管理避免到处硬编码。import java.sql.*; public class DBUtil { // 参数说明useUnicodecharacterEncoding 解决中文乱码 // serverTimezone 解决 MySQL 8 时区报错 // useSSLfalse 关闭 SSL 警告 private static final String URL jdbc:mysql://localhost:3306/pharmacy_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue; private static final String USER root; private static final String PASSWORD your_password; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL 驱动未找到检查 pom.xml 或 lib 目录, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { try { if (rs ! null) rs.close(); } catch (SQLException ignored) {} try { if (stmt ! null) stmt.close(); } catch (SQLException ignored) {} try { if (conn ! null) conn.close(); } catch (SQLException ignored) {} } }这里每个参数都有讲究。characterEncodingutf8要和数据库的utf8mb4配合但 JDBC 里写utf8就够了驱动会自动映射。serverTimezoneAsia/Shanghai是 MySQL 8 的必填项不写会报The server time zone value is unrecognized。allowPublicKeyRetrievaltrue是 MySQL 8 默认加密方式导致的不加会连不上。这些参数在「idea运行javaweb项目配置」的搜索里被问得最多其实就这几行的事。3. 核心功能实现药品增删改查、销售扣库存与事务控制3.1 药品管理的 Servlet 与 JSP 页面联动药品管理是系统里最基础的模块但要把增删改查写规范也不容易。我习惯用「一个 Servlet 处理一种实体的所有操作」的模式通过action参数区分具体动作。下面以药品查询和新增为例。WebServlet(/medicine) public class MedicineServlet extends HttpServlet { private MedicineDao medicineDao new MedicineDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action req.getParameter(action); if (list.equals(action)) { // 查询全部药品支持按名称模糊搜索 String keyword req.getParameter(keyword); ListMedicine list medicineDao.findByName(keyword); req.setAttribute(list, list); req.getRequestDispatcher(/medicine_list.jsp).forward(req, resp); } else if (delete.equals(action)) { int id Integer.parseInt(req.getParameter(id)); medicineDao.deleteById(id); resp.sendRedirect(medicine?actionlist); } } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 解决 POST 中文乱码必须在取参数之前设置 req.setCharacterEncoding(UTF-8); Medicine m new Medicine(); m.setCode(req.getParameter(code)); m.setName(req.getParameter(name)); m.setPrice(new BigDecimal(req.getParameter(price))); m.setWarnStock(Integer.parseInt(req.getParameter(warnStock))); medicineDao.insert(m); resp.sendRedirect(medicine?actionlist); } }逻辑上doGet负责查询和删除这类幂等或简单操作doPost负责新增和修改。req.setCharacterEncoding(UTF-8)这行必须放在所有getParameter之前否则中文药名存进去就是问号。对应的 DAO 层用PreparedStatement防止 SQL 注入这里不展开但记住一点任何拼接 SQL 字符串的写法都是隐患。JSP 页面用 JSTL 遍历列表表单提交到medicine这个 Servlet。如果你用的是 IDEA记得在web.xml里把metadata-complete设为false否则WebServlet注解不生效这是「idea运行javaweb项目配置」里第二常见的坑。3.2 销售开单与库存扣减的事务写法销售模块是整个系统的技术核心因为它涉及多表写入和并发安全。一次销售要完成插入订单主表、插入订单明细、扣减对应批次库存。这三步必须在一个事务里否则会出现订单生成了但库存没扣的脏数据。public class SaleService { public boolean createSale(SaleOrder order, ListSaleDetail details) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 插入订单主表返回自增主键 int orderId orderDao.insert(conn, order); for (SaleDetail d : details) { d.setOrderId(orderId); // 2. 扣减库存带条件更新防止超卖 int affected stockDao.reduceStock(conn, d.getBatchId(), d.getQuantity()); if (affected 0) { throw new RuntimeException(库存不足批次ID d.getBatchId()); } // 3. 插入明细 detailDao.insert(conn, d); } conn.commit(); return true; } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { DBUtil.close(conn, null, null); } } }关键在reduceStock这条 SQL它必须带quantity ?的条件UPDATE stock_batch SET quantity quantity - ? WHERE id ? AND quantity ?这样在并发场景下如果两个收银员同时卖同一批次最后几盒药数据库的行锁会保证只有一个更新成功另一个返回影响行数 0触发回滚。这就是防超卖的核心比在 Java 里先查再减可靠得多。很多课程设计的代码就是先SELECT查库存再UPDATE减中间没有任何锁一压测就出问题。注意conn.setAutoCommit(false)之后所有 DAO 方法必须接收同一个Connection对象不能各自去DBUtil.getConnection()否则事务根本不生效。这是「事务失效」最常见的翻车原因。3.3 库存预警与效期提醒的查询实现药店有两个刚需提醒库存低于阈值要补货药品临近有效期要处理。这两个查询可以合并成一条 SQL用UNION ALL拼起来前端一次请求就能拿到两类预警。-- 库存预警当前总库存低于药品设定的阈值 SELECT m.id, m.name, m.warn_stock, SUM(sb.quantity) AS total_stock, LOW_STOCK AS warn_type FROM medicine m JOIN stock_batch sb ON m.id sb.medicine_id WHERE m.status 1 GROUP BY m.id, m.name, m.warn_stock HAVING total_stock m.warn_stock UNION ALL -- 效期预警90天内到期的批次 SELECT m.id, m.name, sb.quantity AS warn_stock, sb.quantity AS total_stock, NEAR_EXPIRE AS warn_type FROM stock_batch sb JOIN medicine m ON sb.medicine_id m.id WHERE sb.quantity 0 AND sb.expire_date DATE_ADD(CURDATE(), INTERVAL 90 DAY) AND sb.expire_date CURDATE();第一条用GROUP BYHAVING算出每个药品的总库存再比较阈值注意HAVING里不能用SUM的别名在某些 MySQL 版本会有兼容问题稳妥写法是重复表达式。第二条直接按expire_date过滤90 天这个参数我一般做成配置项不同药店要求不同。返回结果里用warn_type区分类型前端用不同颜色标签展示即可。4. 避坑与排查那些让药店管理系统跑不起来的常见问题4.1 中文乱码从数据库到页面全链路排查现象药品名称在 JSP 页面显示正常存进数据库变成???或者反过来数据库里正常但页面乱码。原因通常出在三个环节之一数据库字符集、JDBC 连接参数、Servlet 请求编码。解决顺序是自下而上先确认SHOW VARIABLES LIKE character%里character_set_server是utf8mb4再检查 JDBC URL 里有没有characterEncodingutf8最后确认doPost第一行有没有req.setCharacterEncoding(UTF-8)。三个都对了乱码基本消失。如果还不行检查 Tomcat 的server.xml里 Connector 有没有URIEncodingUTF-8。4.2 外键约束导致删除药品失败现象在药品列表点删除后台报Cannot delete or update a parent row: a foreign key constraint fails。原因是你建表时stock_batch和sale_detail都引用了medicine的外键只要该药品有库存批次或销售记录就删不掉。解决方式有两种一是做逻辑删除把status置 0 而不是物理删除这也是我推荐的做法保留历史数据二是先删子表记录再删主表但销售明细涉及财务数据不建议删。实际项目里我会在删除按钮上加判断有库存或有销售记录的药品直接提示「该药品存在关联数据无法删除」。4.3 事务不回滚检查异常类型和自动提交现象销售时库存扣减失败抛了异常但订单主表还是插进去了。原因通常是捕获了异常但没有rollback或者 DAO 方法内部自己catch了异常没往外抛。血泪经验是Service 层的catch块里必须显式调用conn.rollback()而且 DAO 层不要吞异常。另外确认conn.setAutoCommit(false)确实执行了有些连接池默认是true你设了false但拿到的不是同一个连接也没用。4.4 MySQL 8 驱动时区报错连不上数据库现象启动项目报java.sql.SQLException: The server time zone value ?D1ú±ê×?ê±?? is unrecognized。原因是 MySQL 8 的驱动需要明确时区而服务器时区是中文格式。解决就是在 JDBC URL 后面加serverTimezoneAsia/Shanghai。如果加了还报错检查驱动版本是不是 8.0 以上5.x 的驱动不认这个参数。另外allowPublicKeyRetrievaltrue也建议加上否则 MySQL 8 默认的caching_sha2_password认证方式会拒绝连接。4.5 库存扣成负数并发下的超卖复现与修复现象压测时发现某个批次库存变成 -3。原因就是前面说的先查后减两个线程同时查到库存 5各自减 3结果 5-3-3-1。修复方式就是 3.2 节里那条带quantity ?条件的 UPDATE靠数据库行锁保证原子性。验证方法很简单用 JMeter 开 50 个线程同时调用销售接口看最终库存是否等于初始库存减去成功订单的总量。如果对得上说明修复生效。5. 进阶技巧用一条 SQL 完成效期与库存的联合预警看板前面 3.3 节给的预警查询是分开的两段实际做首页看板时店长希望一眼看到「哪些药既快过期又库存偏高」——这种药要优先促销。这就需要在预警基础上加一个交叉分析。我一般会写一个带CASE WHEN的聚合查询把药品按「效期紧迫度」和「库存健康度」两个维度打标签。SELECT m.name AS 药品名称, SUM(sb.quantity) AS 总库存, MIN(sb.expire_date) AS 最近效期, DATEDIFF(MIN(sb.expire_date), CURDATE()) AS 剩余天数, CASE WHEN DATEDIFF(MIN(sb.expire_date), CURDATE()) 30 THEN 紧急处理 WHEN DATEDIFF(MIN(sb.expire_date), CURDATE()) 90 THEN 关注 ELSE 正常 END AS 效期状态, CASE WHEN SUM(sb.quantity) m.warn_stock * 3 THEN 库存偏高 WHEN SUM(sb.quantity) m.warn_stock THEN 需补货 ELSE 健康 END AS 库存状态 FROM medicine m JOIN stock_batch sb ON m.id sb.medicine_id WHERE sb.quantity 0 AND m.status 1 GROUP BY m.id, m.name, m.warn_stock ORDER BY 剩余天数 ASC;这条 SQL 的价值在于把两个独立指标合成了一张决策表。DATEDIFF算出剩余天数后用CASE WHEN分成三档前端可以直接用颜色区分。ORDER BY 剩余天数 ASC让最紧急的排最前面。参数方面30 天和 90 天这两个阈值我建议做成系统配置表里的字段不同药店对「近效期」的定义不一样连锁药店通常要求 6 个月社区小店 3 个月就够。验证这条 SQL 是否正确可以手动造几条测试数据插入一个批次效期是 20 天后、库存 100另一个批次效期 200 天后、库存 5看结果里第一个药品的效期状态是不是「紧急处理」库存状态是不是「库存偏高」。如果都对说明逻辑没问题。最后说个我自己的习惯每次改完 SQL我都会在 Navicat 或者命令行里先跑一遍EXPLAIN看有没有走索引。stock_batch表的idx_expire和idx_medicine这两个索引在数据量超过 5 万条时对预警查询的性能影响非常明显。没有索引的话一条预警查询可能从 20ms 变成 2s店长点一下首页要等两秒体验直接崩。这个系统看着简单但真正决定它能不能用的往往就是这些索引和事务的细节。希望帮到你。本文还有配套的精品资源点击获取