☰
Javaweb药店管理系统实战:从数据库设计到库存预警的完整案例
2026/10/8 12:58:51 网站建设 项目流程

简介:这是一套基于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 为什么药店管理系统推荐 Servlet+JSP+MySQL 而不是上来就 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 或 11,MySQL 驱动用mysql-connector-java 8.0.28以上。我一般会建议:课程设计用 Servlet+JSP,企业内训用 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 药品基础信息表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存批次表,核心表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 销售订单主表 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 '收银员' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 销售明细表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段 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 { // 参数说明:useUnicode+characterEncoding 解决中文乱码 // serverTimezone 解决 MySQL 8 时区报错 // useSSL=false 关闭 SSL 警告 private static final String URL = "jdbc:mysql://localhost:3306/pharmacy_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true"; 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) {} } }

这里每个参数都有讲究。characterEncoding=utf8要和数据库的utf8mb4配合,但 JDBC 里写utf8就够了,驱动会自动映射。serverTimezone=Asia/Shanghai是 MySQL 8 的必填项,不写会报The server time zone value is unrecognized。allowPublicKeyRetrieval=true是 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"); List<Medicine> 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?action=list"); } } @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?action=list"); } }

逻辑上,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, List<SaleDetail> 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 BY+HAVING算出每个药品的总库存再比较阈值,注意HAVING里不能用SUM的别名在某些 MySQL 版本会有兼容问题,稳妥写法是重复表达式。第二条直接按expire_date过滤,90 天这个参数我一般做成配置项,不同药店要求不同。返回结果里用warn_type区分类型,前端用不同颜色标签展示即可。

4. 避坑与排查:那些让药店管理系统跑不起来的常见问题

4.1 中文乱码从数据库到页面全链路排查

现象:药品名称在 JSP 页面显示正常,存进数据库变成???,或者反过来数据库里正常但页面乱码。原因通常出在三个环节之一:数据库字符集、JDBC 连接参数、Servlet 请求编码。解决顺序是自下而上:先确认SHOW VARIABLES LIKE 'character%'里character_set_server是utf8mb4;再检查 JDBC URL 里有没有characterEncoding=utf8;最后确认doPost第一行有没有req.setCharacterEncoding("UTF-8")。三个都对了,乱码基本消失。如果还不行,检查 Tomcat 的server.xml里 Connector 有没有URIEncoding="UTF-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 后面加serverTimezone=Asia/Shanghai。如果加了还报错,检查驱动版本是不是 8.0 以上,5.x 的驱动不认这个参数。另外allowPublicKeyRetrieval=true也建议加上,否则 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,店长点一下首页要等两秒,体验直接崩。这个系统看着简单,但真正决定它能不能用的,往往就是这些索引和事务的细节。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询