简介:面向计算机相关专业期末大作业与毕业设计场景,JavaWeb医药管理系统项目提供完整可运行的源代码与数据库脚本,涵盖药品、客户、机构、采购等典型业务模块,既可作为课程设计蓝本,也可用于JavaWeb分层开发的实战练习,难度适中。资源共119个文件,压缩包约3.01MB,核心为33个java源码、30个jsp页面与33个class编译文件,辅以4个jar依赖库、1个sql数据库脚本及若干xml配置;其中jsp负责前端页面展示,java处理业务逻辑,sql脚本包含建表语句与初始数据,可快速搭建数据库环境。项目经导师指导并评审98分,所有源码均本地编译调试通过,导入开发环境即可直接运行。目前已有94人浏览学习,学习者可围绕医药管理场景进行二次扩展,快速完成可演示的高分作业。
1. JavaWeb 医药管理系统:为什么这个期末作业值得当成真实项目做
如果你是计算机相关专业的学生,期末拿到“JavaWeb 医药管理系统”这个题目,大概率第一反应是:又是一个 CRUD 增删改查,把用户、药品、供应商、订单这几张表拼一拼,交差完事。但说实话,这个题目恰恰是所有 JavaWeb 课程设计里最接近真实企业项目的类型之一——医药行业有严格的库存批次、有效期、处方药管控逻辑,数据库表之间的约束比“学生-选课”复杂得多。把它做深一点,你在简历上写“医药管理系统”,面试官问你库存批次怎么设计的、药品过期预警怎么做的、多表关联查询的性能怎么优化,你都能答上,这才叫高分项目。
这篇笔记直接给你一套可复现的落地路径:从技术选型、数据库设计、核心功能实现,到那些容易让你翻车的细节,比如日期格式导致的前后端数据不一致、MySQL 连接驱动版本不匹配、Tomcat 虚拟路径配置失效——我会把每个环节的坑点一次性讲清。适合三类人:准备期末答辩的学生、想把这个项目写进简历的应届生、以及想快速搭一套医药进销存练手的人。读完你不需要再查第二篇帖子,直接照着做就行。
2. 技术选型与项目结构:为什么用 JSP+Servlet 而不是 Spring Boot
2.1 期末场景下的技术栈取舍
很多人在做 JavaWeb 大作业时会纠结:要不要直接用 Spring Boot?我的建议是分情况。如果老师明确要求 SSM、Spring Boot,那当然用;但如果只写了“JavaWeb”,我一般会选 JSP + Servlet + JDBC 加三层架构。原因有三:第一,期末答辩时老师大概率会盯着你问“Servlet 生命周期是什么、Request 和 Response 怎么回事、JDBC 的 PreparedStatement 防注入原理”,你用 JSP+Servlet 能答得上来,换成 Spring Boot 这些底层细节容易一问三空;第二,代码是你一行行写的,数据库连接、事务提交、请求转发这些“黑匣子”你全见过,翻车时能自己定位;第三,这个组合代码量适中,改起来灵活,后期想升 Spring Boot 也平滑。
但这不意味着 Servlet 类里直接写 JDBC、JSP 里嵌 Java 脚本。高分项目的底线是三层架构:表现层用 JSP,控制层用 Servlet,业务层是 service 接口加实现类,数据层是 dao 接口加实现类,再加一个 JDBCUtils 工具类管理连接。这个结构哪怕只有几百行代码,答辩时也能撑住“可维护性、可扩展性”这种问题。我这里给出一份目录参考,你按这个搭不会乱。
src/ ├── com.med.system.entity/ // 实体类:User、Drug、Supplier、StockBatch、SaleOrder ├── com.med.system.dao/ // 数据访问接口 │ └── impl/ ├── com.med.system.service/ // 业务逻辑接口 │ └── impl/ ├── com.med.system.servlet/ // 控制层:登录、药品管理、库存预警、订单处理 ├── com.med.system.util/ // DBUtils、MD5工具、分页工具 └── com.med.system.filter/ // 登录拦截、字符编码过滤器 webapp/ ├── static/ // css、js、images ├── WEB-INF/ │ ├── web.xml │ └── lib/ // mysql-connector-java-8.0.x.jar └── jsp/ // login.jsp、drugList.jsp、stockBatch.jsp、saleOrder.jsp2.2 依赖包和运行环境怎么配
一个最常见的翻车点:你下载的 mysql-connector-java 版本和本地 MySQL 版本不匹配,连接时报Public Key Retrieval is not allowed或Cannot create PoolableConnectionFactory。我的搭配是:JDK 8 + Tomcat 8.5/9.0 + MySQL 5.7 或 8.0。MySQL 8.0 必须配mysql-connector-java-8.0.30或更高,驱动驱动类名是com.mysql.cj.jdbc.Driver,同时 JDBC URL 要带时区参数,否则启动即报错:
// DBUtils.java 核心:数据库连接与关闭 package com.med.system.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; public class DBUtils { // MySQL 8.0 必须使用 com.mysql.cj.jdbc.Driver private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; // serverTimezone 不配置会报时区异常 private static final String URL = "jdbc:mysql://localhost:3306/med_system" + "?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "你的数据库密码"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL驱动加载失败,检查lib目录"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } // 统一的资源关闭方法,避免连接泄漏 public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }这段代码里的参数说明:useSSL=false是因为本地开发环境不需要加密传输;allowPublicKeyRetrieval=true仅针对 MySQL 8.0 在首次连接时用 caching_sha2_password 认证的场景,不加会报连接失败;serverTimezone必须和你的系统时区保持一致,推荐直接写Asia/Shanghai。close方法里的资源释放顺序要注意,先 ResultSet,再 Statement,最后 Connection,这个顺序在连接池场景下尤其重要——如果只关闭了 Connection 而没关 Statement,MySQL 端的游标可能不被及时释放,跑久了连接数飙升。
IDE 配置方面,IntelliJ IDEA 里创建 JavaWeb 项目后,需要把 mysql-connector-java-8.0.30.jar 拷贝到WEB-INF/lib目录,并在 Project Structure 里点一下“Add as Library”。千万别只加到项目的 External Libraries——Tomcat 运行时用的 classpath 是WEB-INF/lib,IDEA 的编译 classpath 和运行 classpath 是两回事。很多同学本地编译通过、一部署到 Tomcat 就报ClassNotFoundException: com.mysql.cj.jdbc.Driver,问题就在这。
3. 数据库设计:药品库存和处方药是区分低分与高分的关键
3.1 从零设计七张核心表:字段、类型与约束
医药管理系统的数据库不能只有“药品表”和“订单表”这么简单。医药行业有两个特殊点:同一批药品有批次号和有效期;药品分处方药和非处方药,处方药销售需要记录开方医生。这两个点在课程设计里做出来,立刻比同组同学高一个档次。我一般会设计如下七张表:用户表(user,用来定义管理员/药师/收银员三种角色)、药品分类表(drug_category)、药品表(drug,含批准文号、药品类型 PID 标记)、供应商表(supplier)、采购入库的库存批次表(stock_batch,带批号和有效期)、销售订单表(sale_order),以及订单明细表(order_item)。
创建数据库脚本的核心部分如下,注意我把外键约束建在了表末而非定义时,这样建表顺序可以灵活调整,避免循环依赖问题:
-- 创建数据库(utf8mb4 比 utf8 更安全,避免生僻字乱码) CREATE DATABASE IF NOT EXISTS med_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE med_system; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(255) NOT NULL COMMENT 'MD5加密后的密码', real_name VARCHAR(50) COMMENT '真实姓名', role VARCHAR(20) NOT NULL DEFAULT 'CASHIER' COMMENT 'ADMIN/CASHIER/PHARMACIST', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE drug_category ( id INT PRIMARY KEY AUTO_INCREMENT, category_name VARCHAR(50) NOT NULL UNIQUE, description VARCHAR(200) ); CREATE TABLE drug ( id INT PRIMARY KEY AUTO_INCREMENT, drug_code VARCHAR(50) NOT NULL UNIQUE COMMENT '药品code,如国药准字', drug_name VARCHAR(100) NOT NULL, category_id INT, spec VARCHAR(50) COMMENT '规格,如0.25g*24粒', unit VARCHAR(10) COMMENT '单位:盒/瓶/袋', purchase_price DECIMAL(10,2) COMMENT '采购价', sale_price DECIMAL(10,2) COMMENT '零售价', is_prescription TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=处方药,0=OTC', manufacturer VARCHAR(100), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_drug_category FOREIGN KEY (category_id) REFERENCES drug_category(id) ); CREATE TABLE supplier ( id INT PRIMARY KEY AUTO_INCREMENT, supplier_name VARCHAR(100) NOT NULL, contact_person VARCHAR(50), phone VARCHAR(20), address VARCHAR(200) ); CREATE TABLE stock_batch ( id INT PRIMARY KEY AUTO_INCREMENT, drug_id INT NOT NULL, batch_no VARCHAR(50) NOT NULL COMMENT '入库单号/批号', supplier_id INT, quantity INT NOT NULL DEFAULT 0 COMMENT '入库数量', remain_quantity INT NOT NULL DEFAULT 0 COMMENT '剩余库存', production_date DATE, expire_date DATE NOT NULL COMMENT '过期时间', purchase_price DECIMAL(10,2), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_stock_drug FOREIGN KEY (drug_id) REFERENCES drug(id), CONSTRAINT fk_stock_supplier FOREIGN KEY (supplier_id) REFERENCES supplier(id) ); CREATE TABLE sale_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(50) NOT NULL UNIQUE, cashier_id INT NOT NULL COMMENT '收银员', doctor_name VARCHAR(50) COMMENT '处方药必须填开方医生', total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, pay_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_order_user FOREIGN KEY (cashier_id) REFERENCES user(id) ); CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, drug_id INT NOT NULL, batch_id INT NOT NULL COMMENT '出库批次', quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT '成交单价', CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES sale_order(id), CONSTRAINT fk_item_drug FOREIGN KEY (drug_id) REFERENCES drug(id), CONSTRAINT fk_item_batch FOREIGN KEY (batch_id) REFERENCES stock_batch(id) );关键设计说明:第一,stock_batch和order_item之间通过batch_id关联,这样卖药时能精确知道是哪一个批次出的货,过期预警直接对expire_date做条件查询;第二,product drug.is_prescription和sale_order.doctor_name配合,前端页面根据是否处方药动态显示开方医生输入框,后端 Servlet 再做一次非空校验——这是业务闭环,答辩老师问到能讲很久;第三,金额类型全部用DECIMAL(10,2),别用 float/double。float 在 MySQL 里是近似值,算总价时出现过0.1+0.2=0.30000000000000004这种尴尬情况,期末项目里这是硬伤。外键约束建议保留,虽然很多人为了性能不用,但课程设计里外键能帮你维持数据完整性——比如某个药品还在订单明细里就不能直接删除,开不开级联删除需要认真想。
3.2 初始化数据与 SQL 脚本参数
建完表还需要初始化数据,否则登录页面都进不去。我一般创建 admin 账号(密码在 Java 里用 MD5 加密),并预填 6 到 8 条药品数据,其中至少两条批次接近过期,这样库存预警页面一打开就有真实效果。初始化 SQL 注意密码字段必须是 MD5 散列后的值,不是明文——你可以先在 Java 里写一句System.out.println(MD5Utils.md5("admin"))把结果打出来,再贴进 INSERT 语句。这样除了让项目更真实,也避免答辩时被问“密码为什么能明文存储在数据库里”。
各表之间关键字段的关联逻辑,我给个表格方便你快速对照:
| 核心字段 | 关联方向 | 说明 |
|---|---|---|
| drug.category_id → drug_category.id | 多对一 | 药品属于哪个分类 |
| stock_batch.drug_id → drug.id | 多对一 | 同一药品可多次入库,产生多个批次 |
| stock_batch.supplier_id → supplier.id | 多对一 | 每个批次由哪个供应商供货 |
| sale_order.cashier_id → user.id | 多对一 | 记录销售操作员 |
| order_item.batch_id → stock_batch.id | 多对一 | 关键设计:锁定具体出货批次 |
4. 核心功能实现:登录、药品 CRUD、入库批次、销售结账
4.1 登录与登录拦截:用 Filter 解决“登录后才能访问”问题
登录模块看似简单,却是很多人丢分的地方——因为只实现了登录验证,没做访问控制,导致用户直接在浏览器地址栏输入drugList.jsp就能跳过登录。高分做法是结合 Session 和 Filter,做一个登录拦截器。web.xml里配置一个 filter 映射覆盖所有路径,然后在 Filter 里检查 Session 中是否存在登录用户;如果不存在,重定向到登录页。注意放行login.jsp、loginServlet和静态资源,否则会造成死循环。
<filter> <filter-name>AuthFilter</filter-name> <filter-class>com.med.system.filter.AuthFilter</filter-class> </filter> <filter-mapping> <filter-name>AuthFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>再在 AuthFilter 里做校验:
package com.med.system.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; // 直接注解方式,省去部分 XML 配置;两者同时存在时注意别重复注册 @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("login.jsp") || uri.endsWith("loginServlet") || uri.contains("/static/") || uri.endsWith(".css") || uri.endsWith(".js")) { chain.doFilter(request, response); return; } HttpSession session = req.getSession(false); if (session != null && session.getAttribute("loginUser") != null) { chain.doFilter(request, response); } else { resp.sendRedirect(req.getContextPath() + "/jsp/login.jsp"); } } }这段代码需要注意两点:一是getSession(false)不要传 true,getSession(true)或直接getSession()会强制创建一个新 Session,导致未登录用户也会拿到一个空 Session 然后被误判为已登录;二是sendRedirect前面必须加req.getContextPath(),否则项目部署在带 context-path 时(比如/med),重定向会跳错到根路径下找不到页面。启动 Tomcat 后如果发现点击任何菜单都被弹回登录页,首先检查是不是静态资源被拦截了——/static/前缀必须放行,否则 CSS、JS 全部加载失败,页面样式全乱,会误判成路径问题。
4.2 药品管理的 CRUD 与分页:每个表单独一个 Servlet + Service
药品管理是整个系统的核心操作界面。我习惯让每个实体对应一个 Servlet,例如DrugServlet使用action参数(action=add、action=update、action=delete、action=list)来分发请求,避免为每个操作各建一个 Servlet,导致 web.xml 里全是映射。Service 层接口以DrugService为例,定义一个接口加一个DrugServiceImpl实现类,是为了三层架构的松耦合要求——虽然期末项目不大,但接口和实现分离是老师判断“规范分”的重要依据。
药品列表页必须加分页,这是一眼能看出项目成熟度的功能点。分页本质上就是两个 SQL:一个查总记录数SELECT COUNT(*),另一个查当前页数据LIMIT ?, ?。参数说明:pageNo默认为 1(第一页,不是 0),pageSize每页显示 10 条。用PreparedStatement时注意,LIMIT 的参数要从(pageNo-1)*pageSize开始,不然第二页会重复第一页的数据。JSP 里做一个分页条,包含“上一页、下一页、第 X/Y 页”,再将pageNo通过href拼到DrugServlet?action=list&pageNo=当前页-1上。
// DrugDaoImpl.java 中分页查询的关键方法 public List<Drug> findPage(int pageNo, int pageSize) { List<Drug> list = new ArrayList<>(); String sql = "SELECT d.*, c.category_name FROM drug d " + "LEFT JOIN drug_category c ON d.category_id = c.id " + "ORDER BY d.id DESC LIMIT ?, ?"; try (Connection conn = DBUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, (pageNo - 1) * pageSize); ps.setInt(2, pageSize); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Drug drug = new Drug(); drug.setId(rs.getInt("id")); drug.setDrugCode(rs.getString("drug_code")); drug.setDrugName(rs.getString("drug_name")); drug.setCategoryName(rs.getString("category_name")); drug.setSpec(rs.getString("spec")); drug.setSalePrice(rs.getBigDecimal("sale_price")); list.add(drug); } } } catch (SQLException e) { e.printStackTrace(); } return list; }这里用了 JDK 7 的 try-with-resources 语法,Connection、PreparedStatement、ResultSet都自动关闭,省去手动close的繁琐。LEFT JOIN代替JOIN的原因:万一药品还没分配分类,JOIN会把记录过滤掉,页面就少了一条药。业务上药品不允许无分类,但防御性地用LEFT JOIN能避免“显示数量对不上”的难排查问题。ORDER BY d.id DESC让新入库的药品显示在最前,方便收银员在销售页面快速找到常用药。
4.3 入库与销售:事务管理是高分命门
入库和销售是整套系统里最难的部分。入库操作有三步:往stock_batch插入新批次、给drug表同步更新库存数量、在sale_order和order_item里记录明细并扣减库存——任何一步失败,要么库存对不上,要么订单金额算错,必须把三步放到同一个事务里。底层事务控制在 dao 层做,因为service层拿到了同一个Connection时才能保证事务。我自己常用的做法是:在 service 方法内部DBUtils.getConnection(),然后conn.setAutoCommit(false),三步操作都成功后commit(),任何一步异常则rollback(),最后在finally里把autoCommit恢复为 true 再关闭连接。注意:不能直接finally { conn.close() }后让容器回收一个autoCommit=false的连接,因为后续该连接被复用时会继承非自动提交状态,新操作的修改不落库。
销售开单的 service 层方法,代码骨架如下:
// SaleService.java 销售与扣库存,一个事务完成 public boolean createSaleOrder(SaleOrder order, List<OrderItem> items) { Connection conn = null; try { conn = DBUtils.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 // 1. 插入订单主表 String insertOrder = "INSERT INTO sale_order(order_no, cashier_id, doctor_name, total_amount) VALUES(?,?,?,?)"; try (PreparedStatement psOrder = conn.prepareStatement(insertOrder, PreparedStatement.RETURN_GENERATED_KEYS)) { psOrder.setString(1, order.getOrderNo()); psOrder.setInt(2, order.getCashierId()); psOrder.setString(3, order.getDoctorName()); psOrder.setBigDecimal(4, order.getTotalAmount()); psOrder.executeUpdate(); ResultSet keys = psOrder.getGeneratedKeys(); int orderId = 0; if (keys.next()) { orderId = keys.getInt(1); } // 2. 插入订单明细,并扣减对应批次库存 for (OrderItem item : items) { String insertItem = "INSERT INTO order_item(order_id, drug_id, batch_id, quantity, price) VALUES(?,?,?,?,?)"; try (PreparedStatement psItem = conn.prepareStatement(insertItem)) { psItem.setInt(1, orderId); psItem.setInt(2, item.getDrugId()); psItem.setInt(3, item.getBatchId()); psItem.setInt(4, item.getQuantity()); psItem.setBigDecimal(5, item.getPrice()); psItem.executeUpdate(); } // 判断批次剩余库存是否足够 int remain = queryRemainQuantity(conn, item.getBatchId()); if (remain < item.getQuantity()) { throw new RuntimeException("批次库存不足" + item.getBatchId()); } String updateStock = "UPDATE stock_batch SET remain_quantity = remain_quantity - ? WHERE id = ? AND remain_quantity >= ?"; try (PreparedStatement psStock = conn.prepareStatement(updateStock)) { psStock.setInt(1, item.getQuantity()); psStock.setInt(2, item.getBatchId()); psStock.setInt(3, item.getQuantity()); int rows = psStock.executeUpdate(); if (rows == 0) { // 并发场景下 where 条件不满足,扣减失败 throw new RuntimeException("库存扣减失败,可能已被并发操作修改"); } } } } conn.commit(); return true; } catch (Exception e) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { if (conn != null) { try { conn.setAutoCommit(true); } catch (SQLException ignored) {} try { conn.close(); } catch (SQLException ignored) {} } } }这段代码里的三个关键细节是一线项目里常见的坑,期末答辩时讲出来很有说服力。第一,PreparedStatement.RETURN_GENERATED_KEYS拿到自增主键 orderId,再插入明细表完成主外键关联;如果不取生成主键,明细表里的order_id没法填。第二,扣库存时不仅做UPDATE还带了AND remain_quantity >= ?,并检查受影响行数是否为零——直接更新扣减成负数在出现并发时也会执行成功,加了条件才能真正防超卖。UPDATE在受影响行数为 0 时说明条件不满足,必须抛出运行时异常触发回滚。第三,batch_id的存在说明前端在售药时可以选择“先用先进先出的批次”,这就在实现层面真正的批次管理了。
5. 代码实现与页面联调:从 JSP 到 MySQL 的全链路参数与调试
5.1 字符编码、上下文路径和路径风格统一
联调阶段是大量学生最容易浪费时间的环节,因为问题往往不是 SQL 或逻辑错误,而是编码、路径、大小写这些低级问题。先说字符编码:JSP 顶部必须有pageEncoding="UTF-8",Servlet 里必须设置request.setCharacterEncoding("UTF-8"),数据库 URL 里的characterEncoding=utf8也要有。少设任何一个环节,从页面输入“阿莫西林”传到 MySQL 里就变成“???”或“澶氶椤”?。最省心的做法是写一个EncodingFilter,对所有请求统一setCharacterEncoding("UTF-8"),在web.xml里排到最前面。
再有是路径风格统一:JSP 里的 href、form action、图片路径等,统一用上下文无关的写法,避免start with /或者硬编码localhost:8080。比如在 JSP 顶部加<base href="<%=request.getContextPath()+"/"%>">,然后页面里所有链接都写相对路径。否则部署到http://localhost:8080/med/时,你写死href="/drugServlet"会跳到http://localhost:8080/drugServlet,直接 404。
// EncodingFilter.java 统一编码过滤器 package com.med.system.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; @WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); chain.doFilter(request, response); } }5.2 前后端联调:药品销售一个流程常见的 TypeError 与 SQLException
销售模块的前端流程大概是:销售页面sale.jsp加载药品列表,收银员搜索药品并点击“加入购物车”,页面用JavaScript维护一个本地数组,最后提交到SaleServlet。最容易出的问题在“选药品”这一步:每个药品出现在多个批次时,下拉框的值到底该传给哪个batch_id。我建议直接把药品和批次数据一起加载——前端下拉框的value属性设计成"{drugId},{batchId},{price}",提交时用split(",")拆开,分别映射到OrderItem的字段。后端接参的方式统一用request.getParameter("drugId_" + index)这种形式,把动态行数限制在 20 以内,避免出现参数名复杂导致的取值为空。
联调时最值得注意的报错是SQLSyntaxErrorException: You have an error in your SQL syntax,这种错误通常不是 SQL 语法本身的问题,而是实体类字段名、JSP 表单控件名和数据库列名映射错位。比如数据库字段drug_name,实体里用drugName,JSP 里写drugname,三方不一致,导致getParameter返回 null 拼进 SQL 后变成NULL语义异常。修复技巧:把java.util.logging或System.out打印出实际 SQL,拿回 Navicat 里执行一次,立刻知道问题在哪。这条血泪经验在毕业设计和工作中都适用——凡是 SQL 报错,第一步永远是把最终执行的 SQL 打出来。
5.3 数据库连接驱动和 Tomcat 部署的坑:连接池、版本冲突、项目发布
Tomcat 部署环节有三个高频翻车点。第一个是 jar 包放错位置:驱动 jar 必须放在WEB-INF/lib下,而不是 Tomcat 根目录的lib下。如果你把 Tomcat 当作独立开发环境使用,同时运行多个 JavaWeb 项目,common.loader会把这枚 jar 暴露给所有项目,互相干扰版本。第二个是端口冲突:8080被占用导致 Tomcat 启动报Port already in use。解决方法是找到占用进程并处理掉,或者改 Tomcat 的server.xml端口号。第三个是 IDEA 中Build Artifacts配置错误,修改代码后运行还是旧版页面——因为 IDEA 的“exploded”部署没有自动把最新编译产物拷贝到 Tomcat 的webapps目录。处理办法是在 Deployment 里选择“Hot Swap”或手动Build > Rebuild Project,确认out/artifacts/med_war_exploded目录时间戳更新了再启动。
Apache Tomcat 9 用 Servlet 4.0 规范,支持@WebServlet("/drugServlet")注解,但要注意:如果使用注解方式在 IDEA 中默认部署,还需要在 Project Structure 里确认web.xml里的metadata-complete设为false或直接不配置该项,否则 Servlet 注解不被扫描,页面全报 404。很多人的项目明明有DrugServlet,访问却 404,八成是这个问题——看着不起眼,能让整个项目“看似正常但哪里都进不去”。
6. JavaWeb 医药管理系统必踩的 8 个坑与排查方案
做这类 JavaWeb 课程设计,多数时间是花在查错和改错上。下面这八条是我整理的高频踩坑记录,按“现象→原因→解决”的结构列出来,基本覆盖了一个期末项目从开发到部署的全部过程。
1. 现象:Tomcat 启动即报ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因:jar 包不在运行时 classpath 里。很多同学只把 jar 添加到 IDEA 的 Libraries,忘了同时放入WEB-INF/lib。解决:把mysql-connector-java-8.0.30.jar拷贝进webapp/WEB-INF/lib/,然后File > Project Structure > Artifacts,确认 Output Layout 里包含lib下这个 jar,最后Rebuild一次再重启 Tomcat。
2. 现象:登录时密码正确但一直提示密码错误,或数据中文全部变成问号。原因:密码在数据库里存 MD5 值,但登录代码里没做散列直接比对明文;中文乱码是编码过滤器缺失或类没加载。解决:登录 SQL 查询前先MD5Utils.md5(inputPassword)再拼进查询条件;编码问题则确认EncodingFilter配置生效并在web.xml各 filter 中排列在第一位。数据库连接 URL 再补上characterEncoding=utf8。
3. 现象:数据库里药品列表能查出数据,但页面显示“加载失败”。原因:常见是实体类没有实现Serializable,或 JSP 里用了 EL 表达式取list[0].drugName但属性名拼写错误,也可能是getter/setter缺失。解决:实体类加implements Serializable,IDEA 里按Alt+Insert生成属性对应的 getter/setter;如果 Sun 与 Java 命名规范有出入(比如isPrescription的 getter 为getIsPrescription),统一用表结构做对照。
4. 现象:销售下单时提示“库存不足”,但数据库里remain_quantity明明有库存。原因:stock_batch里remain_quantity是批次剩余,不是药品总剩余。如果你在下单时检查的是药品总库存而非批次剩余,自然会出现误报。解决:始终使用表单中传回的batch_id去查该批次的remain_quantity,不要用 drug 表里的stock字段判断。多种校验逻辑统一放在SaleService事务里做,避免前端校验通过、后端却没再判一次。
5. 现象:列表页面出现 SQL 语法错误,但 SQL 单独在 Navicat 里执行又是对的。原因:JSP 的 SQL 字符串里直接拼接了用户输入,药品名里含单引号、百分号,导致 SQL 结构被破坏。解决:所有参数一律用PreparedStatement占位符。这条既是性能考虑,也是安全底线。页面搜索框输入' OR '1'='1能炸出全部数据,答辩时可以主动提及你已经做了防 SQL 注入。
6. 现象:el表达式在页面里原样输出${drugName}不解析。原因:JSP 顶部漏掉了<%@ page isELIgnored="false" %>。这是许多教材的旧习惯导致——老版本 JSP 默认忽略 EL,新版本部分项目配了isELIgnored=true。解决:新建 JSP 时统一用Eclipse 的 JSP 模板或手动在页面顶部写明isELIgnored="false",直接避免这个坑。
7. 现象:Tomcat 启动正常,但浏览器访问 URL 返回 404,项目名 path 不对。原因:IDEA 部署路径和访问路径不一致,或context path写死成了绝对路径。解决:Run Configuration > Deployment > Application context设置为/med,浏览器用http://localhost:8080/med/jsp/login.jsp访问。若页面里的 href 全是以<base href>开头的相对路径,修改 context path 后所有链接依然可用。
8. 现象:数据库连接过多导致 MySQL 报Too many connections,重启后正常、跑一会儿又崩。原因:项目里没有使用连接池,且DBUtils.close()在异常路径里没被调用——比如try块里抛了异常直接跳出finally里的关闭被跳过。解决:严格使用finally关闭资源,同时把DBUtils中的Connection换成druid连接池。期末项目即便不用连接池,也要保证正常流程和异常流程都关闭 Connection,这是最基础的要求。
以上八条,每一条都来自实际开发和问答时的血泪经验。项目做到最后,你基本能证明自己具备“独立解决 JavaWeb 常见问题”的能力,这比多写一个功能面对老师追问时的价值大得多。
7. 进阶验证技巧:如何让评分老师和面试官一眼认定这是高分项目
到这一步功能已经齐全,但“高分”与“及格”的分水岭往往在收尾工作。我建议上线前做三个层面的验证和优化,每个都能讲出可说的故事。
第一个是“权限粒度验证”。目前只有登录拦截,但管理员、收银员、药师看到的功能菜单应该不同——管理员能看到用户管理和供应商管理,收银员只能看到销售页面和库存查询。实现方式不复杂:在 JSP 菜单标签外用if (role == 'ADMIN')控制渲染内容,在 Servlet 里加一个PermissionFilter或直接用注解做方法级检查。实际做法可以更简单——在AuthFilter里校验当前访问路径与角色是否匹配,例如/userServlet只允许 ADMIN 访问。只要加几行代码,就比同组“所有登录用户看到同一套界面”的人高一个层级。
第二个是“过期预警周期化展示”。常态做法是把剩余有效期三个月内的药品列在一个专门页面,并在登录首页用数字标记“近效期药品 N 种”。下单时如果某个药品批次已经过期,后端要直接拒绝销售或弹出强提示。这一个细节能体现你对行业业务的理解——医院药房绝不能把过期药卖给患者。实现时可以在StockBatchDao里加一条查询:
public List<StockBatch> findExpiringBatches(int thresholdDays) { String sql = "SELECT b.*, d.drug_name FROM stock_batch b " + "JOIN drug d ON b.drug_id = d.id " + "WHERE b.remain_quantity > 0 " + "AND b.expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) " + "ORDER BY b.expire_date ASC"; // 阈值可调:90天近效期 或 30天紧急预警 }需要注意BETWEEN和DATE_ADD中的参数绑定——INTERVAL ? DAY在 MySQL 的 PreparedStatement 中可以直接绑定数字参数,但如果写成INTERVAL '?' DAY会语法错误。这条 SQL 就是整个“库存预警功能”的数据源头。
第三个是“买一送一的演示数据集”。在数据库初始化脚本里故意构造一批场景数据:一款 OTC 药库存充足,一款处方药缺少doctor_name记录,一条批次过期,一个供应商电话为空。带着这套演示数据参与答辩,打开页面给老师看:这个地方有近效期预警,那个地方新增处方药时强制填医生;再看异常场景中系统没有崩,而是正确报错。这比临时返回新增一条“演示数据”更有说服力。
结个尾:我的习惯是每次做完这类课程设计,都会把整个项目打一个 zip 包,并在README.md里写清三件事——用的 Java 版本、MySQL 版本、Tomcat 版本,和数据库导入步骤。因为“代码跑不起来”是这个项目最大的减分项,等老师或面试官拿到手时,让他零成本跑起来就是成功的一半。期末作业的分数和面试的通过率,往往都不取决于功能多少,而是取决于“你是否真正理解系统设计和那些别人没注意到的坑”。能走到这一步,就已经是同类作品里的高分了。希望这篇笔记能帮到你少走我当年走过的弯路。
本文还有配套的精品资源,点击获取