JavaWeb药品存销管理系统:从数据库设计到部署的完整实践
简介面向软件工程课程设计的药品存销信息管理系统以JavaWeb为核心技术栈完整提供前端页面、后端逻辑和数据库脚本适合高校学生完成课程设计、大作业或毕业设计参考。资源压缩包共623个文件大小约46.67MB内部包含108个Java源文件、202个编译后的class类、69个jar依赖库、36个JSP页面、65个XML配置、30个JS脚本与22个CSS样式文件以及SQL数据库脚本覆盖页面展示、业务处理和数据存储等环节目录结构清晰方便导入开发环境后按模块查阅。系统围绕药品存销场景实现出入库管理、库存查询、销售记录等常用功能并通过日志文件完整记录运行过程便于排查部署异常整体采用经典MVC分层设计可辅助读者理解JavaWeb中视图、控制器与业务模型的协作方式。目前已有98人学习下载对希望快速梳理项目骨架、掌握前后端交互细节的初学者颇具借鉴意义。1. 药品存销信息管理系统一份 JavaWeb 课程设计的骨架与血药品进、销、存三个动作要落到数据库用户权限和库存预警又逼着你去处理事务与状态临界规模不大却能把 Servlet、JSP、JDBC、MySQL 全串起来。对刚做完 JavaWeb 基础课、准备把第一个完整项目写进简历的人来说这套题够分量但不至于失控。拿到“源代码数据库”的压缩包和拿到需求文档完全是两回事。前者让你少走框架集成的弯路后者决定这套系统在你手里能拆得多深。这篇内容按数据模型、数据库脚本、Java 核心模块、部署验证的顺序讲清楚一个可用版本应该长什么样、每个关键代码段的参数在改什么、测试时盯哪些日志。2. 系统边界与数据库设计药品、批次、供应商缺一不可2.1 药品存销的最小业务闭环怎么拆把标题拆开看存销是核心词。系统至少要覆盖五个动作药品入库、药品出库销售、库存盘点、供应商对账、系统用户管理。如果只建一张药品表加一张明细表那叫增删改查不叫存销管理。课程设计评审会盯住批次这个实体——同一药品不同批号进价、有效期、库存数量必须分离。我一般把一个可用版本拆成六张业务表药品目录表drug_info、药品批次表drug_batch、供应商表supplier、入库单主表purchase_in、销售单主表sale_out、系统用户表sys_user。库存数量不直接冗余在 drug_info 里而是通过 drug_batch 的剩余数量做聚合这样每次出入库都只影响对应批次行对账时也查得清来龙去脉。2.2 表结构与字段约束的取舍药品目录表需要存储通用名、商品名、规格、生产厂家、批准文号、剂型。批准文号是国药准字号有固定格式建议直接建唯一索引。药品批次表是全系统的重点它要关联 drug_info通过 drug_id还要单独存批号batch_no、生产日期produce_date、有效期至expire_date、进价purchase_price、售价sale_price、入库数量与当前剩余数量stock_count。供应商表不需要做得太复杂名称、联系人、电话、地址就够。入库单和销售单做主从结构分别有主表与明细表会更完整但课程设计的代码量有限六个表以内是合理规模——你要给三个月后再看代码的自己留出能认出来的余地而不是把完整 ERP 塞进去。2.3 为什么用 MySQL 的 InnoDB 与 utf8mb4药品名称会包含特殊字符比如维生素C泡腾片橙味里的括号和拉丁字母如果库表字符集用 utf8 而非 utf8mb4在某些 MySQL 版本下插入特殊符号会报错或变成乱码。建库语句就用DEFAULT CHARSETutf8mb4引擎选 InnoDB否则外键和事务回滚都没法保证。另一个容易被忽略的细节是金额字段的类型。药品进价和售价不要用 FLOAT 或 DOUBLE浮点数在累加时会丢精度DECIMAL(10,2) 即可Java 侧对应 BigDecimal避免把数据库取出的 0.1 变成 0.09999999。这两个选择在校验阶段能帮你挡掉一大半数据异常类 bug属于课程设计报告中可以单独写进设计理由的加分项。3. 建库脚本与初始化数据手写 SQL 的边界在哪3.1 完整建表 SQL 与字段注释课程设计交付物里数据库脚本是评分的基本盘也是答辩时老师最常直接打开看的文件。一个合格的建库脚本要包含建库语句、建表语句、索引、外键和测试数据。下面这段是药品批次表的核心建表脚本可以复制到 Navicat 或 MySQL 命令行执行CREATE DATABASE IF NOT EXISTS drug_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE drug_db; DROP TABLE IF EXISTS drug_batch; CREATE TABLE drug_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 流水id, drug_id BIGINT NOT NULL COMMENT 药品目录id, batch_no VARCHAR(50) NOT NULL COMMENT 生产批号, produce_date DATE NOT NULL COMMENT 生产日期, expire_date DATE NOT NULL COMMENT 有效期至, purchase_price DECIMAL(10,2) NOT NULL COMMENT 进价, sale_price DECIMAL(10,2) NOT NULL COMMENT 售价, stock_count INT NOT NULL DEFAULT 0 COMMENT 当前剩余数量, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, KEY idx_drug_id (drug_id), KEY idx_expire_date (expire_date), CONSTRAINT fk_batch_drug FOREIGN KEY (drug_id) REFERENCES drug_info (id) ) ENGINEInnoDB COMMENT 药品批次表;这段 SQL 里三个索引各有用途idx_drug_id 支持按药品查批次idx_expire_date 为近效期预警提供索引外键保证批次不会指向不存在的药品。DECIMAL(10,2) 中 10 是总位数、2 是小数位上限 99999999.99 元课程设计完全够用。如果后面要做近效期提醒expire_date 这个索引就是 SQL 能走索引而不是全表扫描的前提。3.2 初始化数据把演示路径先打通除了建表初始化脚本里至少要有两个演示用户一个 admin 一个 operator供应商两家药品五种。每种药品最好造两个批次——一个临期、一个正常这样近效期预警功能才有数据可显示而不是空表跑不出效果。用户表的初始化语句如下INSERT INTO sys_user (username, password_hash, role) VALUES (admin, 25d55ad283aa400af464c76d713c07ad, ADMIN), (operator, 25d55ad283aa400af464c76d713c07ad, OPERATOR);密码字段不要明文存储课程设计至少做到 MD5。上面 25d55ad283aa400af464c76d713c07ad 是 12345678 的 MD5 值便于评审用统一密码登录体验。自查代码时注意比对登录逻辑里是否也有同一个 MD5 工具类——如果登录代码不加密只查原文那这条 INSERT 语句就没有意义了前后要匹配。3.3 从建表冲突和中文乱码里跳出来服务器上没有测试环境时常见做法是本地起一个 MySQL 8.x 实例或用 Docker 运行 mysql 容器。MySQL 8.0 之后建表不加默认字符集会继承库级别字符集所以建库时没指定表里面就会出现中文变成问号的问题。另一个高频报错是Foreign key constraint is incorrectly formed原因通常有两个父表字段类型不是 BIGINT 而是 INT或父表字段不是索引列。MySQL 要求外键引用列必须是被引表的索引列建表顺序也应该先建父表 drug_info 再建子表 drug_batch否则子表创建时父表尚不存在外键建立直接失败。4. 核心模块的 Java 实现连接管理、批次出入库与库存预警4.1 分层与包结构不引 Spring 也能写规整标题里写的是 JavaWeb意味着可以不引 Spring用 Servlet JSP JDBC 完成。搭一个清晰的分层结构会让代码路径好查常见分包是 entity实体类、dao数据访问、service业务、servlet控制器JSP 放到 webapp 目录下。数据库连接单独封装在一个 DBUtil 类里用静态代码块加载驱动避免每个 DAO 里重复写连接代码。public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/drug_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }URL 里 serverTimezoneAsia/Shanghai 解决 MySQL 8.x 连接时报 invalid timezone 的问题useUnicodetrue 和 characterEncodingutf8 解决中文乱码这两个参数和建库时的 utf8mb4 是一套组合拳。课程设计阶段用 DriverManager 完全够不必上连接池但答辩时主动说明正式环境应该换成 HikariCP 或 Druid属于明显的加分表达。4.2 药品列表的分页查询SQL 里 LIMIT 的两个参数药品列表页面是评审必点的地方。最省事的写法是查出全表在 JSP 里循环当然能跑但数据过百条后页面明显变慢。按数据库分页的写法如下public ListDrugInfo findPage(int pageNum, int pageSize) throws SQLException { String sql SELECT id, generic_name, spec, producer, approval_no FROM drug_info ORDER BY id LIMIT ?, ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, (pageNum - 1) * pageSize); ps.setInt(2, pageSize); try (ResultSet rs ps.executeQuery()) { ListDrugInfo list new ArrayList(); while (rs.next()) { DrugInfo d new DrugInfo(); d.setId(rs.getLong(id)); d.setGenericName(rs.getString(generic_name)); // 其余字段赋值略 list.add(d); } return list; } } }LIMIT 两个参数第一个是偏移量第二个是限制条数。pageNum1 时 offset0pageNum2 时 offset10这是最常见的 MySQL 分页写法。另一个容易踩的坑是用字符串拼接方式构造 SQL那必然在 SQL 注入上翻车所有动态条件都通过 PreparedStatement 的占位符传入。这里每个字段从 ResultSet 取出来后 set 进实体对象对应 JSP 侧再去 getter。4.3 批次出库与入库事务到底在哪一层存销系统的核心不是查询是库存扣减。药品出库时同时发生两件事向 sale_out 表插入一条销售记录把 drug_batch 对应批次的 stock_count 减一。如果这两条 SQL 之间程序抛了异常库里就会出现销售单有了但库存没减的数据不一致。事务的边界必须在 Service 层不能在 DAO 层。DAO 的职责是单条 SQL 的执行而事务跨多个 DAO 方法所以 Service 方法里获取连接、关闭自动提交、执行多个 DAO 操作、统一提交或回滚public boolean saleOut(SaleOutForm form) throws SQLException { Connection conn DBUtil.getConnection(); boolean success false; try { conn.setAutoCommit(false); saleOutDao.insert(conn, form); batchDao.reduceStock(conn, form.getBatchId(), form.getQuantity()); conn.commit(); success true; } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); } return success; }注意 DAO 方法签名里要接收 Connection 参数不能在方法内部再 new 连接否则事务根本不在同一个连接上提交回滚就全乱套。回滚后要重新设为 autoCommittrue否则连接没有连接池回收机制还好一旦引入连接池这个连接下次被借出时所有操作都会卡在隐式事务未提交状态这是答辩现场特别常见的隐性 bug。4.4 库存预警与近效期提醒SQL 里的时间计算库存预警分为存量预警和近效期预警。存量预警的阈值可以写在药品表里做成字段也可以做成全局参数。近效期预警则是一个纯粹的时间比较查找 expire_date 距今 90 天以内的批次。MySQL 里当前日期加 90 天可以用 DATE_ADD写成查询语句如下SELECT b.id, b.batch_no, d.generic_name, b.expire_date, b.stock_count FROM drug_batch b JOIN drug_info d ON b.drug_id d.id WHERE b.expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) ORDER BY b.expire_date ASC;BETWEEN 的边界是闭区间刚好 90 天整的批次也能查出来业务输出在 JSP 中是红色高亮表格。若需要把已过期的批次也标出来把条件拆成两个分支展示过期的用灰色近 90 天到期的用红色。如果只想显示存量不足WHERE 子句加一个AND b.stock_count 20阈值放常量类不硬编码在 JSP 里。5. 登录会话与页面层Filter 拦截和 JSTL 的边界5.1 登录逻辑与 Session 拦截是怎么配合的登录是第一个入口。用户在 login.jsp 输入账号密码提交到 LoginServlet先做 MD5 加密再查库比对成功后把用户 ID 和角色写进 session重定向到首页。这里扣分点通常有两个密码明文传参直接查询数据库以及登录成功后用 forward 转发导致刷新页面时表单重复提交。登录成功应该用resp.sendRedirect(index.jsp)而不是request.getRequestDispatcher(...).forward(...)。防止未登录访问其他页面最轻量的做法是写一个 Filter 拦截所有路径放行静态资源和登录相关路径WebFilter(/*) public class AuthFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String uri req.getRequestURI(); if (uri.endsWith(.css) || uri.endsWith(.js) || uri.endsWith(.png)) { chain.doFilter(request, response); return; } Object user req.getSession().getAttribute(loginUser); if (user null !uri.endsWith(login.jsp) !uri.endsWith(login)) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }Filter 放行静态资源必须写在最前面否则 CSS 加载不出来页面会变得很难看。Session 过期时间在 web.xml 里配置为 30 分钟比较合适低于这个值用户演示时稍一停顿就被踢出登录页高于这个值又不符合课程设计的安全预期。另外注意 Filter 里判断的/login路径要和 LoginServlet 的 WebServlet 映射保持完全一致大小写和斜杠都不能错。5.2 JSP 页面里 JSTL 与 EL 表达式各管什么JSP 页面上的循环和条件判断用 JSTL不要写脚本片段。现在课程设计明确扣分 JSP 里的 Java 代码因为那意味着逻辑和视图没分离。页面顶部声明 taglib列表渲染代码如下% taglib prefixc urijakarta.tags.core % c:forEach items${page.list} vardg tr td${dg.id}/td td${dg.genericName}/td td${dg.spec}/td td${dg.approvalNo}/td tda hrefdrugServlet?actioneditid${dg.id}编辑/a/td /tr /c:forEach这段代码对应上一节的 findPage 返回结果Servlet 通过request.setAttribute(page, page)放进请求域。vardg是当前循环元素的别名${dg.genericName}调用 DrugInfo 实体类的 getGenericName()。如果 EL 表达式取不到值优先怀疑实体类属性名没有遵循驼峰命名或者 getter 方法不存在。JSTL 的 uri 取决于容器版本Tomcat 9 用http://java.sun.com/jsp/jstl/coreTomcat 10 之后才迁移到 jakarta.tags.core这个差异是新手最容易疑惑的地方。5.3 表单校验放前端还是后端前端用 JavaScript 做非空校验体验最好但它是纯展示层的绕过页面直接提交参数就拦不住。后端 Service 里必须再做一次非空和数值范围校验比如出库数量不能大于库存、销售单价必须大于 0、有效期不能早于生产日期。后端校验代码最终会在答辩时被直接观看所以不要只依赖前端。一个建议是写一个独立的校验工具类把每种校验做成静态方法Service 里调用后返回错误信息集合统一在 JSP 里通过c:if展示。这些细节决定了这个项目是从增删改查走向工程化的小门槛。6. Maven 打包部署与答辩前验证一份核对清单6.1 pom.xml 最小依赖集与版本陷阱课程设计项目如果没有 Maven直接拷 lib 目录也可以跑但跨机器时 lib 里的 jar 容易丢失。pom.xml 最小依赖集是三件套servlet-api 编译期提供、jstl、mysql-connector-j。注意 servlet-api 的 scope 必须是 provided因为 Tomcat 自带 Servlet 容器实现打包进去反而导致冲突。dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjstl/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency如果工程是 Java 11 以上且 Tomcat 是 9.xjavax.servlet 前缀匹配没问题如果换 Tomcat 10这部分要改成 jakarta.servlet命名空间完全不同。启动报 ClassNotFound 时先看 Tomcat 主版本再决定改哪份依赖。Maven 的 finalName 改成 drug 可以在打包后得到一个干净路径的 war 包避免访问地址里带一串下划线后缀。6.2 部署步骤与端口报错排查在 IntelliJ IDEA 里执行 mvn package控制台出现 BUILD SUCCESS 后把 target 目录下 war 包拷到 Tomcat 的 webapps 目录启动 bin/startup.bat。浏览器里访问http://localhost:8080/drug/login.jsp看到登录页就算部署成功。如果 8080 被占用Windows 上先跑netstat -ano | findstr :8080找到占用进程再处理。如果页面报 404优先看 war 包名和访问路径是否一致报 500 就翻 Tomcat 的 logs 目录下 localhost.yyyy-MM-dd.log根据堆栈去定位不要去看 catalina.out那个文件里全是启动日志真正的请求异常在 localhost 日志里。6.3 答辩前跑通三组数据校验路径演示之前把这三条路径各走一遍任何一条失败都能在十分钟内定位到具体层。第一admin 登录后做一次入库供应商选国药控股药品选阿莫西林胶囊批次号填一个不重复值数量填 100提交后去 drug_batch 表确认 stock_count 变成 100。第二用 operator 账号登录把同一批次出库 30回看 drug_batch 表确认剩余 70。第三把某批次的有效期改成两个月后回到库存列表看近效期列是否高亮或直接执行近效期 SQL 看返回行数是否覆盖该批次。三条路径全通说明数据库连接、权限控制、事务提交、时间计算四处核心逻辑都处于可交付状态。如果出库后库存变负查 reduceStock 的 SQL 是否带 stock_count ? 条件如果近效期列表为空先看 JDBC URL 的 serverTimezone 是否设置再在 MySQL 端执行SET time_zone 8:00对齐时区。这三组验证做完这套系统交付出去基本不会在现场翻车。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →