
简介这是一套面向JavaWeb初学者与课程设计需求的药店管理系统完整源码包采用servlet与jsp作为核心实现技术搭配MySQL完成数据存储可直接部署于Tomcat环境运行。系统覆盖客户信息管理、药品类别管理、药品信息管理、药品出入库记录管理及用户信息管理五大模块支持药品查询、新增、修改、删除、出库入库以及密码修改与安全退出等操作适合作为毕业设计、课程实训或JavaWeb入门练手项目。资源包共112个文件约2.19MB包含32个java源文件与32个class编译文件、7个jsp页面、8个css与6个js前端资源、6个jar依赖包以及1个sql数据库脚本另附Eclipse项目配置与WebContent目录结构便于快速导入与二次开发。目前已有574人学习下载可帮助读者理解Servlet请求处理、DAO数据访问与JSP页面渲染的完整分层思路并掌握药品出入库记录等业务逻辑的实现方式。1. 从零手搓一套药店管理系统为什么 servletjspmysql 这套老组合还值得做药店管理系统这个题目在毕设和课程设计里出现的频率高得离谱但真正能跑起来、能讲清楚每一层在干什么的十个里不到三个。很多人一上来就想着上 SpringBoot结果连 request 怎么进到 service、jsp 怎么把结果渲染回浏览器都说不明白。我见过太多人拿着一个跑不起来的 war 包来问「为什么 404」最后发现是 web.xml 里 servlet 映射写错了。这套基于 javaWeb servlet jsp mysql 的药店管理系统核心价值不在于技术多新而在于它把「请求怎么进来、业务怎么处理、数据怎么落库、页面怎么渲染」这条链路完整暴露在你面前。药品管理、库存预警、销售出库、会员积分这些业务用最原始的 Servlet 做控制器、JSP 做视图、JDBC 直连 MySQL没有任何框架帮你兜底出了问题你必须自己查。适合谁适合刚学完 javaWeb 头歌实训、想找一个完整案例把知识点串起来的人适合要做毕设但不想被 SpringBoot 自动配置绕晕的人也适合工作几年后想回头补一补「框架到底帮你做了什么」的工程师。下面我按实际搭建顺序把环境、数据库、后端、前端、部署和踩坑一条条讲清楚。2. 环境与数据库把 MySQL 8 和 Tomcat 先跑通2.1 开发环境选型与版本对齐这套系统对版本不敏感但版本错配是新手翻车的第一大来源。我一般固定用 JDK 8 Tomcat 8.5 MySQL 8.0 这个组合原因是 Tomcat 8.5 对 Servlet 3.1 支持完整MySQL 8 的驱动类名和 5.x 不同必须用com.mysql.cj.jdbc.Driver。IDE 用 IDEA 或 Eclipse 都行IDEA 新建项目时选 Java Enterprise勾上 Web Application它会自动生成 web 目录和 web.xml。组件推荐版本关键说明JDK1.8高于 8 编译 jsp 可能报错Tomcat8.5.xServlet 3.1支持注解配置MySQL8.0.x驱动类带 cj时区要设驱动 jarmysql-connector-java-8.0.x放 WEB-INF/lib 下IDEIDEA 2021社区版需手动配 Tomcat注意驱动 jar 必须放在WEB-INF/lib目录下不是项目根目录也不是 Tomcat 的 lib。放错了会报ClassNotFoundException这个错我见过不下二十次。2.2 建库建表药品、库存、销售、用户四张核心表数据库设计决定了后面代码好不好写。药店管理最核心的是药品表和库存表销售表记录出库流水用户表管登录角色。下面这段 SQL 可以直接在 Navicat 或 MySQL Workbench 里执行。CREATE DATABASE pharmacy DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE pharmacy; -- 用户表区分管理员和普通店员 CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role TINYINT DEFAULT 0 COMMENT 0店员 1管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 药品表基础信息 CREATE TABLE medicine ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, category VARCHAR(50), price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock INT NOT NULL DEFAULT 0, warn_stock INT DEFAULT 10 COMMENT 库存预警线, produce_date DATE, expire_date DATE, status TINYINT DEFAULT 1 COMMENT 1在售 0下架 ); -- 销售记录表 CREATE TABLE sale_record ( id INT PRIMARY KEY AUTO_INCREMENT, medicine_id INT NOT NULL, quantity INT NOT NULL, total_price DECIMAL(10,2), sale_time DATETIME DEFAULT CURRENT_TIMESTAMP, operator VARCHAR(50) );建表时有两个参数必须注意utf8mb4而不是utf8否则药品名里的生僻字会乱码DECIMAL(10,2)而不是FLOAT金额用浮点会出现 0.10.2 不等于 0.3 的经典问题。库存预警线warn_stock设默认值 10后面做低库存提醒直接拿stock warn_stock判断。2.3 JDBC 连接工具类一次写好处处复用每个 Servlet 都写一遍DriverManager.getConnection是灾难。抽一个工具类把驱动加载、连接获取、资源关闭统一处理。public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/pharmacy?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PWD 你的密码; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL驱动加载失败, e); } } public static Connection getConn() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } public static void close(Connection c, PreparedStatement p, ResultSet r) { try { if (r ! null) r.close(); } catch (SQLException ignored) {} try { if (p ! null) p.close(); } catch (SQLException ignored) {} try { if (c ! null) c.close(); } catch (SQLException ignored) {} } }URL 里三个参数是血泪经验useSSLfalse关掉 SSL 警告serverTimezoneAsia/Shanghai解决时区报错characterEncodingutf8防止中文乱码。少任何一个要么连不上要么存进去的中文变成问号。连接池这块课程设计用DriverManager够了真上生产再换 Druid 或 HikariCP但那是另一个话题。3. Servlet 控制器请求怎么进来、业务怎么处理3.1 Servlet 生命周期与注解配置Servlet 的生命周期就三个阶段init初始化一次、service每次请求调用、destroy销毁一次。理解这个你就明白为什么不能在service里存用户状态——多个请求共享同一个实例会串数据。Servlet 3.0 之后可以用WebServlet注解代替 web.xml 配置少写一堆 XML。WebServlet(/medicine) public class MedicineServlet extends HttpServlet { private MedicineService service new MedicineServiceImpl(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action req.getParameter(action); if (list.equals(action)) { ListMedicine list service.findAll(); req.setAttribute(list, list); req.getRequestDispatcher(/medicine_list.jsp).forward(req, resp); } else if (delete.equals(action)) { int id Integer.parseInt(req.getParameter(id)); service.delete(id); resp.sendRedirect(medicine?actionlist); } } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String action req.getParameter(action); if (add.equals(action)) { Medicine m new Medicine(); m.setName(req.getParameter(name)); m.setPrice(new BigDecimal(req.getParameter(price))); m.setStock(Integer.parseInt(req.getParameter(stock))); service.add(m); resp.sendRedirect(medicine?actionlist); } } }这段代码里有个关键设计用一个action参数区分不同操作而不是给每个功能写一个 Servlet。药店系统里药品的增删改查、销售的出库、用户的登录如果每个都写一个 Servlet类会爆炸。用 action 分发是传统 javaWeb 项目最常见的做法。doPost第一行req.setCharacterEncoding(UTF-8)必须放在取参数之前否则 POST 提交的中文全是乱码。GET 请求的中文乱码要在 Tomcat 的server.xml里给 Connector 加URIEncodingUTF-8这是两个不同的坑。3.2 Service 层与 DAO 层把业务和 SQL 分开Servlet 里直接写 SQL 能跑但维护起来是噩梦。标准做法是分三层Servlet 管请求响应Service 管业务逻辑DAO 管数据库操作。药品出库这个业务Service 层要同时做「扣库存」和「写销售记录」还要判断库存够不够。public class SaleServiceImpl implements SaleService { private MedicineDao medicineDao new MedicineDaoImpl(); private SaleDao saleDao new SaleDaoImpl(); Override public synchronized String sell(int medicineId, int quantity, String operator) { Medicine m medicineDao.findById(medicineId); if (m null) return 药品不存在; if (m.getStock() quantity) return 库存不足当前仅剩 m.getStock(); // 扣库存 medicineDao.updateStock(medicineId, m.getStock() - quantity); // 写销售流水 SaleRecord sr new SaleRecord(); sr.setMedicineId(medicineId); sr.setQuantity(quantity); sr.setTotalPrice(m.getPrice().multiply(new BigDecimal(quantity))); sr.setOperator(operator); saleDao.insert(sr); return success; } }synchronized这个关键字在单机部署下能防止并发超卖两个店员同时卖最后一盒药不加锁会都判断库存够然后都扣库存变负数。当然分布式环境下这个方法失效得用数据库行锁SELECT ... FOR UPDATE但课程设计阶段单机 synchronized 够用面试时能说出这个区别就是加分项。DAO 层用PreparedStatement而不是Statement这是防 SQL 注入的底线。拼接字符串select * from medicine where name name 这种写法遇到 or 11直接全表泄露别问我是怎么知道的。3.3 库存预警与销售统计的查询实现药店管理有个刚需哪些药快没了要提醒补货。这个查询在 DAO 层一条 SQL 搞定Service 层直接调。public ListMedicine findWarnList() { ListMedicine list new ArrayList(); String sql SELECT * FROM medicine WHERE stock warn_stock AND status 1 ORDER BY stock ASC; try (Connection c DBUtil.getConn(); PreparedStatement p c.prepareStatement(sql); ResultSet r p.executeQuery()) { while (r.next()) { Medicine m new Medicine(); m.setId(r.getInt(id)); m.setName(r.getString(name)); m.setStock(r.getInt(stock)); m.setWarnStock(r.getInt(warn_stock)); list.add(m); } } catch (SQLException e) { e.printStackTrace(); } return list; }ORDER BY stock ASC让最缺的药排最前面店员一眼看到。销售统计按日期分组求和SQL 是SELECT DATE(sale_time) d, SUM(total_price) FROM sale_record GROUP BY d这个查询在报表页面上直接展示每日营业额。注意DATE()函数在 MySQL 8 里正常用但如果你按周统计要用YEARWEEK()按月用DATE_FORMAT(sale_time,%Y-%m)别写错。4. JSP 视图层把数据渲染成能点的页面4.1 JSP 九大内置对象与 EL 表达式JSP 本质是一个 Servlet容器会把它翻译成 java 文件再编译。九个内置对象里最常用的是request、session、application、out。但现代写法应该尽量用 EL 表达式${}和 JSTL 标签而不是在页面里写% java代码 %。脚本片段写多了页面逻辑和业务逻辑搅在一起改一个字段要翻三个文件。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle药品列表/title/head body h2药品库存/h2 table border1 cellpadding8 trth编号/thth名称/thth价格/thth库存/thth操作/th/tr c:forEach items${list} varm tr td${m.id}/td td${m.name}/td td${m.price}/td td c:if test${m.stock m.warnStock} span stylecolor:red${m.stock} 库存不足/span /c:if c:if test${m.stock m.warnStock}${m.stock}/c:if /td td a hrefmedicine?actiondeleteid${m.id}删除/a a hrefsale?actiontoSellid${m.id}销售/a /td /tr /c:forEach /table /body /htmlc:forEach遍历 Servlet 放进 request 的 listc:if判断库存是否低于预警线低于就标红。这种写法比% for(...) { %干净太多。JSTL 需要引入jstl.jar和standard.jar两个包放WEB-INF/lib下忘了引会报The absolute uri cannot be resolved。4.2 登录拦截与 session 管理药店系统不能让没登录的人直接访问药品列表。最原始也最有效的做法是写一个 Filter拦截所有请求检查 session 里有没有用户。WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); // 放行登录页、登录接口、静态资源 if (uri.endsWith(login.jsp) || uri.endsWith(/login) || uri.contains(/css/) || uri.contains(/js/)) { chain.doFilter(req, resp); return; } Object user request.getSession().getAttribute(user); if (user null) { response.sendRedirect(request.getContextPath() /login.jsp); } else { chain.doFilter(req, resp); } } }WebFilter(/*)拦截所有路径放行名单里必须包含登录页和静态资源否则 CSS 加载不出来页面会裸奔。request.getContextPath()拿到项目名重定向时加上它不然部署到 Tomcat 后路径会错。session 默认 30 分钟超时可以在web.xml里用session-config调整药店收银场景可以设长一点避免店员操作到一半被踢出去。4.3 表单提交与中文乱码的根治JSP 表单提交到 Servlet中文乱码有三个位置要处理页面编码、请求编码、数据库编码。页面顶部% page contentTypetext/html;charsetUTF-8 %管响应输出Servlet 里req.setCharacterEncoding(UTF-8)管请求参数数据库连接 URL 里characterEncodingutf8管存储。三处缺一不可。form actionmedicine methodpost input typehidden nameaction valueadd 药品名称input typetext namename requiredbr 价格input typenumber step0.01 nameprice requiredbr 库存input typenumber namestock requiredbr 预警线input typenumber namewarnStock value10br button typesubmit添加/button /form表单用methodpost提交增删改操作查询用 get。这不是随便定的get 有长度限制且参数暴露在 URL 里密码、金额这类数据必须走 post。required属性做前端校验但后端 Servlet 里还要再校验一遍前端校验只是提升体验绕过它太容易了。5. 避坑与排查那些让我加班到凌晨的报错5.1 404 找不到 Servlet 映射现象浏览器访问medicine?actionlist报 404Tomcat 日志没有异常。原因通常是WebServlet的路径和访问路径对不上或者web.xml里metadata-completetrue导致注解被忽略。解决检查注解值是否以/开头检查 web.xml 的 version 是否 3.0 以上检查项目是否真的部署到了 Tomcat 的 webapps 下。IDEA 里还要看 Artifacts 配置有时候改了代码没重新 build部署的还是旧 war。5.2 数据库连接报错 Error 2002 和时区异常现象一Cant connect to local MySQL server through socket。原因MySQL 服务没启动或者连接 URL 用了 localhost 但 MySQL 只监听 socket。解决systemctl start mysql启动服务URL 里把 localhost 换成 127.0.0.1 强制走 TCP。现象二The server time zone value is unrecognized。原因MySQL 8 默认时区和 JDBC 解析不一致。解决URL 加serverTimezoneAsia/Shanghai或者数据库执行SET GLOBAL time_zone8:00。5.3 JSP 中文乱码的三种表现现象页面显示问号、表单提交后数据库存问号、数据库读出来页面显示问号。原因分别是页面编码、请求编码、数据库编码没设对。解决页面contentType设 UTF-8Servlet 里setCharacterEncoding放在取参数前数据库和表用utf8mb4连接 URL 加characterEncodingutf8。三个位置逐一排查别只改一个就以为好了。5.4 库存扣成负数现象并发测试时库存出现负数。原因Service 层先查库存再扣减两个线程同时查到库存为 1都判断够都扣。解决方法加synchronized或者 SQL 改成UPDATE medicine SET stock stock - ? WHERE id ? AND stock ?用数据库的原子性保证。后者更可靠因为 synchronized 只在单 JVM 有效。5.5 打包 war 部署到 Tomcat 后静态资源 404现象IDEA 里跑正常打成 war 丢到 Tomcat webapps 下CSS 和 JS 全 404。原因页面里写的是相对路径css/style.css但访问的 URL 带了 action 参数浏览器解析相对路径时基准变了。解决静态资源路径用${pageContext.request.contextPath}/css/style.css或者用c:url标签生成绝对路径。这个坑在传统 jsp 项目打包 war 时几乎人人踩一遍。6. 进阶技巧把项目从「能跑」推到「能讲」6.1 用数据库连接池替换 DriverManagerDriverManager每次请求都新建连接几十个并发就顶不住。换成 Druid 连接池性能提升立竿见影面试也能讲。引入druid-1.2.x.jar加一个druid.propertiesdriverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://127.0.0.1:3306/pharmacy?useSSLfalseserverTimezoneAsia/Shanghai usernameroot password你的密码 initialSize5 maxActive20 maxWait3000工具类改成从DruidDataSourceFactory拿连接getConn()方法体换成dataSource.getConnection()。maxActive20是最大连接数maxWait3000是拿不到连接等 3 秒就抛异常。这两个参数要根据实际并发调设太小会排队设太大数据库扛不住。6.2 用存储过程做月度销售报表统计类查询用存储过程封装Java 端调用更简洁。下面这个存储过程输入年月返回该月每种药的销售总量和金额DELIMITER // CREATE PROCEDURE month_report(IN ym VARCHAR(7)) BEGIN SELECT m.name, SUM(s.quantity) AS total_qty, SUM(s.total_price) AS total_amt FROM sale_record s JOIN medicine m ON s.medicine_id m.id WHERE DATE_FORMAT(s.sale_time, %Y-%m) ym GROUP BY m.id, m.name ORDER BY total_amt DESC; END // DELIMITER ;Java 端用CallableStatement调用CallableStatement cs conn.prepareCall({call month_report(?)}); cs.setString(1, 2024-06);。存储过程的争议一直有业务逻辑放数据库里不好维护但报表这种纯统计场景用存储过程确实比在 Java 里拼 SQL 清爽。6.3 验证清单交付前必须过的五关项目做完别急着交按这个清单走一遍第一关全新环境导入 SQL 能否一次成功注意字符集和时区第二关登录、增删改查、销售出库、库存预警五条主流程能否走通第三关并发卖同一盒药库存会不会负第四关中文药品名从录入到展示有没有乱码第五关打成 war 丢到干净 Tomcat 里能不能跑。这五关过了答辩时被问「你这个怎么保证库存准确」你能答上来被问「乱码怎么解决」你也能答上来。我自己做这套系统时最深的教训是别小看环境配置。代码逻辑再对JDK 版本、Tomcat 版本、MySQL 驱动版本有一个不匹配就是几个小时的排查。后来我养成了一个习惯每接一个新项目先把环境版本写进 README 第一行谁问先看这行。这套 servletjspmysql 的老组合技术不新但把这条链路吃透再学 SpringBoot 你会知道它到底帮你省了什么。希望帮到你。本文还有配套的精品资源点击获取