尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

servlet+jsp+mysql实现药店管理系统完整实战指南

servlet+jsp+mysql实现药店管理系统完整实战指南 简介一套基于JavaWeb技术栈的药店管理系统源码包采用Servlet、JSP搭配MySQL数据库实现覆盖客户信息管理、药品类别管理、药品信息管理、出入库记录与用户权限控制等核心业务模块。压缩包共112个文件包含32个Java源文件及对应class文件、7个JSP页面、8个CSS样式、6个JavaScript脚本、6个JAR依赖包并附带初始化数据库所需的SQL脚本整体大小约2.19MB目录结构清晰适合导入Eclipse直接部署。已有573人学习下载可满足JavaWeb课程设计、毕业设计或入门实战需求。通过该系统可完整掌握客户与药品信息的增删改查、药品出库入库、登录退出及用户管理等典型功能适合需要快速上手ServletJSP项目开发的读者也能为二次扩展和功能优化提供清晰的基础代码。1. 为什么servletjspmysql仍是药店管理系统快速交付的首选在Spring Boot几乎统治后端生态的今天把药店管理系统做成javaWebservletjspmysql的组合常被划进课堂作业那类评价。但真实需求里有一种场景恰恰只有这一套最适合单店内网的进销存后台数据量和并发规模都不大部署环境可能只是一台Windows主机页面要能直接打开、改两行就能生效。servlet把每个请求分发到对应的处理入口jsp承担页面渲染mysql保存药品与库存数据没有中间层和自动配置要维护war包扔进Tomcat就能跑。这套系统的业务面集中在登录、药品维护、库存变更和效期预警MVC三层各管一段后文会从mysql建库脚本开始一路把DAO、servlet控制器、jsp渲染和部署排错完整走一遍。适合要给小药店快速交付管理工具的工程师也适合想在面试里把javaWeb整个请求链路串起来讲的人。2. javaWeb技术栈清晰分工servlet处理请求、jsp渲染视图、mysql落地数据开始编码之前先立住一条线在同一套系统里servlet、jsp、mysql各自的职责边界是什么。servlet不负责拼HTMLjsp不连接数据库mysql不写业务判断。在药店管理系统里你看到的往往是二十来个页面、七八张业务表谁把职责往旁边挪一点后面排错就要多绕一段路。下面按三块分别展开顺带把常见误用一起点掉。2.1 servlet单实例多线程模型为什么controller里不能放成员变量servlet由容器管理实例默认一个servlet类只创建一个对象所有并发请求复用这个实例以多线程方式执行service方法。这个模型的直观后果是servlet里的成员变量对所有请求共享。如果你在一个药品编辑servlet里用成员变量暂存前端传来的drug_id两个管理员同时打开编辑页后提交的那个人就会看到另一个人正在处理的数据。正确约束是保持servlet无状态doGet和doPost方法内部只用局部变量请求数据通过request.getParameter读取跨请求的用户状态唯一放session查询结果用request.setAttribute传给jsp。控制流上养成一个习惯让doPost直接调用doGet或者把公共逻辑抽成一个私有方法后续加查询参数时不会出现get调用链和post调用链不一致的错位。servlet在这个系统里用到的核心API按实际落点梳理成下面的对应关系API在药店管理系统里的落点容易踩的误区request.getParameter读取药品名、批号、库存数字未先设置字符集中文变问号request.getSession登录后保存用户身份登录校验前就创建session产生空会话request.getRequestDispatcher().forward()把数据带进jsp渲染页面与sendRedirect混用丢失request属性response.sendRedirect()操作完成后的页面跳转表单重复提交时用了forwardgetParameter取值之后要做一次空值和长度的校验药品编码为空、库存传了负数这类问题在servlet层挡掉比到DAO层报SQL异常要直观得多。getSession(false)在不传true时如果还没有会话会返回null这是登录拦截最简单的实现方式。forward和sendRedirect的选择规则很固定列表查询之后forward给jsp表单提交成功之后redirect到列表页防止用户按F5重复提交。2.2 jsp的翻译、编译与class文件位置jsp本质是servlet。第一次被请求时容器把jsp源文件翻译成对应servlet的Java源文件再用javac编译成class之后按servlet路径执行。Tomcat在运行时依赖jsp文件的时间戳判断是否需要重新翻译这解释了为什么“jsp改了不生效”是高频问题部署目录里那份jsp不是你编辑的那份或者容器没检测到时间戳变化。翻译生成的java文件和class文件放在Tomcat的work目录下路径类似work/Catalina/localhost/项目名/org/apache/jsp每个jsp对应一个名为index_jsp.java这样的类。排查jsp不生效时直接打开这个java文件看代码生成时间和内容比盲目重启Tomcat有效得多。jsp页面里不建议再使用%%这种scriptlet嵌入业务代码药品列表、库存预警这类页面只需要做循环输出和条件判断EL表达式加JSTL标签完全够用。JSP九大内置对象里实际开发中用得最多的是request、session、application三个作用域。给jsp传数据时遵循就近原则列表数据放request登录用户放session系统级配置放application。别把几十条药品数据同时塞进sessionsession存的是整个浏览器会话的共享数据存大对象会白白占用内存。2.3 mysql JDBC连接串一组能少踩坑的参数servlet不直接碰数据库中间的通道是JDBC。不引入连接池的写法里获取连接的代码长这样String url jdbc:mysql://127.0.0.1:3306/drug_store_db ?useSSLfalsecharacterEncodingutf8 serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue; Connection conn DriverManager.getConnection(url, root, yourpass);逻辑说明useSSLfalse在内网部署时能明显缩短连接耗时characterEncodingutf8保证中文药品名和供应商名称在应用层与mysql之间正确转码serverTimezone指定连接会话的时区mysql 8.x驱动默认要求明确时区否则会直接报时区错误allowPublicKeyRetrievaltrue解决caching_sha2_password认证方式下首次连接拿不到公钥的问题。参数说明serverTimezone的值必须与mysql实例所在操作系统一致而不是与前端显示器上的时间一致。如果服务器跑在UTC时区连接串写Asia/Shanghai会让日期字段整体偏差。这一版先用DriverManager直连是为了在调试阶段能把异常栈看得最直接。系统上线前再按第5章的接法换成连接池DAO层代码不需要改动。3. mysql数据库设计药店管理系统的核心表结构与初始化脚本管理系统的成败一半在数据库设计。药店系统的最核心实体是药品但它还牵扯两件事管理员从哪来、货源从哪来。所以至少需要用户表、药品表、供应商表以及连接药品与供应商的中间表。下面把字段取舍和建表脚本一次讲完。3.1 用户表、药品表、供应商表的关键字段用户表不追求大而全username、password、role三件套足够登录和鉴权用。药品表是这套系统的重心字段要区分“基础信息”和“库存状态”。基础信息包括药品编码、通用名、规格、单位、生产厂家、分类、处方标志库存状态包括库存数量、库存下限、采购价、售价、生产日期、有效期。把两类信息放在同一张表里没问题但要清楚哪些字段是高频更新、哪些是低频更新索引设计和页面表单分组都跟着这个认知走。供应商表和药品是多对多关系一种药品可以有多家供货商一家供货商可以供多种药。用一张drug_supplier_rel中间表承载联合主键是(drug_id, supplier_id)。如果以后要记录每次采购的数量和价格再去中间表上扩展采购单字段现阶段不引入复杂的采购单据模型避免在一套小型系统里过度设计。下面是药品表里最值得提前想清楚的字段字段类型业务含义设计说明drug_codeVARCHAR(20)药品编码唯一索引对应药品的条码或内部编码prescription_flagTINYINT是否处方药0为OTC1为处方药页面按此显示提示stock_quantityINT当前库存出库扣减、入库增加业务层阻止负数stock_lower_limitINT库存预警下限低于此值列表页标红expire_dateDATE有效期至效期预警的查询条件sale_priceDECIMAL(10,2)零售价用DECIMAL而不是double避免浮点误差另外要强调一个设计决策效期字段直接放在药品表而不是单独建批次表。药店场景下同一药品在一个时间段内通常只有一个主流批号批次表会让入库操作多一步选批号实际录入员往往直接沿用旧批号。如果系统面向的是带GSP要求的连锁药店再升级成批次表不迟单体药店的管理系统用单一效期字段运维成本最低。3.2 mysql建表脚本与字符集设置下面的SQL脚本可以在mysql 8.x直接执行建库、建表、建初始数据一次完成CREATE DATABASE IF NOT EXISTS drug_store_db DEFAULT CHARACTER SET utf8mb4; USE drug_store_db; CREATE TABLE sys_user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(30), role CHAR(2) NOT NULL DEFAULT A, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE drug_info ( id INT AUTO_INCREMENT PRIMARY KEY, drug_code VARCHAR(20) NOT NULL UNIQUE, drug_name VARCHAR(100) NOT NULL, spec VARCHAR(100), unit VARCHAR(10), manufacturer VARCHAR(100), category VARCHAR(30), prescription_flag TINYINT DEFAULT 0, stock_quantity INT NOT NULL DEFAULT 0, stock_lower_limit INT DEFAULT 10, purchase_price DECIMAL(10,2), sale_price DECIMAL(10,2), production_date DATE, expire_date DATE, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE supplier ( id INT AUTO_INCREMENT PRIMARY KEY, supplier_name VARCHAR(100) NOT NULL, contact_person VARCHAR(30), phone VARCHAR(20), address VARCHAR(255) ) ENGINEInnoDB; CREATE TABLE drug_supplier_rel ( drug_id INT NOT NULL, supplier_id INT NOT NULL, PRIMARY KEY (drug_id, supplier_id), FOREIGN KEY (drug_id) REFERENCES drug_info(id), FOREIGN KEY (supplier_id) REFERENCES supplier(id) ) ENGINEInnoDB;逻辑说明建库语句里的DEFAULT CHARACTER SET utf8mb4一旦指定后续建表不写字符集时全部继承。sys_user里password字段留64位是为了给BCrypt或SHA-256预留空间如果直接用MD532位也够。drug_info里update_time配合ON UPDATE CURRENT_TIMESTAMP每次更新行时时间自动刷新不用在Java代码里手动set。参数说明临时表、存储过程这类能力在这个系统里没有使用场景不要为了展示技术硬加进去。外键约束是InnoDB的强项但中间表的外键指向两张主表删除药品或供应商时要先处理中间表的关联行否则外键会拦下DELETE语句。实际开发里如果发现删除频繁被外键阻塞说明业务上并不适合物理外键可以改成应用层维护关联关系。3.3 初始化数据与效期预警查询插入一个管理员账号和两条示例药品让系统第一次登录就能看到列表效果INSERT INTO sys_user (username, password, real_name) VALUES (admin, MD5(123456), 管理员); INSERT INTO drug_info (drug_code, drug_name, spec, unit, manufacturer, category, prescription_flag, stock_quantity, stock_lower_limit, purchase_price, sale_price, production_date, expire_date) VALUES (DRG001, 阿莫西林胶囊, 0.25g*24粒, 盒, 某制药厂, 抗生素, 1, 120, 30, 8.50, 12.00, 2024-03-01, 2026-02-28), (DRG002, 布洛芬缓释胶囊, 0.3g*20粒, 盒, 某药业公司, 解热镇痛, 0, 85, 20, 10.00, 15.80, 2024-01-15, 2025-12-31);逻辑说明管理员密码用MD5只是为了演示登录链路。真实系统建议用BCryptJDK里没有自带实现需要引入依赖这也是为什么第2章直连方式更适合教学而生产部署要换连接池一样都属于上线前替换项。示例数据里把处方标志和库存预警都覆盖到列表页才能同时展示两种状态。插入后的第一条业务查询是效期预警。查未来90天内过期的药品并和库存预警合并输出SELECT drug_code, drug_name, stock_quantity, expire_date FROM drug_info WHERE stock_quantity stock_lower_limit OR expire_date DATE_ADD(CURDATE(), INTERVAL 90 DAY);逻辑说明stock_quantity stock_lower_limit是数值比较expire_date DATE_ADD(...)是日期比较两个条件用OR连接查询结果是两条线路的并集正好对应页面上一张“需关注药品”列表。参数说明DATE_ADD(CURDATE(), INTERVAL 90 DAY)里的90天是业务参数不同药店对效期预警的窗口要求不一样有的是60天有的是180天建议把天数提取成JSP页面上的一个下拉框后端用PreparedStatement绑定参数传递。注意expire_date允许为空后效期预警就会漏掉空值记录。严格模式就加上NOT NULL约束保留空值的系统查询时加上expire_date IS NOT NULL避免NULL参与比较导致整行被过滤掉。4. 从DAO到Servlet再到JSP药品列表的完整编码链路这一章用“药品列表页”作为最小闭环串起从mysql查询到页面渲染的全部代码。药品列表是整套系统的首页级功能它跑通之后新增、编辑、删除、库存预警都按同一套模式复制扩展。写的时候把每一层职责看清楚后面改需求就不会伤筋动骨。4.1 DAO层用PreparedStatement把mysql数据映射成对象DAO层只看数据库不看HTTP。查询药品列表并拼出供应商名单的DAO方法如下public ListDrugInfo findDrugList() { String sql SELECT d.id, d.drug_code, d.drug_name, d.spec, d.stock_quantity, d.stock_lower_limit, d.expire_date, d.sale_price, GROUP_CONCAT(s.supplier_name) AS suppliers FROM drug_info d LEFT JOIN drug_supplier_rel r ON d.id r.drug_id LEFT JOIN supplier s ON r.supplier_id s.id GROUP BY d.id ORDER BY d.drug_code; ListDrugInfo list new ArrayList(); try (Connection conn DbUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { DrugInfo item new DrugInfo(); item.setId(rs.getInt(id)); item.setDrugCode(rs.getString(drug_code)); item.setDrugName(rs.getString(drug_name)); item.setSpec(rs.getString(spec)); item.setStockQuantity(rs.getInt(stock_quantity)); item.setStockLowerLimit(rs.getInt(stock_lower_limit)); item.setExpireDate(rs.getDate(expire_date)); item.setSalePrice(rs.getBigDecimal(sale_price)); item.setSuppliers(rs.getString(suppliers)); list.add(item); } } catch (SQLException e) { throw new RuntimeException(查询药品列表失败, e); } return list; }逻辑说明try-with-resources会在代码块结束时按逆序关闭ResultSet、PreparedStatement、Connection省去finally里手写关闭的冗长样板。SQL里用的GROUP_CONCAT是mysql聚合函数它把同一药品关联的多个供应商名称拼成一行避免在Java里自己组装Map。LEFT JOIN保证药品还没关联供应商时药品行依然出现在列表里而不是被JOIN掉。参数说明查询列中不包含purchase_price采购价列表页只需要展示零售价把采购价留在详情或编辑页单独查可以避免价格信息在页面上被随意看到。rs.getBigDecimal(sale_price)对应DECIMAL类型比getDouble精确后续做合计、对账时不会出现浮点数误差。4.2 Servlet层登录校验与请求转发Servlet负责接收请求、调用DAO、决定下一步走向。药品列表的Servlet用Servlet 3.0的WebServlet注解即可不用在web.xml里写映射WebServlet(/drug/list) public class DrugListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8); HttpSession session request.getSession(false); if (session null || session.getAttribute(loginUser) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } DrugDao dao new DrugDao(); ListDrugInfo list dao.findDrugList(); request.setAttribute(drugList, list); request.getRequestDispatcher(/WEB-INF/pages/drug/list.jsp) .forward(request, response); } Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doGet(request, response); } }逻辑说明request.setCharacterEncoding(UTF-8)必须发生在任何getParameter调用之前因为参数是在第一次读取请求体时被解析的晚了就来不及了。getSession(false)在没有会话时返回null配合session里的loginUser属性判断登录态未登录直接重定向到登录首页。请求转发用forward它会把request对象连同drugList属性一起带到jsp而不是让浏览器发起第二次请求。参数说明转发路径写成/WEB-INF/pages/drug/list.jsp。把jsp放进WEB-INF目录后浏览器无法直接访问jsp源码只能由servlet转发触达这是servletjsp项目里最常见的权限保护手段。sendRedirect的参数用request.getContextPath()拼前缀避免项目部署在非ROOT路径时跳错地址。4.3 JSP层用JSTL循环渲染药品表格JSP页面只做展示。列表页用JSTL的c:forEach循环输出药品数据% page contentTypetext/html;charsetUTF-8 % % 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 cellpadding6 cellspacing0 tr th药品编码/thth药品名称/thth规格/th th库存/thth效期至/thth售价/thth供应商/th /tr c:forEach items${drugList} vard tr td${d.drugCode}/td td${d.drugName}/td td${d.spec}/td td${d.stockQuantity}/td tdfmt:formatDate value${d.expireDate} patternyyyy-MM-dd//td td${d.salePrice}/td td${d.suppliers}/td /tr /c:forEach /table /body /html逻辑说明${d.drugCode}这类EL表达式调用的是DrugInfo对象对应的getter方法。c:forEach遍历servlet里放进request作用域的drugList列表页的核心渲染逻辑只有循环和取值。fmt:formatDate负责把Date对象格式化成字符串它内部对线程安全处理得比手动new SimpleDateFormat更可靠。参数说明页面里第4章延续了服务端渲染的方式不要让jsp去调servlet接口拿JSON那是前后端分离的玩法在servletjsp栈里硬套反而多一层维护。库存预警的标红操作会在库存数量低于下限时用c:if判断class输出做法是再加一列把数量用小号字体和不同背景色渲染逻辑同样在jsp内完成不需要额外往返请求。JSTL标签与当前项目的对应关系整理如下标签作用药店系统里的位置c:forEach集合循环药品列表、库存预警列表c:if条件判断库存低于阈值时标红c:choose多分支选择按药品分类显示不同标签样式fmt:formatDate日期格式化有效期、生产日期字段4.4 web.xml与部署环境的几处关键配置使用注解后web.xml不需要servlet mapped但还有几项适合放在web.xml里的全局配置web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 session-config session-timeout30/session-timeout /session-config welcome-file-list welcome-filelogin.jsp/welcome-file /welcome-file-list /web-app参数说明session-timeout设成30分钟药店收银台长时间不操作后自动断开登录避免公用电脑上的会话被他人沿用。welcome-file直接指向login.jsp整个系统的入口从这里开始。之后如果要给多个后台页面统一做登录过滤把Filter的filter-mapping也写进本文件URL pattern用/drug/*、/supplier/*分块拦截即可。部署时还有一个和IDEA相关的坑在开发环境直接右键运行servlet类会看到“错误: 找不到或无法加载主类”的提示。原因是servlet本身没有main方法它必须由Tomcat容器加载并调用。遇到这种情况检查编译输出目录有没有生成对应class文件然后通过Tomcat的Run Configuration启动Web项目不要试图把servlet当普通Java类执行。servlet的入口是容器调起的service方法IDE弹出的main入口提示属于误操作。5. 上线前后必查的三个问题jsp改不生效、mysql乱码与连接池接入前面几章把系统搭起来后运行期最常见的问题集中在三个方向。它们不太影响功能完成度但直接影响交付印象。5.1 jsp改了不生效的排查顺序jsp改不生效时先别急着重启Tomcat按下面的顺序查确认你改的是Tomcat实际加载的那份jsp。IDE里类似target或out的构建目录经常和源目录并存构建产物没刷新时容器加载的是旧文件。打开Tomcat work目录下对应项目的org/apache/jsp找到页面名对应的java文件看生成时间是否晚于你最后一次编辑时间。检查jsp编译是否报错。新写的EL表达式或taglib指令存在语法错误时容器会继续沿用上一次编译成功的class从catalina.out里能看到编译异常。Tomcat通过时间戳判断jsp是否需要重编译如果服务器时间被回拨也会触发不生效。开发阶段把部署方式保持为exploded模式改完jsp后直接刷新页面不要每次打包war再发布。5.2 mysql中文乱码的检查与修复药品名、供应商名出现中文问号根源几乎都是字符集不一致。排查范围锁定三个节点连接串里的characterEncoding、mysql表默认字符集、jsp的contentType指定charset。三者统一用utf8mb4时乱码基本不会出现。如果数据已经乱码先不要直接UPDATE用下面的命令看清楚当前连接的字符集SET NAMES utf8mb4; SHOW VARIABLES LIKE character_set%;逻辑说明SET NAMES仅影响当前会话让客户端与服务器之间的通信立即切到utf8mb4SHOW VARIABLES输出六项character_set变量其中character_set_client、character_set_connection、character_set_results三项值得关注。三项都是utf8mb4说明链路没问题。参数说明如果三项不一致优先在连接串层面修复不要在数据库里反复转码。修复存量乱码数据时尽量避免“更新子查询”这种写法UPDATE drug_info SET drug_name (SELECT ... FROM drug_info WHERE id ?)会直接报“You cant specify target table for update”因为mysql不允许在子查询中直接引用目标表需要再包一层临时表派生查询才能绕过。5.3 把直连改成连接池的接入方式DriverManager直连在开发阶段完全够用但并发一上来每次请求的建连耗时就会拖慢页面。常见做法是引入HikariCP连接池把获取连接的工具类替换掉HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://127.0.0.1:3306/drug_store_db ?useSSLfalsecharacterEncodingutf8serverTimezoneAsia/Shanghai); config.setUsername(root); config.setPassword(yourpass); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(3000); HikariDataSource ds new HikariDataSource(config);逻辑说明HikariDataSource.getConnection()返回的是标准的java.sql.Connection对象所以DAO层已有的try-with-resources代码一行都不用改只把DbUtil里获取连接的方式指向连接池。上面这段初始化代码放在DbUtil的静态块里执行一次之后整个系统复用这个数据源。参数说明maximumPoolSize设为10对于药店系统足够过大的连接数反而会拖垮mysqlminimumIdle设为2是为了让应用启动时就备好两条连接避开第一波请求同时建连connectionTimeout是3000毫秒拿不到连接就抛异常避免请求无限期挂起。改完之后用带查询参数的药品列表接口连续刷几十次观察数据库端连接数和响应耗时的变化如果连接数稳定在池的上限以内、耗时不持续攀升说明连接池已经接管了连接生命周期。本文还有配套的精品资源点击获取
返回列表