
简介面向计算机相关专业学生与Java Web开发者的药店药品信息管理系统毕业设计文档针对传统手工管理药品信息效率低、查询耗时、流程繁琐等痛点完整呈现了从课题背景、需求分析到系统设计、功能实现等核心内容。系统基于B/S三层结构采用JSP、SSMSpring、Spring MVC、MyBatis与MySQL数据库实现支持管理员和用户双权限操作覆盖用户信息、供应商、租赁及回复管理等模块。资源包内仅含1个docx文档压缩包大小约696KB文档内含摘要、目录、正文等完整论文结构便于直接参考和二次编辑。已有137人浏览学习。读者可从中学到基于SSM框架的Web系统设计思路、数据库建表与访问方案、权限角色划分方法以及毕业论文的结构规范与写作表达适合作为毕业设计开题、撰写和答辩准备的参考素材也能显著减少前期资料收集与整体结构规划的时间。1. 从需求到模块药店药品管理系统到底要管什么拿「基于java的药店药品信息管理系统设计与实现」当课程设计题多数人第一反应是做成增删改查几张表、几个页面、登录一下交差。但药品管理的核心矛盾从来不是 CRUD而是同一个药名对应多个批次有效期、进价、供应商都不一样。建表时如果把库存写进药品表后续做效期预警、批次追溯、近效期催销就只剩跨表拼接 SQL越改越乱。这套系统值得写的三个点是批次级库存建模、效期预警与并发扣减、登录鉴权与流水审计。下文按数据库建模、Spring Boot MyBatis 实现、业务约束、上线验证的顺序拆开代码可直接抄进项目跑通。适合做 java 课程设计的学生、想补完整业务链路的初级后端以及准备 java 面试八股时需要真实场景的人。2. 数据库建模药品档案、批次与库存表怎么设计2.1 先划实体边界药品、批次为什么不能合成一张表药品信息管理系统里最容易被低估的是 drug_batch 这张批次表。现实药店的进货按批次走同一批准入文号的药不同批次的生产日期、有效期、进价甚至供应商都不一定相同监管上也要求按批号追溯遇到质量公告要能定位到具体批次做召回或停售。把 batch_no 直接做成 drug 表的一个字段等于默认「一药一批」第二次录入同款药的新批次时要么改表结构要么再造一堆重复的药品记录库存统计还要靠名称 group by数据早晚脏掉。拆表的原则是drug_info 只放静态档案编码、品名、规格、厂家、零售价、库存下限drug_batch 放动态的批次库存批号、生产日期、有效期、进价、剩余数量、供应商stock_log 放每一条出入库流水。三张表各管一段药品改名不丢批次批次过期不影响档案任何时候都能通过流水反推库存是怎么变的。注意表名和字段名统一小写下划线。MySQL 在 Linux 下对表名大小写敏感在 Windows 下默认不敏感项目从本地迁到服务器最容易在这里出现「表不存在」的诡异报错。2.2 建表 DDL效期字段和联合索引的位置我一般会建四张核心表sys_user、drug_info、drug_batch、stock_log。DDL 兼容 MySQL 8.0引擎统一 InnoDB字符集 utf8mb4CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL COMMENT BCrypt 哈希值, role TINYINT NOT NULL DEFAULT 1 COMMENT 0管理员 1店员, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE drug_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_code VARCHAR(30) NOT NULL COMMENT 药品编码, drug_name VARCHAR(100) NOT NULL, spec VARCHAR(50) COMMENT 规格, manufacturer VARCHAR(120) COMMENT 生产厂家, approval_no VARCHAR(50) COMMENT 批准文号, category VARCHAR(30) COMMENT 分类如抗生素/OTC, retail_price DECIMAL(10,2) NOT NULL DEFAULT 0, min_stock INT NOT NULL DEFAULT 10 COMMENT 库存下限, UNIQUE KEY uk_drug_code (drug_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE drug_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL, batch_no VARCHAR(50) NOT NULL COMMENT 批号, production_date DATE, expiry_date DATE NOT NULL, purchase_price DECIMAL(10,2), stock_count INT NOT NULL DEFAULT 0, supplier_id BIGINT, alerted TINYINT NOT NULL DEFAULT 0 COMMENT 近效期是否已提醒, KEY idx_drug_expiry (drug_id, expiry_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;DDL 里有两个参数容易被忽略。金额用 DECIMAL(10,2) 而不用 DOUBLE浮点误差在月底对账时会产生分位差异(drug_id, expiry_date) 联合索引是效期预警查询能不能走索引的关键后面会专门讲。sys_user 里存放 BCrypt 哈希而不是明文密码演示项目也别省这一步不然登录功能写完后还得回头补。alerted 字段是给定时任务用的状态位保证同一批药不会被反复提醒。库存流水表单独设计before_count 和 after_count 两个快照字段必须同时落库只存变动量的话盘点差异核对时无法验证连续性CREATE TABLE stock_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id BIGINT NOT NULL, change_type VARCHAR(10) NOT NULL COMMENT IN/OUT/CHECK/SCRAP, qty INT NOT NULL COMMENT 正数入库负数出库, before_count INT NOT NULL, after_count INT NOT NULL, operator_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_batch_time (batch_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.3 预警 SQL把近效期判断压进 WHERE不要全表捞回 Java 过滤课程设计里最常见的写法是查出全部 drug_batch然后在 for 循环里用 LocalDate 比较「是否 90 天内到期」。几百行数据时没问题超过两万行后每轮定时任务全量加载都会拖慢应用年轻代 GC 频繁查询接口跟着卡顿。正确做法是把比较逻辑交给数据库SELECT b.id, d.drug_name, b.batch_no, b.expiry_date, b.stock_count FROM drug_batch b JOIN drug_info d ON d.id b.drug_id WHERE b.expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) AND b.stock_count 0 ORDER BY b.expiry_date ASC;DATE_ADD 让「近效期 90 天」变成一个可参数化的边界改成 30 天、180 天只动 INTERVAL 一个地方。注意不要在字段上套函数写成 DATE_ADD(b.expiry_date, INTERVAL 90 DAY) CURDATE() 会让 (drug_id, expiry_date) 索引彻底失效。库存下限预警同理一条 WHERE stock_count min_stock 就能统计出缺货清单不需要在 Java 里做任何内存判断。实现方式是否利用索引5 万行批次时的表现可维护性条件全写在 SQL WHERE命中 idx_drug_expiry毫秒级返回内存占用固定阈值只改 SQL 一处全表加载后在 Java 过滤否每轮全表扫描GC 压力大阈值逻辑散落在多个方法里3. Spring Boot MyBatis 分层落地登录鉴权与药品档案的最小代码3.1 选型Spring Boot 2.7 MyBatis Thymeleaf别在配置上浪费时间当前 java 后端学习路线和课程设计的主流组合是 Spring Boot MyBatis比 SSH 少一半配置又比 MyBatis-Plus 更接近 SQL 本身。用 Spring Initializr 生成工程后pom.xml 里保留四个依赖就够用dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependencyweb 提供内嵌 Tomcat 和 Spring MVCmybatis-spring-boot-starter 负责数据源自动装配和 Mapper 接口扫描mysql-connector-j 是 MySQL 8 的驱动坐标thymeleaf 是服务端模板引擎。如果不想写模板页面接口直接返回 JSON、配一个极简前端也可以后端分层完全不受影响。课程设计要交源码答辩时这个组合的目录结构最容易讲清楚controller 接参数、service 写业务、mapper 管 SQL。3.2 登录鉴权拦截器统一拦截不在 Controller 里散落判断药店系统的权限粒度很粗管理员能进货、盘点、管用户店员只能开单销售。用 HandlerInterceptor 统一处理比在每个 Controller 里反复判断 session 是否为空干净得多Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user request.getSession().getAttribute(login_user); if (user null) { response.sendRedirect(/login); return false; } return true; } }注册拦截器时把登录接口和静态资源放行其余全部拦截Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /doLogin, /css/**, /js/**); } }登录成功往 session 放 login_user退出时调用 session.invalidate()。这里有一个实际体验问题Tomcat 的 session 默认过期时间是 30 分钟药店收银台可能一整天不关浏览器超时后店员下一单就被踢回登录页。把 session.setMaxInactiveInterval 调到 8 小时或者引入「记住我」的 Cookie 有效期属于这类系统很实际的体验优化项。3.3 多表写入的事务边界药品建档 首次入库必须在一个方法里录入一个药品并登记首批库存涉及一次 drug_info INSERT 和一次 drug_batch INSERT。如果拆成两个 service 方法调用第二步失败就会留下「有档案无库存」的脏数据。正确做法是合成一个方法并把事务标注在方法上Service public class DrugService { Autowired private DrugInfoMapper infoMapper; Autowired private DrugBatchMapper batchMapper; Transactional(rollbackFor Exception.class) public Long addDrugWithFirstBatch(DrugInfo info, DrugBatch batch) { infoMapper.insert(info); batch.setDrugId(info.getId()); batchMapper.insert(batch); return info.getId(); } }rollbackFor Exception.class 不能省。Spring 默认只对 RuntimeException 回滚业务里一旦抛出受检异常而没写这个参数事务会静默提交库存就落到库里了。batch.setDrugId(info.getId()) 依赖 MyBatis 的 useGeneratedKeys 回填主键INSERT 语句里必须配好 keyPropertyid。Maven 项目里还要注意MyBatis 的 XML 文件放在 src/main/resources 下会被正常打包如果放在 java 目录里就得在 build 配置里额外声明否则运行时直接报绑定错误。Mapper 绑定相关的常见报错集中在下面这张表报错信息常见原因排查入手点Invalid bound statement (not found)XML namespace 与 Mapper 接口包名不一致或方法 id 对不上打开 target/classes 检查 XML 是否被正确复制Bean named xxxMapper not found启动类缺少 MapperScan加上 MapperScan(com.demo.drug.mapper)SQLSyntaxErrorException本地与目标库的 MySQL 方言有差异逐条核对 CREATE TABLE 和生产库版本到这里登录、药品档案、批次库存三块的骨架已经能跑通。接下来才是药店系统的分水岭库存怎么扣才能在并发下不出错效期预警怎么扫才不会每天重复打扰店长。4. 效期预警与并发扣减三个最容易翻车的业务点4.1 定时任务先扫再标记避免同一批药被重复提醒预警逻辑用 Spring 的 Scheduled 实现每天凌晨 2 点执行一次避开白天销售高峰的数据库写入Component public class ExpiryAlertTask { Autowired private DrugBatchMapper batchMapper; Scheduled(cron 0 0 2 * * ?) public void scanExpiry() { ListLong ids batchMapper.findExpiringIds(90); if (ids.isEmpty()) { return; } for (Long id : ids) { batchMapper.markAlerted(id); // 在这里发站内信、短信或生成催销单 } } }cron 表达式0 0 2 * * ?对应「秒 分 时 日 月 周」即每天 02:00:00 触发。括号里的 90 建议从配置中心读取不要硬编码在方法里。这段代码的关键不是扫描本身而是 markAlerted处理完把 alerted 置 1否则任务第二天重跑店长每天早上收到完全相同的催销清单预警系统很快会被无视。新采购入库的批次 alerted 重置为 0自然进入下一轮提醒周期。定时任务默认单线程串行扫描逻辑一旦变慢会阻塞后续任务数据量大时给 TaskScheduler 配一个 2 到 4 线程的线程池。4.2 并发扣减条件 UPDATE 代替「先查后改」让数据库做裁决销售出库是并发冲突最集中的地方。教科书式的三段式流程——先 SELECT 库存、Java 判断够不够、再 UPDATE——在两个人同时买同一种药时必然翻车-- 反例两个事务同时读到 stock_count 5都认为可以卖 2 SELECT stock_count FROM drug_batch WHERE id 1; UPDATE drug_batch SET stock_count 5 - 2 WHERE id 1;两个请求读到相同快照各自执行更新其中一次更新被覆盖库存越卖越多。正确写法是把「校验库存」和「扣减数量」合并成一条原子 UPDATETransactional public void sell(Long batchId, int qty, Long operatorId) { int rows batchMapper.deductStock(batchId, qty); if (rows 0) { throw new BizException(库存不足或批次不存在); } logMapper.insert(StockLog.of(batchId, -qty, OUT, operatorId)); }update iddeductStock UPDATE drug_batch SET stock_count stock_count - #{qty} WHERE id #{id} AND stock_count #{qty} /updaterows 0 说明条件不满足即库存不足直接抛业务异常让整个事务回滚stock_log 也不会留下孤立的流水。WHERE 里的 stock_count #{qty} 是关键约束UPDATE 自带校验InnoDB 对这行的排他锁只覆盖语句执行本身不会像 SELECT ... FOR UPDATE 那样把锁持有到事务提交。单店收银场景下这种乐观风格完全够用不需要引入 Redis 或分布式锁。提示MyBatis 的 update 方法返回 int 表示实际受影响的行数不是「匹配到」的行数与 JDBC 的返回值语义一致。调试时不要把它当成匹配行数来用。4.3 Transactional 不生效的三个高频原因事务问题既是 java 八股文的常客也是这套系统里最容易踩的坑。三个高频场景场景现象原因与规避同类内部 this.sell() 调用异常后库存没有回滚Transactional 走代理对象this 调用绕过了代理方法声明为 private启动无报错异常不回滚Spring 只代理 public 方法异常被 try-catch 吞掉数据已提交但业务实际失败让异常抛到代理边界或显式 setRollbackOnly()排查手段很直接在 sell 方法里人为抛一个 RuntimeException观察 stock_log 是否跟着回滚。如果没有优先怀疑是不是出现了 this.sell() 这类内部调用。按表格从上往下一一核对十分钟内基本能定位。4.4 盘点报损走流水库存差异不能直接改数还有一个容易被课程设计漏掉的功能是盘点。GSP 检查要求药店有盘点记录账面数、实盘数、差异原因都要留痕。常见做法是盘点时把差异生成一条 change_type CHECK 的流水盘亏为负数盘盈为正数最后通过 stock_log 重放核对数据连续性而不是直接 UPDATE drug_batch 的库存。任何一笔库存变动都能在 stock_log 里反查到操作人、操作时间和前后快照对账时写一个校验 SQL把某批次所有流水按时间排序验证每一条 after_count 等于上一条 after_count 加本笔 qty库存连续性检查就完全自动化了。5. 上线前验证并发压测与 explain 抓慢 SQL5.1 用 20 个并发打销售接口验证库存不超卖系统部署后先用 curl 登录拿会话 Cookie再并发打销售接口curl -s -c cookies.txt -X POST http://localhost:8080/doLogin \ -d usernameadminpassword123456 seq 1 50 | xargs -I {} -P 20 curl -s -b cookies.txt \ -X POST http://localhost:8080/sale \ -d batchId1qty1 -o /dev/null -w %{http_code}\n \ | sort | uniq -c-P 20 表示同时维持 20 个请求跑完看状态码分布再去数据库核对 drug_batch 的 stock_count 是否正好减少 50。对不上时先查 deductStock 的 SQL 和事务配置再看 stock_log 里有没有缺失的流水两步就能定位是 SQL 写错还是事务没生效。5.2 慢页面先 explain不要急着加缓存预警列表页打开慢第一反应不是加 Redis而是看执行计划EXPLAIN SELECT b.id, d.drug_name, b.batch_no, b.expiry_date FROM drug_batch b JOIN drug_info d ON d.id b.drug_id WHERE b.expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) AND b.stock_count 0;EXPLAIN 输出里 type 列为 ALL、key 列为 NULL就是全表扫描。此时回 2.2 节检查 (drug_id, expiry_date) 联合索引是否存在以及 WHERE 里有没有对 expiry_date 套函数。另一个容易忽略的坑是 JOIN 驱动顺序小表驱动大表时EXPLAIN 第一行表的 rows 更小必要时用 STRAIGHT_JOIN 强制连接顺序避免优化器选错驱动表。5.3 用 89 天后到期的批次验证预警任务定时任务不能等明天凌晨再验证。手工插入一条 expiry_date CURDATE() INTERVAL 89 DAY 的批次记录手动触发一次扫描方法检查该批次 alerted 是否从 0 变成 1再次触发时确认它不再出现在结果集里。这个办法同时验证了 SQL 的时间边界和状态位逻辑顺手也把 BETWEEN 的闭区间语义测了一遍CURDATE() 当天到期的批次会被扫出来而第二天到期的不会。本文还有配套的精品资源点击获取