ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

药店管理系统实战:Servlet+JSP+MySQL从建库到上线

2026/9/28 11:32:58 拓冰建站 浏览量
药店管理系统实战:Servlet+JSP+MySQL从建库到上线 简介这是一套面向JavaWeb初学者与课程设计需求的药店管理系统完整源码包基于servlet、jsp与MySQL实现配合Tomcat部署运行适合作为毕业设计、实训项目或自学练手参考。系统覆盖客户信息管理、药品类别管理、药品信息增删改查与出入库、出入库记录列表展示、用户管理及修改密码、退出登录等模块业务链条较为完整。压缩包共112个文件约2.19MB包含32个java源文件与32个class编译文件、7个jsp页面、8个css与6个js前端资源、6个jar依赖以及1个sql数据库脚本和Eclipse项目配置源码、页面、样式与建库脚本齐备导入后即可对照调试。目前已有574人学习下载可帮助读者理解DAO分层、Servlet请求处理与JSP展示的协作方式快速搭建可运行的药店管理项目并在此基础上二次开发。1. 药店管理系统为什么还在用 ServletJSP一个被低估的练手与交付方案打开招聘网站搜「Java 开发」满屏的 Spring Boot、微服务、分布式但你去翻很多中小软件公司的实际交付清单会发现一个反直觉的事实区域连锁药店、社区诊所、单体药房的管理系统相当一部分仍然是 JavaWeb Servlet JSP MySQL 这套「老技术栈」在跑。原因不复杂——这类项目并发量不大、部署环境简单、客户预算有限用 Spring Boot 反而增加了运维和二次开发的门槛。药店管理系统的核心诉求就四件事药品档案能录、库存能查、销售能开单、过期能预警。Servlet 负责请求分发和业务控制JSP 负责页面渲染MySQL 存数据这套组合足够撑起一个日订单几百到几千的门店系统。这篇文章面向两类人一是正在做课程设计或毕设、需要一套完整可运行的药店管理系统源码和数据库的同学二是刚入行、想通过一个真实业务场景把 Servlet 生命周期、JSP 内置对象、JDBC 连接池这些基础打牢的开发者。我会从建库建表开始一路讲到 Servlet 怎么写、JSP 怎么渲染、库存扣减怎么防并发最后给出几个只有踩过才知道的坑。你照着做能在本地跑起来一套能用的系统你理解了能把它改造成自己需要的任何进销存场景。2. 先把数据库和项目骨架搭起来药店管理系统的表设计与工程结构2.1 药品、库存、销售三张核心表怎么设计药店管理系统的数据库设计不需要花哨但有几个字段如果一开始没留后面改起来非常痛苦。我一般会建四张表药品分类表、药品信息表、库存流水表、销售订单表。药品信息表里必须有的字段包括药品编号唯一、通用名、商品名、规格、生产厂家、批准文号、进价、售价、库存数量、库存下限、有效期、是否处方药。其中「库存下限」和「有效期」是药店场景的刚需前者触发补货提醒后者触发过期预警。下面是我常用的建表 SQL直接在 MySQL 8.0 里执行即可。注意字符集用 utf8mb4排序规则用 utf8mb4_general_ci避免中文药品名出现乱码。-- 创建数据库 CREATE DATABASE pharmacy_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE pharmacy_db; -- 药品分类表 CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 分类名称如抗生素、解热镇痛, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 药品信息表 CREATE TABLE medicine ( id INT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(30) NOT NULL UNIQUE COMMENT 药品编号, generic_name VARCHAR(100) NOT NULL COMMENT 通用名, trade_name VARCHAR(100) COMMENT 商品名, spec VARCHAR(50) COMMENT 规格如0.25g*24粒, manufacturer VARCHAR(100) COMMENT 生产厂家, approval_no VARCHAR(50) COMMENT 批准文号, category_id INT COMMENT 分类ID, purchase_price DECIMAL(10,2) DEFAULT 0.00 COMMENT 进价, sale_price DECIMAL(10,2) DEFAULT 0.00 COMMENT 售价, stock INT DEFAULT 0 COMMENT 当前库存, stock_min INT DEFAULT 10 COMMENT 库存下限, expire_date DATE COMMENT 有效期至, is_rx TINYINT(1) DEFAULT 0 COMMENT 是否处方药 0否1是, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 销售订单表 CREATE TABLE sale_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 订单号, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, operator VARCHAR(30) COMMENT 收银员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 销售明细表 CREATE TABLE sale_detail ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, medicine_id INT NOT NULL, quantity INT NOT NULL, unit_price DECIMAL(10,2) NOT NULL, subtotal DECIMAL(10,2) NOT NULL, FOREIGN KEY (order_id) REFERENCES sale_order(id), FOREIGN KEY (medicine_id) REFERENCES medicine(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这几张表的逻辑关系是category 和 medicine 是一对多sale_order 和 sale_detail 是一对多sale_detail 通过 medicine_id 关联到具体药品。库存扣减发生在销售开单时需要同时更新 medicine.stock 并写入 sale_detail。这里有个关键点stock 字段不要用无符号整数因为退货场景可能出现临时负数用有符号 INT 更灵活。2.2 IDEA 里建 JavaWeb 项目的目录结构用 IDEA 新建项目时选 Java Enterprise勾选 Web Application不要选 Spring Initializr。建好后的目录结构应该是这样pharmacy-system/ ├── src/ │ └── main/ │ ├── java/ │ │ └── com/pharmacy/ │ │ ├── dao/ # 数据库访问层 │ │ ├── entity/ # 实体类 │ │ ├── servlet/ # 控制器 │ │ └── util/ # 工具类如DBUtil │ ├── resources/ │ │ └── db.properties # 数据库配置 │ └── webapp/ │ ├── WEB-INF/ │ │ └── web.xml # 部署描述符 │ ├── css/ │ ├── js/ │ └── *.jsp # 页面文件 └── pom.xml 或 lib/ # 依赖管理如果你用的是 Mavenpom.xml 里至少需要三个依赖mysql-connector-java8.0.x、javax.servlet-api4.0.1scope 设为 provided、jstl1.2。不用 Maven 的话把这三个 jar 包丢进 WEB-INF/lib 也行。我一般推荐 Maven因为后面换连接池、加 JSON 库的时候省事。注意Servlet 4.0 对应的包名是 javax.servlet不是 jakarta.servlet。如果你 Tomcat 版本是 10 以上包名变成了 jakarta很多老教程的代码会直接编译不过。新手建议先用 Tomcat 9.x和绝大多数教程保持一致。2.3 数据库连接工具类与连接池配置直接在 Servlet 里 DriverManager.getConnection 是最容易翻车的做法——每次请求都新建连接几十个并发就能把 MySQL 的连接数打满。正确做法是用连接池。Druid 和 HikariCP 都行我习惯用 Druid因为它的监控面板对调试很有帮助。// DBUtil.java package com.pharmacy.util; import com.alibaba.druid.pool.DruidDataSource; import java.sql.Connection; import java.sql.SQLException; import java.util.ResourceBundle; public class DBUtil { private static DruidDataSource dataSource new DruidDataSource(); static { // 从 db.properties 读取配置 ResourceBundle rb ResourceBundle.getBundle(db); dataSource.setUrl(rb.getString(jdbc.url)); dataSource.setUsername(rb.getString(jdbc.username)); dataSource.setPassword(rb.getString(jdbc.password)); dataSource.setDriverClassName(rb.getString(jdbc.driver)); // 连接池参数 dataSource.setInitialSize(5); // 初始连接数 dataSource.setMinIdle(5); // 最小空闲连接 dataSource.setMaxActive(20); // 最大活跃连接 dataSource.setMaxWait(3000); // 获取连接超时时间(ms) } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(Connection conn) { if (conn ! null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }db.properties 放在 resources 目录下jdbc.urljdbc:mysql://localhost:3306/pharmacy_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 jdbc.usernameroot jdbc.password你的密码 jdbc.drivercom.mysql.cj.jdbc.Driver参数说明maxActive 设 20 是经验值药店系统并发不高20 个连接足够maxWait 设 3000ms 意味着获取连接超过 3 秒就抛异常避免请求无限等待。serverTimezone 必须设否则 MySQL 8 会报时区错误。useSSLfalse 在本地开发时关掉生产环境建议开启。3. Servlet 写业务、JSP 做渲染药品增删改查的完整链路3.1 药品列表查询从 Servlet 到 JSP 的数据流转先写实体类 Medicine字段和数据库表一一对应用 IDE 自动生成 getter/setter。然后写 DAO 层这里只贴最核心的查询方法// MedicineDao.java public ListMedicine findAll() { ListMedicine list new ArrayList(); String sql SELECT m.*, c.name AS category_name FROM medicine m LEFT JOIN category c ON m.category_id c.id ORDER BY m.id DESC; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { Medicine m new Medicine(); m.setId(rs.getInt(id)); m.setCode(rs.getString(code)); m.setGenericName(rs.getString(generic_name)); m.setSpec(rs.getString(spec)); m.setStock(rs.getInt(stock)); m.setSalePrice(rs.getBigDecimal(sale_price)); m.setExpireDate(rs.getDate(expire_date)); m.setCategoryName(rs.getString(category_name)); list.add(m); } } catch (SQLException e) { e.printStackTrace(); } return list; }Servlet 里调用 DAO把结果集塞进 request 域然后转发到 JSP// MedicineListServlet.java WebServlet(/medicine/list) public class MedicineListServlet extends HttpServlet { private MedicineDao medicineDao new MedicineDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { ListMedicine list medicineDao.findAll(); req.setAttribute(medicineList, list); // 转发到 JSP 渲染 req.getRequestDispatcher(/medicine_list.jsp).forward(req, resp); } }JSP 页面用 JSTL 的 c:forEach 遍历% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % html headtitle药品列表/title/head body table border1 tr th编号/thth通用名/thth规格/th th库存/thth售价/thth有效期/thth操作/th /tr c:forEach items${medicineList} varm tr td${m.code}/td td${m.genericName}/td td${m.spec}/td td${m.stock}/td tdfmt:formatNumber value${m.salePrice} pattern#0.00//td tdfmt:formatDate value${m.expireDate} patternyyyy-MM-dd//td td a href${pageContext.request.contextPath}/medicine/edit?id${m.id}编辑/a a href${pageContext.request.contextPath}/medicine/delete?id${m.id} onclickreturn confirm(确认删除)删除/a /td /tr /c:forEach /table /body /html这段链路的关键在于Servlet 只负责取数据和转发JSP 只负责展示业务逻辑不写在 JSP 的 scriptlet 里。我见过太多毕设项目在 JSP 里直接写 JDBC 查询页面和数据库耦合死改一个字段要动五个文件。用 request.setAttribute forward 的方式职责清晰后面加缓存或换前端框架都容易。3.2 销售开单与库存扣减事务和并发的正确处理销售开单是药店系统里唯一需要事务的地方。用户点「结算」时要同时做三件事插入 sale_order、批量插入 sale_detail、扣减 medicine.stock。这三步必须在一个事务里否则会出现订单生成了但库存没扣或者库存扣了订单没生成的情况。// SaleService.java public boolean createOrder(SaleOrder order, ListSaleDetail details) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 插入订单主表 String orderSql INSERT INTO sale_order(order_no, total_amount, operator) VALUES(?,?,?); PreparedStatement ps1 conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS); ps1.setString(1, order.getOrderNo()); ps1.setBigDecimal(2, order.getTotalAmount()); ps1.setString(3, order.getOperator()); ps1.executeUpdate(); ResultSet rs ps1.getGeneratedKeys(); int orderId 0; if (rs.next()) orderId rs.getInt(1); // 2. 批量插入明细 扣库存 String detailSql INSERT INTO sale_detail(order_id, medicine_id, quantity, unit_price, subtotal) VALUES(?,?,?,?,?); String stockSql UPDATE medicine SET stock stock - ? WHERE id ? AND stock ?; PreparedStatement ps2 conn.prepareStatement(detailSql); PreparedStatement ps3 conn.prepareStatement(stockSql); for (SaleDetail d : details) { ps2.setInt(1, orderId); ps2.setInt(2, d.getMedicineId()); ps2.setInt(3, d.getQuantity()); ps2.setBigDecimal(4, d.getUnitPrice()); ps2.setBigDecimal(5, d.getSubtotal()); ps2.addBatch(); // 扣库存带 stock quantity 条件防止超卖 ps3.setInt(1, d.getQuantity()); ps3.setInt(2, d.getMedicineId()); ps3.setInt(3, d.getQuantity()); int affected ps3.executeUpdate(); if (affected 0) { throw new SQLException(库存不足药品ID d.getMedicineId()); } } ps2.executeBatch(); conn.commit(); return true; } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这里有两个关键设计。第一扣库存的 SQL 带了AND stock ?条件如果库存不够executeUpdate 返回 0直接抛异常触发回滚。这比先查库存再扣更安全因为查和扣之间可能有其他请求插进来。第二用 addBatch executeBatch 批量插入明细减少网络往返。注意事务里不要用连接池的自动关闭必须手动 commit 或 rollback否则连接归还时事务状态不确定。3.3 JSP 页面复用把头部、侧边栏抽成 include药店管理系统页面不少如果每个 JSP 都复制一遍导航栏改一个菜单要改十几个文件。用 JSP 的 include 指令可以解决!-- header.jsp -- % page contentTypetext/html;charsetUTF-8 languagejava % div classheader h1药店管理系统/h1 span当前用户${sessionScope.username}/span a href${pageContext.request.contextPath}/logout退出/a /div在主页面里引入% include file/common/header.jsp %注意% include %是静态包含编译时合并成一个 Servletjsp:include是动态包含运行时分别执行。导航栏这种不随请求变化的用静态包含需要传参的用动态包含。另外include 的页面里不要再写html和body标签否则生成的 HTML 结构会乱。4. 药店管理系统上线前必须处理的五个坑4.1 中文乱码POST 和 GET 要分开处理现象表单提交的药品中文名在数据库里变成问号或者页面显示乱码。原因Tomcat 8 以后 GET 请求默认用 UTF-8但 POST 请求体默认用 ISO-8859-1 解码。很多人只在 JSP 页面写了contentTypetext/html;charsetUTF-8这只管响应不管请求。解决在 Servlet 的 doPost 第一行加req.setCharacterEncoding(UTF-8)并且要放在所有 getParameter 之前。如果用的是 Tomcat 9 以下GET 请求还要在 server.xml 的 Connector 里加URIEncodingUTF-8。更省事的做法是写一个 CharacterEncodingFilter在 web.xml 里配 /* 拦截所有请求。4.2 连接池耗尽Connection 忘了关现象系统跑一段时间后报wait millis 3000, active 20所有请求卡死。原因某个 DAO 方法里 Connection 没有在 finally 里关闭或者 ResultSet、PreparedStatement 没关。连接池的 maxActive 是 20泄漏 20 次之后就再也拿不到连接了。解决所有数据库操作必须用 try-with-resources或者严格在 finally 里 close。我一般会在 DBUtil 里加一个close(Connection, Statement, ResultSet)的重载方法按 ResultSet → Statement → Connection 的顺序关闭。另外 Druid 可以开 removeAbandoned超过一定时间没归还的连接强制回收但这是兜底不能替代正确的关闭。4.3 库存超卖并发下的经典翻车现象两个收银员同时给同一盒药开单库存只有 1结果两单都成功了库存变成 -1。原因先 SELECT 查库存再 UPDATE 扣减两步之间没有锁。并发时两个请求都查到库存为 1都认为可以卖。解决用UPDATE medicine SET stock stock - ? WHERE id ? AND stock ?这一条原子 SQL 完成判断和扣减根据 affected rows 判断是否成功。如果业务复杂需要先查再扣就要在 SELECT 时加FOR UPDATE行锁但要注意死锁风险多个药品扣减时按 id 排序依次加锁。4.4 JSP 编译报错jar 包冲突和版本不匹配现象JSP 页面报java.lang.NoClassDefFoundError: javax/servlet/jsp/jstl/core/Config。原因JSTL 的 jar 包没放对位置或者放了多个版本的 servlet-api.jar。Tomcat 的 lib 目录里已经有 servlet-api 和 jsp-api项目 WEB-INF/lib 里如果再放一份类加载器会冲突。解决Maven 里 servlet-api 和 jsp-api 的 scope 必须设为 provided打包时不打进去。JSTL 需要两个 jarjstl.jar 和 standard.jarJSTL 1.2 合并成了一个 jstl-1.2.jar。确认 WEB-INF/lib 下没有 servlet-api.jar。4.5 日期格式转换String 和 java.sql.Date 的互转现象从 JSP 表单提交的日期字符串插入数据库时报Cannot parse date或插入后日期变成 0000-00-00。原因表单提交的日期是 String 类型PreparedStatement 的 setDate 需要 java.sql.Date直接传 String 会报错。另外 MySQL 的 date 类型不接受空字符串。解决用 SimpleDateFormat 解析注意格式必须和表单一致SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd); java.util.Date utilDate sdf.parse(req.getParameter(expireDate)); java.sql.Date sqlDate new java.sql.Date(utilDate.getTime()); ps.setDate(10, sqlDate);如果表单可能不填日期要先判断字符串是否为空为空时用ps.setNull(10, Types.DATE)不要直接 setDate(null)会抛空指针。5. 从能跑到好用药店管理系统的进阶改造与验证方法系统能跑起来只是第一步真正交付给门店用还得解决几个体验问题。第一个是过期药品预警。我一般会在 MedicineDao 里加一个方法查询expire_date在 90 天内且库存大于 0 的药品在首页用红色高亮显示。SQL 很简单WHERE expire_date DATE_ADD(CURDATE(), INTERVAL 90 DAY) AND stock 0。这个功能看起来小但药店老板最认这个因为过期药直接是钱。第二个是库存下限提醒。在药品列表页对stock stock_min的行加一个「需补货」标签。实现方式可以在 SQL 里用 CASE WHEN 算一个字段也可以在 JSP 里用 c:if 判断。我倾向在 SQL 里算因为后面如果要导出补货清单直接复用这条查询就行。第三个是销售统计。药店老板通常想看「今天卖了多少钱」「这个月哪个药卖得最好」。这需要一条聚合查询-- 今日销售额 SELECT IFNULL(SUM(total_amount), 0) AS today_amount FROM sale_order WHERE DATE(create_time) CURDATE(); -- 本月销量前10的药品 SELECT m.generic_name, SUM(d.quantity) AS total_qty FROM sale_detail d JOIN medicine m ON d.medicine_id m.id JOIN sale_order o ON d.order_id o.id WHERE DATE_FORMAT(o.create_time, %Y-%m) DATE_FORMAT(CURDATE(), %Y-%m) GROUP BY m.id ORDER BY total_qty DESC LIMIT 10;验证系统是否真的可用我一般会做三个测试。第一并发测试用 JMeter 开 50 个线程同时调销售开单接口看库存会不会扣成负数看连接池会不会耗尽。第二边界测试库存为 0 时开单、数量填负数、日期填 2099 年看系统是报错还是静默失败。第三数据一致性测试开 100 单之后手动核对SUM(sale_detail.quantity)和medicine表初始库存减当前库存是否相等。这三个测试过了基本可以交给门店试用。最后一个技巧是关于部署的。传统 JSP 项目打包成 war 丢进 Tomcat 的 webapps 目录是最稳的方式不要用 IDEA 的 artifacts 直接部署那个路径和依赖经常出玄学问题。打包命令用 Maven 的mvn clean package生成的 war 包复制到 Tomcat启动后访问http://localhost:8080/pharmacy-system/即可。数据库记得先导入 SQL 文件改 db.properties 里的密码。如果部署到 Linux 服务器注意 MySQL 的默认字符集和时区配置这两个是线上环境最常见的翻车点。我自己做这类项目最大的教训是不要一上来就想着用框架。Servlet JSP 这套东西虽然老但它把 HTTP 请求、会话管理、数据库连接这些底层机制暴露得很清楚。你把药店管理系统用原生 Servlet 写一遍再去学 Spring MVC会发现那些注解和配置背后做的事情你全都理解。反过来直接上 Spring Boot 的人遇到连接池泄漏或者事务不生效往往连从哪查都不知道。希望帮到你。本文还有配套的精品资源点击获取