☰
Javaweb药店管理系统源码与数据库落地实战:从建表到避坑
2026/10/8 16:36:20 网站建设 项目流程

简介:这是一套基于Java Web技术栈的药店管理系统完整项目,包含可运行源码与配套数据库,面向Java Web初学者、课程设计学生及需要实战练手的开发者,帮助理解Servlet、JSP、JSTL与MVC分层开发在真实业务中的落地方式。系统覆盖用户管理、药品管理、销售管理、库存管理、报表统计与权限控制等模块,数据库设计涉及药品表、用户表、订单表等核心结构,并包含库存预警、销售报表、数据加密与用户认证等实用功能点。压缩包共2493个文件,以js、png、gif、css等前端静态资源为主,辅以jsp页面、java源码、class文件、jar依赖、xml配置及sql建库脚本,整体约22.51MB,目录按MVC模式组织,便于按模块查阅与二次开发。目前已有882人学习下载,适合对照源码梳理请求处理流程、数据库表关系与业务逻辑,是掌握Java Web综合项目开发的实践素材。

1. 药店管理系统从零到跑通:一套 Javaweb 源码和数据库到底该怎么落地

接手一个药店管理系统的 Javaweb 项目,最怕的不是代码写不出来,而是拿到源码和数据库文件之后,环境跑不起来、表结构对不上、增删改查一改就崩。药店这个场景跟普通 CRUD 练手项目不一样,它涉及药品批号、有效期、库存预警、处方药限购、销售流水追溯,任何一张表设计歪了,后面全是血泪经验。我见过太多人把「基于 Javaweb 的药店管理系统及源码和数据库」当成课程设计模板,导入 IDEA 一跑就报 500,最后连数据库都没连上。这篇笔记就按一线落地的顺序,把技术选型、数据库设计、源码结构、环境配置、避坑排查和进阶技巧讲透,适合正在做课程设计、毕业设计或者小团队接私活的开发者,也适合想拿一个完整案例练手 Javaweb 连接 MySQL 数据库的人。读完你至少能自己把项目跑起来,知道每个参数为什么这么设,出了问题往哪查。

2. 技术选型与数据库设计:为什么药店场景不能照搬通用 CRUD 模板

2.1 药店管理系统的核心业务对象决定了表结构

普通管理系统通常就是用户表、角色表、日志表三件套,但药店管理系统必须围绕「药品」这个核心实体展开。药品有通用名、商品名、规格、剂型、生产厂家、批准文号、批号、有效期、储存条件,这些字段一个都不能省。尤其是批号和有效期,直接决定库存能不能卖、要不要预警。销售环节还要记录处方药标记、购买人身份信息(处方药限购)、销售员、销售时间。如果只按「商品表 + 订单表」来设计,后面做有效期预警和批号追溯时就得推倒重来。

我一般会把核心表分成四组:基础信息表(药品、供应商、员工、会员)、库存表(按批号维度)、销售表(主表 + 明细)、系统表(用户、角色、日志)。库存表一定要以「药品 ID + 批号」作为联合唯一键,而不是只按药品 ID 汇总数量。因为同一药品不同批号的有效期不同,卖的时候要按先进先出扣减。这个设计点很多模板会忽略,等到做近效期预警时才发现库存表根本查不出哪个批号快过期。

2.2 数据库建表脚本与关键字段说明

下面这段 SQL 是药店管理系统里最核心的药品表和库存表建表语句,可以直接在 MySQL 5.7 或 8.0 里执行。注意字符集用 utf8mb4,排序规则用 utf8mb4_general_ci,避免药品名称里的生僻字乱码。

-- 药品基础信息表 CREATE TABLE `medicine` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '药品ID', `generic_name` varchar(100) NOT NULL COMMENT '通用名', `trade_name` varchar(100) DEFAULT NULL COMMENT '商品名', `spec` varchar(50) NOT NULL COMMENT '规格', `dosage_form` varchar(20) DEFAULT NULL COMMENT '剂型', `manufacturer` varchar(100) DEFAULT NULL COMMENT '生产厂家', `approval_no` varchar(50) DEFAULT NULL COMMENT '批准文号', `is_prescription` tinyint(1) DEFAULT '0' COMMENT '是否处方药 0否 1是', `storage_condition` varchar(50) DEFAULT NULL COMMENT '储存条件', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_generic_name` (`generic_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药品基础信息'; -- 库存表(按批号维度) CREATE TABLE `stock` ( `id` int(11) NOT NULL AUTO_INCREMENT, `medicine_id` int(11) NOT NULL COMMENT '药品ID', `batch_no` varchar(50) NOT NULL COMMENT '批号', `production_date` date DEFAULT NULL COMMENT '生产日期', `expiry_date` date NOT NULL COMMENT '有效期至', `quantity` int(11) NOT NULL DEFAULT '0' COMMENT '库存数量', `purchase_price` decimal(10,2) DEFAULT NULL COMMENT '进价', `sale_price` decimal(10,2) DEFAULT NULL COMMENT '售价', `supplier_id` int(11) DEFAULT NULL COMMENT '供应商ID', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_medicine_batch` (`medicine_id`,`batch_no`), KEY `idx_expiry_date` (`expiry_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存批号表';

