简介:基于SSH框架(Struts2、Spring、Hibernate)开发的医院物资管理系统课程设计项目,面向Java Web学习者、高校在校生以及需要完成类似毕业设计或课设的开发者。系统覆盖物资采购、入库、领用、库存查询、盘点、报损报溢、调拨等核心业务,前后端页面与交互逻辑完整,能直观看到登录、主页等模块运行效果;代码结构体现MVC分层、Spring依赖注入与事务管理、Hibernate对象关系映射等关键实践。压缩包共12个文件,包含项目源码rar、SQL数据库脚本、操作演示avi、课程设计报告doc/docx、界面截图jpg/png以及阅读说明txt,整体大小44.81MB,文件类型覆盖源代码、数据库、视频演示和设计文档四类用途。其中报告对需求分析、总体设计、数据库表结构、类图及功能模块划分均有叙述,视频则能辅助快速搭建运行环境并理解操作流程。已有36人学习浏览,适合用来理解SSH整合思路、掌握医院物资管理的业务流程,或直接在此基础上进行功能扩展和二次开发。
1. jsp552 医院物资管理系统(ssh)课程设计在解决什么问题
解压课程设计压缩包,jsp552 只是命名序号,技术栈在内部:JSP 做视图,SSH(Struts2 + Spring + Hibernate)做控制、业务和持久化。这里的 ssh 是三个 Java 框架的缩写,不是连服务器用的 Secure Shell 工具。
系统要解决的场景很具体:科室领物资、库房登台账、月底对账。Excel 管会出现同名物资多条记录、出库后库存对不上、领用人查不到历史流水。落到项目里就是物资维护、入库单、出库单、库存查询、供应商管理、用户登录等模块。
适合做课程设计要找思路的学生,也适合要接手 SSH 老项目的工程师。下文按框架分工、建表与代码、页面查询、部署排错的顺序展开,环境按 JDK 1.8 + Tomcat 8.5 + MySQL 5.7 常见组合来讲。
2. SSH 三框架的分工:从 struts.xml 到 applicationContext.xml 的配置线
课程设计里最常见的翻车点不是功能没写出来,而是三个框架的配置互相咬合不上:Action 找不到、Service 注入失败、Session 提前关闭。先搞清楚每个框架管到哪一层,再对照配置文件排查,定位就快很多。下面按请求从浏览器进入 Struts2 的顺序,把三份核心配置串起来讲。
2.1 Struts2 在物资管理里处理什么:表单到 Action 的流转
Struts2 在 SSH 里负责 MVC 的 C。用户在入库页点提交,浏览器发一个 /material/save.action 请求,StrutsPrepareAndExecuteFilter 拦截后按 struts.xml 映射找到 Action 类和要执行的方法,把表单字段自动填进 Action 对应的 setter,方法返回 success 或 input 字符串,框架再按 result 配置转发到 JSP。课程设计规模下,一个 Action 管一个模块就够,MaterialAction 管物资列表和新增,StockAction 管入库出库的入口。
<struts> <!-- 统一请求编码,post 提交不乱码的第一道保险 --> <constant name="struts.i18n.encoding" value="UTF-8" /> <package name="mms" namespace="/material" extends="struts-default"> <action name="list" class="materialAction" method="list"> <result name="success">/WEB-INF/views/material/list.jsp</result> </action> <!-- 保存成功后重定向到列表,防止 F5 重复提交 --> <action name="save" class="materialAction" method="save"> <result name="success" type="redirectAction">list</result> <result name="input">/WEB-INF/views/material/edit.jsp</result> </action> </package> </struts>namespace 决定访问前缀,/material/list.action 才能命中上面第一个 action。class 写的是 Spring 容器里的 bean 名 materialAction,不是类的全路径,这是 struts2-spring-plugin 的约定;如果项目没集成 Spring 插件而是直接 new 的 Action,这里才写全类名。method 指定执行方法;save 返回 input 表示表单校验未通过,回到编辑页保留用户输入。
2.2 Spring 容器如何把 Action、Service、DAO 串成一个整体
Spring 负责对象的创建和依赖注入。Action 要调 Service,Service 要调 DAO,DAO 要拿到 SessionFactory 才能操作数据库。课程设计普遍用注解扫描加少量 XML:Service 和 DAO 打 @Service、@Repository 注解,数据源、SessionFactory 这类没法用注解描述的对象写进 applicationContext.xml。
<!-- 扫描 com.hospital.mms 下所有带注解的类 --> <context:component-scan base-package="com.hospital.mms" /> <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource"> <property name="driverClassName" value="com.mysql.jdbc.Driver" /> <!-- 注意 & 是 & 的 XML 转义,少一层会解析失败 --> <property name="url" value="jdbc:mysql://localhost:3306/hospital_mms?useUnicode=true&characterEncoding=utf8" /> <property name="username" value="root" /> <property name="password" value="123456" /> </bean> <bean id="sessionFactory" class="org.springframework.orm.hibernate5.LocalSessionFactoryBean"> <property name="dataSource" ref="dataSource" /> <!-- 自动扫描 @Entity 的实体类,省掉 hbm.xml 映射文件 --> <property name="packagesToScan" value="com.hospital.mms.entity" /> <property name="hibernateProperties"> <props> <prop key="hibernate.dialect">org.hibernate.dialect.MySQL5Dialect</prop> <prop key="hibernate.show_sql">true</prop> <prop key="hibernate.hbm2ddl.auto">update</prop> </props> </property> </bean>packagesToScan 只扫实体包,show_sql 打开后控制台会打印 Hibernate 生成的 SQL,排错时最有用;hbm2ddl.auto 的 update 会根据实体自动建表加列,课程设计图省事可以开,生产环境必须改成 validate。url 里的 useUnicode 和 characterEncoding 是中文入库不乱码的关键参数,在 XML 中 & 必须写成 &,这条写错启动时会报连接串解析异常。
| 层次 | 代表类 | 配置位置 | 职责 |
|---|---|---|---|
| 控制层 | MaterialAction | struts.xml | 接收参数、调 Service、决定返回哪个 JSP |
| 业务层 | StockInServiceImpl | @Service 注解 | 事务边界、库存校验等业务规则 |
| 持久层 | MaterialDaoImpl | @Repository 注解 | 用 HQL 或 Criteria 读写数据库 |
| 视图层 | list.jsp | /WEB-INF/views | 用 EL 和 JSTL 展示数据、提交表单 |
2.3 Hibernate 的实体映射与事务边界设在哪一层
实体类和表一一对应,注解映射比配置 hbm.xml 省事。Material 实体的写法是标准样式:@Entity 声明持久化类,@Table 指定表名,@Id 加 @GeneratedValue(strategy = GenerationType.IDENTITY) 对应 MySQL 自增主键。字段上的 @Column 只有列名和字段名不一致时才需要写,materialCode 映射到 material_code 就必须写。
@Entity @Table(name = "material") public class Material { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Column(name = "material_code", length = 20) private String materialCode; @Column(name = "material_name", length = 50) private String materialName; @Column(name = "spec", length = 50) private String spec; @Column(name = "unit", length = 10) private String unit; @Column(name = "stock") private Integer stock; // getter/setter 用 IDE 生成,此处省略 }事务边界要放在 Service 层,Spring 用 HibernateTransactionManager 统一管理,@Transactional 可加在实现类上,也可加在具体方法上。两个常见错误:一是把 @Transactional 加到 DAO 上,Service 调两个 DAO 方法时各自提交,中途失败会出现库存改了、流水没写的脏数据;二是整份配置里没注册 transactionManager,运行时报 Could not obtain transaction-synchronized Session。DAO 里取 Session 统一用 sessionFactory.getCurrentSession(),它跟随事务、事务结束自动关闭,不要用 openSession() 自己关,漏关会一直占着连接池。
3. 医院物资入库出库的建表 SQL 与 Action-Service-DAO 链路
物资管理系统的核心不在页面多少,而在库存怎么变动。设计上守住一条原则:任何入库或出库流水,都必须和库存变动发生在同一个事务里。顺着这个原则建表、写 DAO、写 Service,顺序不容易乱。
3.1 物资、入库单、出库单三张核心表的字段设计
三张表就够:material 记录物资基础信息和当前库存;stock_in 和 stock_out 各记一条变动流水。课程设计不推荐做成订单主子表,入库单一行物资一张单,拆成 header 和 line 两层纯属增加 JOIN,评审也不会因为多两张表加分。建表 SQL 如下,字符字段统一 utf8。
CREATE TABLE material ( id INT AUTO_INCREMENT PRIMARY KEY, material_code VARCHAR(20) NOT NULL COMMENT '物资编码', material_name VARCHAR(50) NOT NULL COMMENT '物资名称', spec VARCHAR(50) COMMENT '规格型号', unit VARCHAR(10) COMMENT '计量单位', stock INT NOT NULL DEFAULT 0 COMMENT '当前库存', supplier_id INT COMMENT '默认供应商ID' ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE stock_in ( id INT AUTO_INCREMENT PRIMARY KEY, material_id INT NOT NULL COMMENT '物资ID', quantity INT NOT NULL COMMENT '入库数量', in_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间', operator VARCHAR(20) COMMENT '经办人', remark VARCHAR(100) COMMENT '备注', KEY idx_material (material_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE stock_out ( id INT AUTO_INCREMENT PRIMARY KEY, material_id INT NOT NULL COMMENT '物资ID', quantity INT NOT NULL COMMENT '出库数量', out_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '出库时间', department VARCHAR(30) COMMENT '领用科室', operator VARCHAR(20) COMMENT '经办人', remark VARCHAR(100) COMMENT '备注', KEY idx_material (material_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;stock 字段用 INT,医疗耗材按最小单位计数,避免出现 0.5 件这种小数。库存冗余在 material 表里而不是靠 SUM 流水算出来,是因为每次查询都扫流水表,数据一多 SQL 越写越重,课程设计里直接维护一个冗余字段最实用。department 和 operator 是后期按科室、按经办人统计的主要筛选条件,长度按实际业务留够。
| 字段 | 类型 | 选择理由 |
|---|---|---|
| material.stock | INT | 按最小单位计数,不做小数盘点 |
| stock_in.in_time | DATETIME | 默认 CURRENT_TIMESTAMP,代码不用手动赋值 |
| stock_out.department | VARCHAR(30) | 科室名长度够用,普通 KEY 支持筛选 |
3.2 入库实现:在一个事务里写流水并增加库存
入库的业务规则一句话:先校验物资存在,再把库存加上去,最后插入一条流水。三个动作必须在同一个事务里,否则要么库存和流水对不上,要么出现只有流水没有库存的脏数据。
@Service("stockInService") @Transactional public class StockInServiceImpl implements StockInService { @Resource private MaterialDao materialDao; @Resource private StockInDao stockInDao; @Override public boolean inbound(StockIn in) { Material material = materialDao.findById(in.getMaterialId()); if (material == null) { return false; // 物资不存在,直接返回失败 } material.setStock(material.getStock() + in.getQuantity()); stockInDao.save(in); // 插入入库流水 return true; } }material 是 Hibernate 的持久态对象,事务提交时 Hibernate 会把当前状态和快照对比,自动生成 update 语句,所以不调 materialDao.update(material) 也能写库;课程设计里为了意图清楚,显式调一次 update 也常见,两种都对。顺序上先改库存再插流水,或反过来,不影响最终一致性,但按先校验、再改动、后记流水读起来最清晰。
@Repository("stockInDao") public class StockInDaoImpl implements StockInDao { @Resource private SessionFactory sessionFactory; private Session getSession() { // 必须配了事务管理器才能拿到当前 Session return sessionFactory.getCurrentSession(); } @Override public void save(StockIn in) { getSession().save(in); } }提示:控制台看到 No Hibernate Session bound to thread 时,先确认 Service 方法上有 @Transactional,再检查 applicationContext.xml 里是否注册了 HibernateTransactionManager,两个条件缺一个都会报这个错。
3.3 出库扣减库存的写法与并发下的库存校验
出库比入库多一个条件:库存够不够。最稳的写法不是先查出来在 Java 里比大小再 update,而是把 m.stock >= :qty 直接写进 update 语句,让数据库在更新那一刻判断。这样即使两个请求同时出库,行锁也只会放行其中一个。
@Override public boolean outbound(StockOut out) { Session session = sessionFactory.getCurrentSession(); String hql = "update Material m set m.stock = m.stock - :qty " + "where m.id = :id and m.stock >= :qty"; int updated = session.createQuery(hql) .setParameter("qty", out.getQuantity()) .setParameter("id", out.getMaterialId()) .executeUpdate(); if (updated == 0) { return false; // 库存不足或物资不存在,事务回滚 } session.save(out); // 插入出库流水 return true; }executeUpdate 返回受影响行数:0 行说明物资不存在或库存不够,返回 false 让事务回滚,不会出现负数库存;1 行说明扣减成功,继续写流水。Action 层拿到 false 之后,往 request 或 session 里存一条“库存不足”提示,再由 result 配置回到列表页。不要在 Service 里为这个场景抛业务异常,课程设计的页面提示用标志位最简单,答辩时能说清 return false 和抛异常的取舍,反而是加分项。
4. JSP 列表页、分页查询与登录拦截的可抄写法
框架通了,剩下的功夫全在视图和数据展示。把最耗时间的三个点写清楚:JSP 放哪里、分页怎么做、登录怎么拦,按这个顺序抄能少调一晚上。
4.1 视图放哪、EL 和 JSTL 怎么用(附带个人信息展示)
视图文件建议统一放在 /WEB-INF/views 下。放在这里的 JSP 不能通过 URL 直接访问,必须先经过 Action 的 result 转发,等于一层免费的访问控制。页面里只用 EL 表达式和 JSTL 标签取数,不要写 <% %> Java 脚本段,循环、判空混在 HTML 里,答辩时被问维护性会很尴尬。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head><title>物资列表</title></head> <body> <p>当前用户:${sessionScope.loginUser.realName}(${sessionScope.loginUser.username})</p> <table border="1" cellpadding="6"> <tr> <th>物资编码</th><th>名称</th><th>规格</th><th>单位</th><th>库存</th><th>操作</th> </tr> <c:forEach items="${pageBean.list}" var="m"> <tr> <td>${m.materialCode}</td> <td>${m.materialName}</td> <td>${m.spec}</td> <td>${m.unit}</td> <td>${m.stock}</td> <td> <a href="${pageContext.request.contextPath}/material/in_add.action?materialId=${m.id}">入库</a> <a href="${pageContext.request.contextPath}/material/out_add.action?materialId=${m.id}">出库</a> </td> </tr> </c:forEach> </table> </body> </html>第一行 EL 从 session 里取 loginUser 对象展示姓名和账号,这就是课程设计里最常见的个人信息展示页面写法:登录时往 session 放对象,JSP 用 sessionScope 取,不需要额外配置。c:forEach 的 items 是集合属性,var 是循环变量名,${m.stock} 自动调用 getStock()。链接里拼 pageContext.request.contextPath 是为了换上下文路径部署时不 404,用写死的相对路径,war 换个项目名访问就会断。in_add 和 out_add 两个 action 在 struts.xml 里分别配 method 指向 inAddPage 和 outAddPage,页面把 materialId 用隐藏域带回表单提交。
4.2 分页查询的 PageBean 与 HQL 写法
物资表几百条数据时不分页也能用,但课程设计评审表里经常有“分页查询”这一项,躲不掉。PageBean 自己写一个通用类,字段固定这几样。
| 字段 | 类型 | 说明 |
|---|---|---|
| pageNum | int | 当前页码,从 1 开始 |
| pageSize | int | 每页条数,默认 10 |
| total | long | 总记录数 |
| totalPages | int | 总页数,(total + pageSize - 1) / pageSize |
| list | List<T> | 当前页数据 |
@Override public PageBean<Material> findPage(int pageNum, int pageSize, String keyword) { PageBean<Material> pb = new PageBean<>(); pb.setPageNum(pageNum); pb.setPageSize(pageSize); Session session = sessionFactory.getCurrentSession(); // 先统计总数,用于计算总页数和页码条 String countHql = "select count(*) from Material where materialName like :kw"; Long total = session.createQuery(countHql, Long.class) .setParameter("kw", "%" + keyword + "%") .getSingleResult(); pb.setTotal(total); pb.setTotalPages((int) ((total + pageSize - 1) / pageSize)); // 再取当前页数据,setFirstResult 是偏移量,setMaxResults 是条数 String listHql = "from Material where materialName like :kw order by id desc"; pb.setList(session.createQuery(listHql, Material.class) .setParameter("kw", "%" + keyword + "%") .setFirstResult((pageNum - 1) * pageSize) .setMaxResults(pageSize) .list()); return pb; }count 和列表各打一次 SQL,totalPages 用向上取整公式,避免最后一页算成 0。setFirstResult 传 (pageNum - 1) * pageSize,第 1 页偏移 0,第 2 页偏移 10,正好对应 MySQL 的 LIMIT 10, 10。页码越界时列表会是空集合,Service 里把 pageNum 钳位到 1 和 totalPages 之间即可。keyword 为空时 % 和 % 也匹配全部记录,不用单独写分支。
4.3 登录会话与 Struts2 拦截器做权限控制
登录态用 session 存,判断用拦截器。Struts2 拦截器在 Action 方法执行前插入逻辑,session 里没有 loginUser 就返回 login 结果串,由全局结果跳到登录页。这里有个很隐蔽的坑:登录 action 本身也会被拦截,必须放在不带这个拦截器的 package 里,否则永远进不了登录流程。
public class LoginInterceptor extends AbstractInterceptor { @Override public String intercept(ActionInvocation invocation) throws Exception { Map<String, Object> session = invocation.getInvocationContext().getSession(); if (session.get("loginUser") == null) { return "login"; // 未登录,跳到登录页 } return invocation.invoke(); // 已登录,放行执行 Action } }<!-- 登录包:不挂登录拦截器,否则自己都登不进去 --> <package name="login" namespace="/" extends="struts-default"> <action name="doLogin" class="loginAction" method="login"> <result name="success" type="redirectAction">/material/list</result> <result name="input">/login.jsp</result> </action> </package> <!-- 带拦截器的基包:业务包 extends 它即可复用拦截器栈 --> <package name="secure" namespace="/" extends="struts-default"> <interceptors> <interceptor name="loginCheck" class="com.hospital.mms.interceptor.LoginInterceptor" /> <interceptor-stack name="mmsStack"> <interceptor-ref name="defaultStack" /> <interceptor-ref name="loginCheck" /> </interceptor-stack> </interceptors> <default-interceptor-ref name="mmsStack" /> <global-results> <result name="login">/login.jsp</result> </global-results> </package>login package 只放 doLogin 一个 action,走 struts-default 原始拦截器;secure package 里定义了拦截器栈和全局结果。把第 2 章 material 所在 package 的 extends 从 struts-default 改成 secure,/material 下的 action 就全部套上了登录检查,这是 Struts2 里继承 package 复用拦截器栈的标准做法。login.jsp 要放在 webapp 根目录而不是 /WEB-INF/views,因为它是被拦截器直接转发的登录页,放受保护目录里会出现已登录也打不开的情况。
5. Tomcat 部署后的验证步骤与三个高频坑
5.1 打包部署与核心流程自测清单
Maven 工程在根目录执行 mvn clean package,Eclipse 工程右键 Export 选 WAR file,把产物拷到 Tomcat 的 webapps 目录后启动,访问 http://localhost:8080/项目名/。自测走一条固定链路:登录 → 新增“一次性注射器” → 入库 10 件 → 列表库存显示 10 → 出库 3 件 → 库存变 7 → 到数据库查 stock_in、stock_out 各有一条记录,数量和时间对得上。这条链路全通,系统核心功能就算验收通过。启动失败时不要盯着页面看,先翻 Tomcat 的 logs/catalina.out 最后 30 行,端口被占、jar 没打进去、数据库连不上,三类问题在日志里都有明确关键字。
5.2 中文乱码与 JSP 编译缓存位置
中文乱码按三个位置排查,顺序不要乱:JSP 头部的 pageEncoding 和 contentType 都写 UTF-8;MySQL 连接串带 useUnicode=true&characterEncoding=utf8;Tomcat 的 server.xml 里 Connector 加 URIEncoding="UTF-8"。前两个解决 post 提交和入库,最后一个解决 GET 链接带中文参数的情况。
另一个课程设计里高频的问题是“jsp 编译 class 文件保存在哪里”:答案是 Tomcat 的 work/Catalina/localhost/项目名/org/apache/jsp 目录,里面能看到每个 JSP 编译出的 .java 和 .class。改完 JSP 不生效时,把 work 目录下对应项目整个删掉再重启 Tomcat,比反复刷新页面靠谱得多。
5.3 高频异常对照表与处理方式
| 报错或现象 | 常见原因 | 处理方式 |
|---|---|---|
| Could not obtain transaction-synchronized Session | DAO 在事务外被调用 | 检查 Service 方法是否有 @Transactional |
| LazyInitializationException: could not initialize proxy | JSP 里访问懒加载关联,Session 已关闭 | HQL 里加 fetch,或配置 OpenSessionInView 过滤器 |
| 404 或 The requested resource is not available | struts.xml 的 namespace 和链接路径对不上 | 按 namespace + action name 拼完整 URL 核对 |
| 页面能打开但中文全是问号 | 三处编码漏了其中一处 | 按 5.2 的顺序逐项检查 |
最后一招,把 Tomcat 的 work 和 webapps 下已解压的项目目录全部清掉重新部署一次,SSH 老项目里一半的“改了没反应”都是旧 class 缓存引起的,与其怀疑代码,不如先清缓存重启再看日志。
本文还有配套的精品资源,点击获取