基于Servlet+JSP+MySQL的药店管理系统开发实战
2026/9/12 22:50:05 网站建设 项目流程

简介:基于JavaWeb的药店管理系统完整源码包,采用Servlet+JSP+MySQL+Tomcat技术栈,面向Java初学者、毕业设计学生及需要快速搭建信息管理系统的开发者。系统覆盖客户管理、药品类别、药品信息、出入库记录、用户权限及密码修改等核心模块,可实现药品库存的增删改查与出入库流程管控。压缩包共112个文件,包含32个Java源码、32个class文件、7个JSP页面、8个CSS样式、6个JS脚本及6个Jar依赖库,另有数据库脚本与Eclipse项目配置,整体大小2.19MB,结构清晰,便于直接导入开发工具运行。已有573人学习下载。资源附带完整数据库初始化脚本和项目配置文件,可作为课程设计或毕业设计的基础框架。通过阅读源码可理解Servlet控制层、JSP视图层与DAO数据访问层的分层写法,同时掌握药品出入库记录、客户信息管理等典型业务逻辑的实现思路,适合用来巩固JavaWeb开发技能并扩展完善。

1. 药店管理系统为什么拿 Servlet+JSP 而不是 Spring Boot

近两年的 JavaWeb 项目,开口闭口都是 Spring Boot,反而让 servlet、jsp 这两个名词退化成面试题里的概念。但把药店管理系统这种以增删改查为主的工程拿出来,Servlet+JSP 才是看清楚 Web 运行原理的最佳载体:一次请求怎么从 Tomcat 的 Connector 进入容器,怎么被 DispatcherServlet 或自定义的 HttpServlet 分发,再怎样把结果回填到 JSP 的 out 对象里。基于 javaWeb+servlet+jsp+mysql 搭建药店药品管理系统,核心不在于用什么酷炫框架,而在于药品信息、库存预警、订单记录这些业务表能稳定存取。适合的人群也比较明确:正在做课程设计、毕设,或者想给初学者讲清楚 Web 三层架构的开发者。数据模型不复杂、页面交互以列表和表单为主,恰好把 JavaWeb 最常被问到的技术点全部串起来。

2. 数据库先行:药品表与库存预警字段这样建才对

写 JavaWeb 项目最常见的流程,是先建工程后建表,最后发现业务层里到处拼接 SQL。药店管理系统的数据模型相对固定,把 MySQL 表结构先定下来,后面 Servlet 里的每个方法就都围绕表字段展开,不容易跑偏,也让后续的 JDBC 代码只需要关心 get 和 set。

2.1 MySQL 8.0 初始化脚本:用户表、药品表、销售表一次建齐

药店系统至少需要三张核心表:用户表、药品表、以及销售记录表。下面这份初始化脚本按 MySQL 8.0 默认的 utf8mb4 字符集建库建表,其中 medicine 表里几个字段值得先说明:

-- 建库时指定字符集,避免后期中文乱码再回头改 CREATE DATABASE IF NOT EXISTS pharmacy_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE pharmacy_db; -- 用户表:区分管理员和店员两种角色 CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT '密码,建议MD5加盐', real_name VARCHAR(30) DEFAULT '' COMMENT '姓名', role TINYINT NOT NULL DEFAULT 0 COMMENT '0管理员 1店员', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB COMMENT='系统用户表'; -- 药品表:库存与预警阈值放在同一张表,查询时不需要 JOIN CREATE TABLE medicine ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '药品ID', drug_code VARCHAR(30) NOT NULL UNIQUE COMMENT '药品编码', drug_name VARCHAR(100) NOT NULL COMMENT '药品名称', category VARCHAR(50) DEFAULT '' COMMENT '分类', spec VARCHAR(100) DEFAULT '' COMMENT '规格', unit VARCHAR(20) DEFAULT '盒' COMMENT '计量单位', purchase_price DECIMAL(10,2) DEFAULT 0.00 COMMENT '进货价', sale_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '零售价', stock INT NOT NULL DEFAULT 0 COMMENT '当前库存', warn_stock INT NOT NULL DEFAULT 10 COMMENT '库存预警阈值', manufacturer VARCHAR(100) DEFAULT '' COMMENT '生产厂家', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '录入时间', INDEX idx_category (category), INDEX idx_drug_name (drug_name) ) ENGINE=InnoDB COMMENT='药品信息表'; -- 销售记录表:记录每次出库的数量和金额 CREATE TABLE sale_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '记录ID', medicine_id BIGINT NOT NULL COMMENT '药品ID', sale_count INT NOT NULL COMMENT '销售数量', sale_price DECIMAL(10,2) NOT NULL COMMENT '成交单价', total_price DECIMAL(10,2) NOT NULL COMMENT '总价', operator VARCHAR(50) DEFAULT '' COMMENT '操作人', sale_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '销售时间', INDEX idx_medicine_id (medicine_id), INDEX idx_sale_time (sale_time) ) ENGINE=InnoDB COMMENT='销售记录表';