逻辑说明:medicine 表存药品静态信息,stock 表存动态库存。uk_medicine_batch 联合唯一键保证同一药品同一批号只有一条记录,入库时用 ON DUPLICATE KEY UPDATE 累加数量。idx_expiry_date 索引是为了近效期预警查询能走索引,否则数据量上来后全表扫描会拖慢系统。参数方面,quantity 用 int 足够,药店单批号库存一般不会超过十万;purchase_price 和 sale_price 用 decimal(10,2) 避免浮点误差。is_prescription 用 tinyint(1) 而不是 char,方便 Java 端映射成 Boolean。

2.3 销售主表与明细表的外键约束取舍

销售表设计有个常见争议:要不要加物理外键。我的做法是业务表之间不加物理外键,只在应用层保证一致性。原因是药店系统后期可能分库或者做数据同步,物理外键会带来迁移麻烦。但销售明细表必须冗余药品名称、规格、批号、售价,因为历史订单不能因为药品信息修改而变样。

CREATE TABLE `sale_order` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `member_id` int(11) DEFAULT NULL COMMENT '会员ID', `employee_id` int(11) NOT NULL COMMENT '销售员ID', `total_amount` decimal(10,2) NOT NULL DEFAULT '0.00', `pay_type` tinyint(1) DEFAULT '1' COMMENT '支付方式 1现金 2医保 3移动支付', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售主表'; CREATE TABLE `sale_detail` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_id` int(11) NOT NULL, `medicine_id` int(11) NOT NULL, `medicine_name` varchar(100) NOT NULL COMMENT '冗余药品名', `batch_no` varchar(50) NOT NULL, `quantity` int(11) NOT NULL, `price` decimal(10,2) NOT NULL, `subtotal` decimal(10,2) NOT NULL, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售明细';

order_no 用业务规则生成,比如「年月日 + 随机数 + 序列」,不要用自增 ID 直接暴露给前端。pay_type 预留医保类型,方便后期扩展。sale_detail 里冗余 medicine_name 和 batch_no 是刻意为之,历史数据不可变。如果做课程设计,这套表结构足够覆盖增删改查、库存预警、销售统计三个核心模块。

3. 源码结构与 IDEA 运行配置:把 Javaweb 项目完整案例跑起来

3.1 典型 Maven 项目目录与各层职责

一套能跑的 Javaweb 药店管理系统源码,目录结构通常长这样:src/main/java 下分 controller、service、dao、entity、util 五个包;src/main/resources 放 db.properties、mybatis-config.xml 或 spring 配置文件;src/main/webapp 放 WEB-INF/web.xml、jsp 页面和 static 静态资源。如果是 SSM 项目,controller 用 @Controller 注解,service 用 @Service,dao 用 @Repository 或者 MyBatis 的 Mapper 接口。新手最容易搞混的是 web.xml 里 DispatcherServlet 的配置和 spring 容器扫描路径,配错了启动直接报 NoSuchBeanDefinitionException。

我一般会先看 pom.xml 里的依赖版本,重点确认 mysql-connector-java 版本和数据库版本匹配。MySQL 8.0 要用 8.0.x 的驱动,连接 URL 要加时区参数,否则报「The server time zone value is unrecognized」。下面是一个最小可用的 db.properties 配置。

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/pharmacy_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=你的密码

参数说明:serverTimezone 必须设,否则 MySQL 8 连接失败;useSSL=false 在本地开发关掉,生产环境再开;characterEncoding=utf8 配合数据库 utf8mb4 使用。如果密码里有特殊字符,记得做 URL 编码。

3.2 在 IDEA 里配置 Tomcat 并部署项目

步骤一:File → Project Structure → Modules,确认 src/main/java 被标记为 Sources,src/main/resources 被标记为 Resources。步骤二:Facets 里添加 Web,Web Resource Directory 指向 src/main/webapp,web.xml 路径指向 WEB-INF/web.xml。步骤三:Artifacts 里添加 Web Application: Exploded,输出目录默认就行。步骤四:Run → Edit Configurations,添加 Tomcat Local,Deployment 里选刚才的 Artifact,Application context 设成 /pharmacy。启动后访问 http://localhost:8080/pharmacy 就能看到登录页。