这里三个设计决策要解释一下,照抄字段容易,理解取舍才不踩坑。第一,stock 与 warn_stock 冗余在 medicine 表里,没有单独拆库存表。药店管理系统每次列表展示都要判断“库存充足 / 预警 / 缺货”,同表存放少一次 JOIN,SQL 简单,后续在 JSP 页面里直接比较两个字段即可。第二,drug_code 加 UNIQUE 约束,是为了防止同一药品被重复录入,这是数据库层的兜底,比 Java 代码里先 SELECT 再 INSERT 更可靠。第三,sale_record 不建外键,只加普通索引。管理系统的删除操作经常要跨表人工清理,外键的级联行为在真删和假删之间会制造更多麻烦。

MySQL 8.0 建表时还有一个经常被忽略的点:存储引擎默认就是 InnoDB,事务能力是有的,但如果后续要在 Servlet 里做“扣库存 + 写销售记录”的原子操作,记得在 JDBC 里手动关闭 autocommit。字符集方面,utf8mb4 与 utf8mb4_general_ci 的组合已经能覆盖中文和生僻字,不需要为了排序精确性强行换成 utf8mb4_unicode_ci,那点差异在药店系统里完全感知不到。

2.2 药品编码怎么生成才能避开唯一索引冲突

药品编码如果让操作员手输,要么出现空值,要么出现重号。实际项目里常见做法是在 Java 端自动生成,格式一般为字母前缀加时间戳加随机数:

public static String generateDrugCode() { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); String timePart = sdf.format(new Date()); int randomPart = (int) (Math.random() * 1000); return "M" + timePart + String.format("%03d", randomPart); }

这段代码用秒级时间戳保证大部分场景不重复,三位随机数处理同一秒内多次生成的冲突。但 Math.random() 在并发量上来后不是线程安全的,更稳的写法是换成 AtomicInteger 静态自增变量,或者直接用 UUID 的前 8 位。注意,只要 drug_code 命中了 UNIQUE 索引,重复时 MySQL 会抛 Duplicate entry 异常,Servlet 里要捕获这个异常并提示“编码重复”,而不是直接让 500 页面怼到用户脸上。

2.3 命令行 source 与 Navicat 查询编辑器怎么选

拿到数据库脚本后的导入方式,直接影响第一次跑通的效率。我一般优先用命令行:

mysql -u root -p < pharmacy_init.sql

这个命令的要点是脚本里已经写了 CREATE DATABASE 和 USE,所以不需要提前建库。如果换成 Navicat,不要用“导入向导”去导入 SQL 文件,直接打开查询编辑器把脚本全部粘贴进去执行就行,导入向导偶尔会因为 SQL 中没有 USE 语句而把所有表建到默认连接的库下面。执行过程中如果报错,优先检查两处:连接的用户是否有建表权限,以及脚本中是否混入了像DROP DATABASE这种破坏性语句。

3. 搭建 javaWeb 骨架:Maven 依赖、Druid 连接池与 Servlet 注解

数据库表结构准备好之后,JavaWeb 一侧的工程骨架就是下一步。Servlet、JSP、MySQL 这套组合,在 IDEA 里创建一个 Maven war 工程最稳定,部署时打成 WAR 放进 Tomcat 的 webapps 目录即可。下面按依赖、连接池、接口三步走。