如果启动报 404,先检查 Application context 是不是多了斜杠;如果报 ClassNotFoundException,检查 Artifact 里有没有把 Maven 依赖打进 WEB-INF/lib。这一步是「idea 运行 javaweb 项目配置」里最高频的翻车点,很多人代码没问题,就卡在 Artifact 没配全。

3.3 数据库连接与 MyBatis 映射文件的关键配置

如果项目用 MyBatis,mybatis-config.xml 里要配好 typeAliases 和 mappers。typeAliases 让实体类不用写全限定名,mappers 指定 XML 映射文件位置。下面是一个药店管理系统的 MyBatis 核心配置片段。

<configuration> <typeAliases> <package name="com.pharmacy.entity"/> </typeAliases> <mappers> <mapper resource="mapper/MedicineMapper.xml"/> <mapper resource="mapper/StockMapper.xml"/> <mapper resource="mapper/SaleOrderMapper.xml"/> </mappers> </configuration>

逻辑说明:package 方式批量注册别名,实体类名首字母小写就是别名。mappers 用 resource 方式加载 classpath 下的 XML。如果 Mapper 接口和 XML 放在同一包下,也可以用 package 方式扫描,但要求文件名一致。参数方面,MyBatis 的 mapUnderscoreToCamelCase 建议设为 true,这样数据库的 generic_name 能自动映射到 Java 的 genericName,省掉大量 resultMap 配置。

3.4 药品库存增删改查的 Service 层实现要点

库存扣减是药店系统里最需要小心的地方。卖药时要按批号先进先出,还要防止并发超卖。下面这段 Service 层代码演示了扣减库存的基本逻辑,用了简单的 synchronized 锁,适合课程设计级别;生产环境建议用数据库行锁或者 Redis 分布式锁。

@Service public class StockService { @Autowired private StockMapper stockMapper; // 按批号先进先出扣减库存 public synchronized boolean reduceStock(Integer medicineId, int quantity) { // 查出该药品所有有库存的批号,按有效期升序 List<Stock> stockList = stockMapper.selectAvailableByMedicineId(medicineId); int remain = quantity; for (Stock stock : stockList) { if (remain <= 0) break; int deduct = Math.min(stock.getQuantity(), remain); stockMapper.updateQuantity(stock.getId(), stock.getQuantity() - deduct); remain -= deduct; } return remain == 0; } }

逻辑说明:selectAvailableByMedicineId 对应的 SQL 要加WHERE quantity > 0 AND expiry_date > CURDATE() ORDER BY expiry_date ASC,保证只扣有效库存且先过期先出。updateQuantity 要带条件WHERE id = #{id} AND quantity >= #{deduct},防止并发时扣成负数。参数方面,quantity 是本次销售数量,remain 是还没扣完的数量,循环结束后 remain 为 0 才算成功。如果返回 false,Controller 层要回滚事务并提示库存不足。

4. 避坑与排查:药店管理系统跑不起来时先查这 5 个地方

4.1 现象:启动报 Access denied for user 'root'@'localhost'

原因:db.properties 里的密码不对,或者 MySQL 用户没有远程/本地访问权限。解决:先用命令行mysql -uroot -p确认密码能登录;如果密码对但还报错,检查 MySQL 的 user 表里 root 用户的 host 字段是不是 localhost。有时候用 Docker 跑 MySQL,端口映射了但 root 只允许 % 访问,本地连接反而被拒。改法:ALTER USER 'root'@'localhost' IDENTIFIED BY '新密码';或者新建一个专用用户。

4.2 现象:页面中文乱码,药品名显示问号

原因:数据库、表、连接 URL、JSP 页面四层编码不一致。解决:数据库和表用 utf8mb4,连接 URL 加 characterEncoding=utf8,JSP 页面顶部加<%@ page contentType="text/html;charset=UTF-8" language="java" %>,Tomcat 的 server.xml 里 Connector 加 URIEncoding="UTF-8"。四层缺一层都可能乱码,我一般会从数据库往回查,先确认SHOW VARIABLES LIKE 'character%'的结果。

4.3 现象:MyBatis 报 Invalid bound statement (not found)

原因:Mapper XML 没被编译到 classpath,或者 namespace 和接口全限定名不一致,或者方法名对不上。解决:检查 target/classes 下有没有对应的 XML 文件;如果没有,在 pom.xml 的 build 里加 resources 配置,把 src/main/java 下的 XML 也打包进去。然后核对 XML 的 namespace 是不是接口的全限定名,select 的 id 是不是方法名。这个报错几乎每个 MyBatis 新手都会遇到,属于必踩坑。

4.4 现象:销售时库存扣成负数

原因:并发扣减没有加锁,或者 update 语句没带 quantity >= deduct 条件。解决:Service 层加 synchronized 只能单机有效,多节点要用数据库悲观锁SELECT ... FOR UPDATE或者乐观锁版本号。更简单的做法是 update 语句直接写SET quantity = quantity - #{deduct} WHERE id = #{id} AND quantity >= #{deduct},根据返回的影响行数判断是否成功。返回 0 就说明库存不足,抛异常回滚。

4.5 现象:近效期预警查不出数据

原因:expiry_date 存成了字符串,或者查询条件用了DATEDIFF(expiry_date, CURDATE()) <= 30但字段类型是 varchar,导致隐式转换失效。解决:建表时 expiry_date 必须用 date 类型,查询时用expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY)。如果已经存成字符串,先用 STR_TO_DATE 转换,但最好还是改表结构。这个坑在课程设计里特别常见,因为很多人图省事用 varchar 存日期。