3.1 Maven 坐标与 Tomcat 版本的匹配规则

pom.xml 里最容易被忽略的是 javax.servlet-api 的 scope 必须设为 provided。Tomcat 内嵌了 Servlet 容器实现,再把 servlet-api.jar 打进去,启动时轻则 ClassCastException,重则直接报告类冲突。一组经过验证的依赖版本如下:

<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>

这些版本之间的匹配关系是:Tomcat 9 对应 Servlet 4.0,Tomcat 8.5 对应 Servlet 3.1。如果你用的是 Tomcat 8.5 却引入 servlet-api 4.0.1,大多数简单接口能跑,但涉及请求映射时可能出现方法签名对不上的诡异错误。mysql-connector-java 8.0.x 要求 JDK 8 以上,jstl 1.2 是最后可以用 javax.servlet 命名空间的版本,再往后的 Jakarta 命名空间只有 Tomcat 10+ 才支持,常见的老项目全都在 9 以下,不要混用。

3.2 Druid 连接池初始化以及 JDBC 工具类封装

每次请求都去DriverManager.getConnection()对 MySQL 来说是一种浪费,一个药店系统虽然并发不会太高,但页面刷新和后台查询叠加后,连接反复创建销毁仍然会出现明显的响应延迟。Druid 连接池在 JavaWeb 项目里用得比较普遍,因为它自带监控页面,也支持配置初始连接数和最大活跃数。工具类可以这样封装:

public class DbUtil { private static DruidDataSource dataSource; static { try { dataSource = new DruidDataSource(); dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver"); dataSource.setUrl("jdbc:mysql://localhost:3306/pharmacy_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"); dataSource.setUsername("root"); dataSource.setPassword("123456"); dataSource.setInitialSize(5); dataSource.setMaxActive(20); dataSource.setMinIdle(3); } catch (Exception e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(ResultSet rs, PreparedStatement ps, Connection conn) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} } if (ps != null) { try { ps.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }

这段代码有两个参数需要按机器实际情况调整。initialSize 是启动时预创建的连接数,设成 5 能保证第一个请求进来时不用等待连接创建;maxActive 是峰值连接上限,药店系统一般 20 足够,如果跑在低配云服务器上可以降到 10。URL 里的 serverTimezone 参数不能省,MySQL 8.0 驱动对时区敏感,不加会在连接时报 SQLException 提示时区无法识别。用 Druid 以来的旧例子中还有 setFilters("stat") 的写法,那是开启监控用的,平时跑项目不开问题也不大。

3.3 第一个 Servlet:把 MySQL 里的药品列表变成 JSON

工程骨架里要验证整个链路是否通,最好先写一个最简单的 Servlet 查询药品表并返回 JSON。Servlet 3.0 之后支持注解注册,不需要在 web.xml 里配<servlet><servlet-mapping>。下面这个接口做了三件事:查全部药品、拼 JSON、处理空结果:

@WebServlet("/medicine/list") public class MedicineListServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("application/json;charset=utf-8"); PrintWriter out = resp.getWriter(); List<Map<String, Object>> list = new ArrayList<>(); String sql = "SELECT id, drug_code, drug_name, stock, warn_stock FROM medicine ORDER BY id DESC"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Map<String, Object> map = new HashMap<>(); map.put("id", rs.getLong("id")); map.put("drugCode", rs.getString("drug_code")); map.put("drugName", rs.getString("drug_name")); map.put("stock", rs.getInt("stock")); map.put("warnStock", rs.getInt("warn_stock")); list.add(map); } // 手动拼 JSON,少一层依赖,能跑通后再考虑 Fastjson StringBuilder sb = new StringBuilder("["); for (int i = 0; i < list.size(); i++) { Map<String, Object> m = list.get(i); if (i > 0) sb.append(","); sb.append("{\"id\":").append(m.get("id")) .append(",\"drugCode\":\"").append(m.get("drugCode")) .append("\",\"drugName\":\"").append(m.get("drugName")) .append("\",\"stock\":").append(m.get("stock")) .append(",\"warnStock\":").append(m.get("warnStock")) .append("}"); } sb.append("]"); out.write(sb.toString()); } catch (SQLException e) { resp.setStatus(500); out.write("{\"error\":\"database error\"}"); e.printStackTrace(); } } }

这个 Servlet 的要点在 try-with-resources 写法,Connection、PreparedStatement、ResultSet 三个资源在 try 结束时自动关闭,不用再调用 DbUtil.close。输出 JSON 时手动拼接是为了演示底层逻辑,后面真正做页面时更常见的是转发到 JSP 渲染,不需要序列化库。访问路径就是 http://localhost:8080/项目名/medicine/list,如果收到 404,优先检查 @WebServlet 注解路径跟访问 URL 是否一致,以及 Tomcat 是否把项目部署到了正确目录。

4. JSP 页面与 Servlet 交互:登录、增删改查的完整链路

接口与数据库连通后,回到页面侧。JSP 在这个技术栈里的定位不是模板引擎,而是能直接拿到请求域数据的视图层。药店管理系统的常规功能是登录、药品列表、新增药品、编辑药品、删除药品,这四类操作足够覆盖 Servlet 与 JSP 的交互模式。

4.1 用 Filter 统一校验登录状态

登录拦截不写在每个 Servlet 里,而是用一个 Filter 统一处理。创建 LoginFilter 并配置拦截路径为/pages/*,对未登录请求直接重定向到登录页:

@WebFilter("/pages/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); // session 中不存在 loginUser 说明未登录,直接跳回登录页 if (session == null || session.getAttribute("loginUser") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }

session.getSession(false) 这里要用 false,如果传 true 会强制创建新 session,导致每次未登录请求都生成一个无用的会话对象。loginUser 里存的是 User 对象,登录成功后执行 session.setAttribute("loginUser", user),登出时调 session.invalidate()。这套逻辑放进 JavaWeb 里是最朴素的权限控制,比在 JSP 里到处判断 session 数据要干净得多。

4.2 药品列表页的 JSP 渲染与查询条件回显

药品列表是药店系统的门面,一般按药品名称或分类筛选。Servlet 接收查询参数,把结果放进 request 域,转发到 list.jsp:

String keyword = req.getParameter("keyword"); String sql = "SELECT * FROM medicine WHERE drug_name LIKE ? OR drug_code LIKE ? ORDER BY id DESC"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); ps.setString(2, "%" + keyword + "%"); list = ... // 封装成 List<Medicine> } req.setAttribute("medicineList", list); req.setAttribute("keyword", keyword); req.getRequestDispatcher("/pages/medicine/list.jsp").forward(req, resp);

JSP 那一侧用 JSTL 的 c:forEach 遍历,并在表格里高亮库存预警行:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <body> <h3>药品列表</h3> <form action="${pageContext.request.contextPath}/medicine/list" method="get"> <input type="text" name="keyword" value="${keyword}" placeholder="药品名称或编码"> <button type="submit">查询</button> </form> <table border="1" cellpadding="6" style="border-collapse:collapse"> <tr> <th>编码</th><th>名称</th><th>分类</th><th>库存</th><th>预警值</th><th>状态</th><th>操作</th> </tr> <c:forEach items="${medicineList}" var="m"> <tr> <td>${m.drugCode}</td> <td>${m.drugName}</td> <td>${m.category}</td> <td>${m.stock}</td> <td>${m.warnStock}</td> <td> <c:choose> <c:when test="${m.stock == 0}">缺货</c:when> <c:when test="${m.stock < m.warnStock}">库存预警</c:when> <c:otherwise>正常</c:otherwise> </c:choose> </td> <td> <a href="${pageContext.request.contextPath}/medicine/edit?id=${m.id}">编辑</a> <a href="${pageContext.request.contextPath}/medicine/delete?id=${m.id}" onclick="return confirm('确认删除该药品?')">删除</a> </td> </tr> </c:forEach> </table> </body> </html>

这段 JSP 里容易翻车的地方是 value="${keyword}" 回显。如果 keyword 里包含双引号或 HTML 标签,会破坏 input 标签结构甚至造成 XSS,最基础的防御是引入 JSTL 的 c:out 标签输出值,即<c:out value="${keyword}"/>。另一个常见问题是 JSP 文件修改后刷新不生效,Tomcat 对 JSP 的编译缓存通常开启在 work 目录下,改完 JSP 建议重启 Tomcat 或者把 work/Catalina 下的缓存目录手动清掉。

4.3 库存扣减与重复提交:两个容易翻车的细节

药品销售时要扣库存,这个过程如果是直接 UPDATE medicine SET stock = stock - 1,从 SQL 层面看是原子操作,但必须配合事务。Servlet 的做法是在 service 方法里先查询库存数量,判断够不够,再执行扣减和插入销售记录,这中间要用 Connection 控制事务:

conn.setAutoCommit(false); try { // 扣减库存 ps1 = conn.prepareStatement("UPDATE medicine SET stock = stock - ? WHERE id = ? AND stock >= ?"); ps1.setInt(1, count); ps1.setLong(2, medicineId); ps1.setInt(3, count); int rows = ps1.executeUpdate(); if (rows == 0) { throw new RuntimeException("库存不足"); } // 写销售记录 ps2 = conn.prepareStatement("INSERT INTO sale_record (medicine_id, sale_count, total_price) VALUES (?,?,?)"); ... conn.commit(); } catch (Exception e) { conn.rollback(); e.printStackTrace(); } finally { conn.setAutoCommit(true); // 关闭资源 }

这里把 WHERE 条件写成stock >= ?很关键,数据库会在 UPDATE 时自动校验库存够不够,比先 SELECT 再判断要安全一截,避免两个请求同时读到相同库存导致超卖。重复提交则用页面端 disable 按钮来挡,删除药品时尽量用 POST 请求而不是上面列表里的 GET 链接,GET 请求被浏览器预加载或者被爬虫访问都会误删数据。

5. 部署到 Tomcat 后的三件事:统一编码、内存与自动备份

项目在本机能跑,不表示换一台机器也能跑。部署阶段最常见的三个问题按优先级排是编码乱码、Tomcat 内存不足、MySQL 数据库没有备份。把这三件事处理完,这套系统才算真正交付。

5.1 乱码问题的两个排查入口

页面出现中文问号或者 � 这种替换符,先看是不是 JSP 文件头部声明不对。一个完整的 JSP 页面头部应同时包含 page 指令和 meta 标签:

<%@ page contentType="text/html;charset=UTF-8" language="java" pageEncoding="UTF-8" %> <meta charset="UTF-8">

如果页面声明没问题,再看 Tomcat 的 connector 编码。打开 Tomcat 安装目录下 conf/server.xml,在 Connector 配置里加上URIEncoding="UTF-8",这样 GET 请求参数才能正确解码。

5.2 用 mysqldump 做每日自动备份

MySQL 的备份不用装额外插件,直接用 mysqldump 命令配合系统计划任务就能完成。Windows 环境下写一个 backup.bat:

@echo off set timestamp=%date:~0,4%%date:~5,2%%date:~8,2% mysqldump -uroot -p123456 pharmacy_db > D:\db_backup\pharmacy_%timestamp%.sql echo backup done: %date% %time% >> D:\db_backup\backup.log

Linux 服务器上则用 crontab 每天凌晨执行。mysqldump 导出的 SQL 文件在恢复时执行mysql -uroot -p pharmacy_db < backup.sql即可。注意备份文件不要和数据库放在同一块磁盘上,否则磁盘故障时备份和数据一起丢。如果药店的销售记录量比较大,可以给 mysqldump 加上--single-transaction参数,这样备份过程中不影响线上读写,JDBC 连接池里那些长连接也会因为 MySQL 8.0 的 caching_sha2_password 认证插件而需要修改 my.cnf 中的 default_authentication_plugin,这台机器上换用 mysql_native_password 会更省事。

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

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

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

立即咨询