5. 进阶技巧:用数据库同步和 SQL 优化让药店系统更稳

5.1 用定时任务做近效期预警和库存同步

药店系统跑起来之后,真正体现价值的是自动化。我一般会加一个 Spring Task 或者 Quartz 定时任务,每天凌晨跑一次近效期扫描,把 30 天内过期的批号写进预警表,前端登录后弹窗提示。同时可以做一个库存同步逻辑,把销售明细汇总回库存表,防止手工改库导致数据不一致。下面是一个简单的定时任务示例。

@Component public class ExpiryWarningTask { @Autowired private StockMapper stockMapper; @Autowired private WarningMapper warningMapper; // 每天凌晨2点执行 @Scheduled(cron = "0 0 2 * * ?") public void scanExpiry() { List<Stock> list = stockMapper.selectExpiringSoon(30); for (Stock s : list) { warningMapper.insertWarning(s.getMedicineId(), s.getBatchNo(), s.getExpiryDate()); } } }

逻辑说明:cron 表达式0 0 2 * * ?表示每天 2 点执行。selectExpiringSoon 的 SQL 用expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL #{days} DAY)。参数 days 传 30,可以根据药店实际管理要求调整成 60 或 90。预警表要加唯一键防止重复插入,或者每次扫描前先清空当天预警。

5.2 销售统计查询的 SQL 优化与索引调整

药店老板最关心的是「今天卖了多少钱、哪个药卖得最好、哪个员工业绩高」。这些统计查询如果不加索引,数据量到几万条就会明显变慢。我一般会在 sale_order 的 create_time 和 employee_id 上建联合索引,在 sale_detail 的 medicine_id 上建索引。统计 SQL 尽量用 BETWEEN 而不是 YEAR()、MONTH() 函数,因为函数会导致索引失效。

-- 优化前:索引失效 SELECT SUM(total_amount) FROM sale_order WHERE YEAR(create_time) = 2025 AND MONTH(create_time) = 6; -- 优化后:走索引 SELECT SUM(total_amount) FROM sale_order WHERE create_time >= '2025-06-01 00:00:00' AND create_time < '2025-07-01 00:00:00';

参数说明:日期范围用左闭右开,避免月底最后一秒的边界问题。如果要做月度报表,可以在应用层算好起止时间再传进来。另外,统计结果如果实时性要求不高,可以每天凌晨跑一次汇总表,查询时直接读汇总表,响应时间能从秒级降到毫秒级。

5.3 数据库备份与结构变更的后悔药

药店系统的数据比代码值钱,销售流水丢了就是真金白银的损失。我习惯在项目里加一个简单的备份脚本,用 mysqldump 每天导出一次,保留最近 7 天。结构变更一定要用 SQL 脚本管理,不要直接在客户端改表,否则换台机器部署时结构对不上。下面是一个备份命令示例。

mysqldump -uroot -p密码 --single-transaction --routines --triggers pharmacy_db > /backup/pharmacy_$(date +%Y%m%d).sql

参数说明:--single-transaction 保证 InnoDB 表导出时一致性,不锁表;--routines 导出存储过程和函数;--triggers 导出触发器。备份文件按日期命名,方便回滚。恢复时用mysql -uroot -p密码 pharmacy_db < 备份文件.sql。这个习惯帮我省过好几次事,有一次改表结构改错了,直接回滚到前一天的数据,只损失了几小时流水。

5.4 从课程设计到真实可用的最后一步

课程设计级别的药店管理系统和真实药店在用的系统,差距往往不在功能多少,而在边界处理。比如退货怎么退、拆零销售怎么算、医保对接怎么留接口、盘点差异怎么调账。我的建议是先把核心的进销存跑通,再逐步加边界功能。每加一个功能,先想清楚数据库怎么变、事务边界在哪、异常怎么回滚。这套思路比多写几个页面更有价值。

我自己做这类项目时有个习惯:每次改完代码,先不急着点页面,而是把关键 SQL 在数据库里手动跑一遍,确认结果对了再联调。这个习惯让我少熬了很多夜。希望帮到你。

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

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

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

立即咨询