SSH框架在固定资产管理系统中的实战落地与避坑指南
简介这是基于Java、SSH框架与JSP技术的固定资产管理系统毕业设计项目面向高校计算机相关专业学生及需要快速搭建管理系统的开发者覆盖资产入库、领用、报废、盘点等核心业务流程可直接用于课程设计与毕设选题参考。压缩包共376个文件包含75个Java源码文件与31个JSP页面涵盖Struts、Hibernate、Spring三大框架的配置与业务逻辑实现另有69个依赖jar包、SQL数据库脚本、Word与PPT文档及演示视频部署时无需再四处寻找配套依赖。资源包整体大小约152.28MB结构清晰代码与文档分离便于按模块检索与二次开发。目前已有551人学习下载源码经过测试校正可百分百成功运行适合需要完整项目方案、答辩展示素材或快速上手SSH开发流程的开发者。1. 为什么固定资产管理系统还在用SSH这套老组合今年帮朋友改一套“基于javaSSHJSP的固定资产管理系统”的毕业设计源码和文档都是现成的结果他一看Struts、Spring、Hibernate这套组合就想推翻重写成Spring Boot。我劝他先别急固定资产管理这种流程固定、表单多、报表少的业务用SSH反而能把控制器、业务层、数据库映射拆得明明白白而且目前高校和企业里的老项目存量非常大面试题里也经常拿SSH整合来考察Java工程师对分层和事务的理解。这篇笔记就按我实际把这个系统跑起来并扩展功能的经验从框架分工、代码骨架、分页查询到高频翻车点一次说清楚适合正在做这个课题的学生也适合接手SSH老系统硬着头皮维护的同行。2. SSH框架在固定资产管理里的角色划分Struts管流程、Spring管业务、Hibernate管持久化2.1 固定资产管理的核心流程怎么映射到Struts的Action固定资产管理系统的业务流程基本固定资产登记、部门领用、资产调拨、资产报废、按月折旧。每一个动作在Struts里对应一个Action类和一个转发结果页面流转路径非常直观。我习惯把所有资产操作放在同一个package下用namespace区分模块下面是一段最基础的struts.xml配置package nameasset namespace/asset extendsstruts-default action namelist classcom.fixedasset.web.action.AssetAction methodlist result namesuccess/pages/asset/list.jsp/result /action action nameadd classcom.fixedasset.web.action.AssetAction methodadd result namesuccess typeredirectlist.action/result /action action nameedit classcom.fixedasset.web.action.AssetAction methodedit result nameinput/pages/asset/edit.jsp/result result namesuccess typeredirectlist.action/result /action /packagenamespace对应URL前缀name对应action名method绑定执行方法。比如访问/asset/list.action就会走AssetAction类的list方法。这里有两个关键点一是result的typeredirect登记或修改成功后要重定向到列表页这样用户刷新页面时不会重复提交表单二是edit操作里input和success两种结果分开校验失败回编辑页成功则跳列表。AssetAction本身由Spring管理所以类里不自己new Service而是用setter注入。Action只做三件事接收页面参数、调用Service、决定返回哪个视图。下面是最小的list和add方法public class AssetAction extends ActionSupport { private AssetService assetService; private Asset asset; private ListAsset assetList; public String list() { assetList assetService.findAll(); return SUCCESS; } public String add() { assetService.save(asset); return SUCCESS; } }在Struts里Action的属性会自动从请求参数绑定所以表单里写nameasset.assetNameStruts会自己创建Asset对象并填充属性。千万别在Action里写JDBC或Hibernate代码否则事务边界全部打乱后面接手的同事会骂人。这套分层虽然老但职责边界比现在很多Controller写一堆业务代码的Spring Boot项目清晰得多。2.2 Spring容器里的事务边界资产入库和折旧不能各干各的固定资产业务里有很多跨表写操作。最典型的场景是资产入库既要插入资产主表又要插入一条资产变动记录还要更新部门台账。如果前两步成功、第三步抛异常数据就残了。Spring声明式事务把这些操作包在同一个数据库事务里要么全成功要么全回滚。我把Service接口和实现分开实现类上加Transactionalpublic interface AssetService { void save(Asset asset, AssetChangeRecord record); } Service Transactional public class AssetServiceImpl implements AssetService { Autowired private AssetDao assetDao; Autowired private AssetChangeRecordDao recordDao; Override public void save(Asset asset, AssetChangeRecord record) { assetDao.save(asset); recordDao.save(record); // 若此处抛异常上面asset的插入也会回滚 } }Transactional默认只回滚RuntimeException受检异常不会回滚。所以如果你的业务方法可能抛出SQLException或ServiceException这类受检异常必须显式声明Transactional(rollbackFor Exception.class)还有一个隐藏坑Spring事务基于AOP代理同类内部方法自调用不会触发代理。也就是说在AssetServiceImpl里一个public方法去调用另一个带Transactional的私有方法事务是不生效的。我见过有人把批量折旧方法写成private然后在save里调最后数据一半提交一半没提交。跨表操作必须拆成独立的public方法并且贝外部注入的Bean去调用。2.3 Hibernate映射资产表对象关系映射里最容易出的懒加载坑固定资产表结构一般不会太复杂核心字段就是资产编号、名称、分类、购置日期、原值、净值、状态、所属部门。Hibernate用注解映射即可Entity Table(name t_asset) public class Asset { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name asset_no, length 32) private String assetNo; Column(name asset_name, length 128) private String assetName; Column(name category) private String category; Temporal(TemporalType.DATE) Column(name buy_date) private Date buyDate; Column(name original_value) private BigDecimal originalValue; Column(name net_value) private BigDecimal netValue; ManyToOne(fetch FetchType.LAZY) JoinColumn(name dept_id) private Department department; }fetch FetchType.LAZY意味着访问asset.getDepartment().getName()时Hibernate才执行第二条查询去部门表。这个机制在Service事务内没问题但事务结束后Session一关JSP页面里再访问关联对象就会抛LazyInitializationException。这是SSH项目里出现频率最高的异常。解决思路有三种第一种在Spring配置里加OpenSessionInViewFilter让Session在请求渲染期间保持打开第二种在Service层用Hibernate.initialize(asset.getDepartment())提前初始化第三种把fetch改成EAGER。对于固定资产列表页需要频繁显示部门名称的场景我推荐第一种配置简单对只读查询影响也不大。但要注意它会让数据库连接占用延长资产管理系统并发低完全够用。如果并发量大就改成VO查询只查出页面需要的那几个字段不去碰关联对象。3. 从零搭建固定资产管理系统的代码骨架Maven工程与三大框架整合3.1 最小pom.xml锁定SSH版本避开Java版本冲突SSH整合最怕版本不兼容。Spring 3.x和Hibernate 4.x、Struts 2.3时代各种NoClassDefFoundError很多老博客的配置放到现在都会翻车。我现在的标准组合是Spring 4.3 Hibernate 4.3 Struts 2.5全部支持JDK 8互相之间兼容性最好。看一个能编译的最小pom.xmlproperties spring.version4.3.18.RELEASE/spring.version hibernate.version4.3.11.Final/hibernate.version struts2.version2.5.22/struts2.version /properties dependencies dependency groupIdorg.apache.struts/groupId artifactIdstruts2-spring-plugin/artifactId version${struts2.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-web/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-orm/artifactId version${spring.version}/version /dependency dependency groupIdorg.hibernate/groupId artifactIdhibernate-core/artifactId version${hibernate.version}/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.46/version /dependency /dependenciesstruts2-spring-plugin是Struts和Spring桥接的关键它会让Struts从Spring容器里找Action对象。Spring用spring-web提供ContextLoaderListenerspring-orm管理Hibernate的SessionFactory。MySQL驱动版本要匹配数据库如果你用的是MySQL 8驱动得换成mysql-connector-java8.0.x并且JDBC URL要加时区参数否则时间字段会报错。3.2 web.xml与Spring配置让Struts和Hibernate在同一个容器里听话SSH整合的启动顺序是Tomcat先读web.xml由ContextLoaderListener创建Spring容器再交给StrutsPrepareAndExecuteFilter接收请求。web.xml里最急需注意的是过滤器的映射规则listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/*.xml/param-value /context-param filter filter-namestruts2/filter-name filter-classorg.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter/filter-class /filter filter-mapping filter-namestruts2/filter-name url-pattern*.action/url-pattern /filter-mappingurl-pattern千万不能用/*否则CSS、JS、图片全部被Struts拦截页面会丑到怀疑人生。Spring配置文件放在classpath下的spring目录用通配符一次加载。我把配置拆成applicationContext.xml数据源、SessionFactory、spring-struts.xmlAction注册、spring-service.xml组件扫描、事务。数据源和SessionFactory配置是SSH的老大难直接看这段bean iddataSource classorg.springframework.jdbc.datasource.DriverManagerDataSource property namedriverClassName valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/fixedasset?useUnicodetrueamp;characterEncodingutf8/ property nameusername valueroot/ property namepassword value123456/ /bean bean idsessionFactory classorg.springframework.orm.hibernate4.LocalSessionFactoryBean property namedataSource refdataSource/ property namepackagesToScan listvaluecom.fixedasset.entity/value/list /property property namehibernateProperties props prop keyhibernate.dialectorg.hibernate.dialect.MySQL5Dialect/prop prop keyhibernate.show_sqltrue/prop prop keyhibernate.hbm2ddl.autoupdate/prop /props /property /bean注意URL里的在XML里必须写成amp;否则Spring配置文件解析直接报错。hibernate.hbm2ddl.auto开发时用update它会自动建表但不会自动加字段上线前改成validate让Hibernate启动时校验表结构缺字段就报错逼着你把数据库变更脚本写好。固定资产表结构比较稳定用validate最放心。3.3 固定资产增删改查的Action与Service实现写一个可跑通的最小链路从Dao层开始Hibernate在Spring管理下通过SessionFactory拿当前SessionRepository public class AssetDao { Autowired private SessionFactory sessionFactory; private Session getSession() { return sessionFactory.getCurrentSession(); } public void save(Asset asset) { getSession().save(asset); } public Asset findById(Long id) { return (Asset) getSession().get(Asset.class, id); } }getCurrentSession()依赖Spring事务已经开启如果方法没加事务会报“No current session in context”。这就是为什么Dao的调用必须在Service事务边界内。Service层继续用Transactional包裹然后Action调Service。编辑回显的时候Action从Service拿到Asset后压进值栈public String edit() { this.asset assetService.findById(id); return INPUT; }页面上用Struts表单回显s:form actionupdate namespace/asset methodpost s:hidden nameasset.id/ s:textfield nameasset.assetName label资产名称/ s:submit value保存/ /s:form这里最容易忘的是Action里必须先初始化asset对象否则Struts在绑定参数时不知道该new什么。我在Action的构造方法里直接this.asset new Asset();就能避免空指针。另外update的Action方法要先根据id把数据库里的原记录查出来再把表单属性覆盖上去而不是直接用新对象否则关联关系没处理好的情况下会丢外键。4. 固定资产管理系统里的分页、查询与报表JSP页面与标签库的实战调优4.1 用Hibernate的DetachedCriteria做资产组合查询别拼SQL字符串固定资产列表页最常见的是多条件组合查询按资产名称模糊查、按分类查、按购置日期范围查、按状态查。如果把这些条件拼成SQL字符串风险高且维护麻烦。用Hibernate的Criteria API可以优雅地动态构建查询。我写了一个AssetDao的search方法public PageResultAsset search(AssetQuery query) { DetachedCriteria criteria DetachedCriteria.forClass(Asset.class); if (StringUtils.hasText(query.getName())) { criteria.add(Restrictions.like(assetName, % query.getName() %)); } if (query.getCategory() ! null !query.getCategory().isEmpty()) { criteria.add(Restrictions.eq(category, query.getCategory())); } if (query.getStatus() ! null) { criteria.add(Restrictions.eq(status, query.getStatus())); } if (query.getStartDate() ! null) { criteria.add(Restrictions.ge(buyDate, query.getStartDate())); } if (query.getEndDate() ! null) { criteria.add(Restrictions.le(buyDate, query.getEndDate())); } criteria.addOrder(Order.desc(id)); // Count查询 Long total (Long) hibernateTemplate.findByCriteria( criteria.setProjection(Projections.rowCount()) ).get(0); // 重置投影真正查询列表 criteria.setProjection(null); criteria.setResultTransformer(CriteriaSpecification.ROOT_ENTITY); int start (query.getPage() - 1) * query.getRows(); ListAsset list hibernateTemplate.findByCriteria(criteria, start, query.getRows()); return new PageResult(total.intValue(), list); }这里必须注意一个顺序坑先做count查询时criteria.setProjection(Projections.rowCount())会把查询类型改成聚合查询如果count之后不重置setProjection(null)列表查询返回的就不是Asset对象而是一列数量页面全乱了。另外Restrictions.like我直接在值里拼了%好处是兼容MySQL和Oracle缺点是参数里如果本身就带%或_会被当成通配符固定资产名称一般不会带可以接受。如果用户输入特殊字符有安全洁癖可以考虑用Restrictions.ilike配合MatchMode但那样跨数据库行为不一样所以这里没采用。4.2 JSP页面用JSTL和EL渲染资产列表c:forEach的status属性SSH的JSP页面我尽量用JSTL和EL渲染列表少用Struts标签。Struts标签依赖OGNL功能虽强但处理复杂逻辑时页面会很乱。JSTL做循环和展示非常简洁% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table c:forEach items${pageResult.list} varasset varStatusstatus tr td${status.index 1}/td td${asset.assetNo}/td td${asset.assetName}/td td${asset.category}/td td${asset.originalValue}/td td${asset.netValue}/td td${asset.status 0 ? 在库 : (asset.status 1 ? 领用 : 报废)}/td /tr /c:forEach /tablevarStatusstatus的index从0开始页面显示序号要加1。状态字段是Integer三元表达式判空要注意如果status允许为nullasset.status 0会返回false自动走报废分支这就有业务风险。我一般把状态字段在数据库设成NOT NULL或者在Action里统一把状态值转换成字符串再给页面。分页导航的难点不是翻页本身而是查询条件保持。下面这个链接如果直接写hrefsearch.action?page2第一页输入的条件就丢了用户看到第二页不是“资产名称为XXX”的过滤结果立刻会觉得系统有问题。我一般把查询条件做成隐藏表单翻页时提交这个表单form idqueryForm actionsearch.action methodpost input typehidden namequery.name value${query.name}/ input typehidden namequery.category value${query.category}/ input typehidden namequery.page value1/ c:forEach begin1 end${pageResult.totalPage} varp a hrefjavascript:void(0) onclickgoPage(${p})${p}/a /c:forEach /form然后用一小段JS修改query.page的值并提交表单。这个做法比在URL后面拼接查询参数安全因为HTTP GET的URL长度有限而且中文条件还需要编码。4.3 资产折旧计算的批量处理与事务控制固定资产按月折旧一般用平均年限法每月按原值和折旧率计算当月折旧额更新净值。资产量少时直接全量循环没问题但资产上万后必须分批处理。Hibernate的一级缓存默认会积累所有持久化对象一次性加载几万条数据内存直接报警。我写折旧逻辑时会手动控制缓存Transactional public void monthlyDepreciation() { Session session sessionFactory.getCurrentSession(); int pageSize 100; int firstResult 0; while (true) { Query query session.createQuery(from Asset a where a.status 0 order by a.id); query.setFirstResult(firstResult); query.setMaxResults(pageSize); ListAsset list query.list(); if (list.isEmpty()) break; for (Asset asset : list) { BigDecimal monthDepreciation asset.getOriginalValue() .multiply(new BigDecimal(0.004)); asset.setNetValue(asset.getNetValue().subtract(monthDepreciation)); } session.flush(); // 把当前批次变化发给数据库 session.clear(); // 释放一级缓存防止对象堆积 firstResult pageSize; } }这个代码有两个细节。第一session.flush()会执行SQL但不提交事务clear()会丢弃所有托管对象让下一批查询占用的内存回到干净状态。第二BigDecimal运算一定要用字符串构造new BigDecimal(0.004)不要new BigDecimal(0.004)浮点字面量会带来不可预期的精度问题。折旧率0.004对应的年折旧率约5%实际业务按你们公司财务政策调整即可。如果资产量达到几十万这个方式依然会慢因为每次翻页都执行ORDER BY id扫描。这时可以把游标方式改成一次取回所有主键再分片更新但固定资产管理系统一般到不了这个量级分批循环足够。5. 避坑指南SSHJSP固定资产管理系统的5个高频翻车点5.1 现象Hibernate执行saveOrUpdate报TransientObjectException现象调用assetService.save(asset)时抛TransientObjectException: object references an unsaved transient instance。原因表单里通过nameasset.department.deptId传了一个部门IDStruts自动创建了一个Department对象这个对象只有ID没有在数据库里对应持久化状态。Hibernate发现Asset关联了一个“瞬时对象”因为外键约束不允许直接保存。解决在Service里不要把页面传来的Department直接set进去先按ID查出持久态部门再关联。具体做法是Department dept departmentDao.findById(asset.getDepartment().getId()); asset.setDepartment(dept); assetDao.save(asset);如果你图省事也可以在ManyToOne上配置cascade CascadeType.SAVE_UPDATE但这样Hibernate会把瞬时部门也插入数据库万一用户提交了一个不存在的部门ID反而会制造垃圾数据。我建议优先用查询后set的方式这对数据校验也有帮助。5.2 现象JSP页面取不到Action返回的数据明明request里set了现象在Action里写了ServletRequestContext.getRequest().setAttribute(assetList, list)然后JSP里${assetList}输出为空。原因Struts2的请求参数和作用域由它自己管理request.setAttribute虽然生效但Struts对EL取值做了拦截页面里的${assetList}会优先到ValueStack里找而ValueStack里没有叫assetList的属性。你手写的request attribute被“黑匣子”吞掉了。解决不要在Action里手动set request而是定义getAssetList()方法让Action属性自动进入值栈。如果确实要用request scope在JSP里使用${requestScope.assetList}显式指定作用域。我在SSH项目里统一使用Action getter方式因为Struts的s:iterator valueassetList和JSTL的${assetList}都能从这个getter取到。5.3 现象Spring事务不生效资产入库后立刻查询查不到现象资产保存后再查询数据偶尔能查到偶尔查不到或者日志里显示Committing JPA transaction但数据库里就是没数据再刷新页面又出现了。原因这种诡异问题多半是Spring事务边界没生效。首先检查context:component-scan是否扫到了ServiceImpl所在的包其次检查Transactional是否加在public方法上。还有一个常见玄学Service方法里调用了本类的另一个Transactional方法代理没拦截事务根本没开启。解决把事务方法拆到独立Bean中通过注入调用。如果扫描配置没问题可以打开Spring事务日志logger nameorg.springframework.transaction levelDEBUG/看到Creating new transaction就说明事务在开没看到就检查配置。另外Transactional只对代理对象有效如果你直接new AssetServiceImpl()调用事务必然失效。5.4 现象上传资产图片后Tomcat重启文件丢失现象资产管理里加了图片上传功能当天能用第二天服务器一重启图片就404。原因你用了ServletContext.getRealPath(/upload)把文件写到了Tomcat的webapps目录内。IDEA或者Tomcat清理临时文件时这个目录会被清空。这是所有文件上传场景都会踩的坑和SSH无关。解决把上传目录配置到应用外部比如/data/fixedasset/upload然后在Tomcat的server.xml里配置虚拟路径映射Context path/uploads docBase/data/fixedasset/upload reloadabletrue/这样提交的文件不会随工程重部署丢失图片访问路径保持不变。固定资产图片通常不多但这条经验同样适用于Excel导出、合同附件等场景。5.5 现象MySQL中文乱码资产名称变成问号现象页面上填写“笔记本电脑”保存后数据库里变成“”。特别是第一次部署时最容易出现。原因JDBC连接URL没有指定characterEncodingutf8或者MySQL表字段本身是latin1字符集。Spring配置文件里如果URL是jdbc:mysql://localhost:3306/fixedasset那Hibernate写中文时数据库连接默认用GBK或latin1乱码在所难免。解决把JDBC URL改成jdbc:mysql://localhost:3306/fixedasset?useUnicodetruecharacterEncodingutf8注意XML里的要写成amp;。同时检查数据库和表ALTER TABLE t_asset CONVERT TO CHARACTER SET utf8mb4;如果已经乱码转换后还要把存量脏数据修复一遍这个没法自动因为“”已经丢失了原始字节。最好的办法是建库时就指定utf8mb4连接参数也保持一致。6. 给固定资产管理系统加一个Excel导入导出POI操作与内存控制技巧固定资产台账经常需要导出Excel给财务。如果直接用XSSFWorkbook一行一行写单元格资产数量几千条时就可能会触发内存溢出因为XSSFWorkbook会把所有Cell对象保存在堆里。我建议改用SXSSFWorkbook它内部有窗口机制超过窗口的行会被刷到临时文件内存占用稳定可控。导出资产清单的最小代码是这样的public void export(ListAsset assets, OutputStream os) throws Exception { SXSSFWorkbook workbook new SXSSFWorkbook(100); Sheet sheet workbook.createSheet(固定资产); String[] headers {编号, 名称, 分类, 原值, 净值}; Row headerRow sheet.createRow(0); for (int i 0; i headers.length; i) { headerRow.createCell(i).setCellValue(headers[i]); } for (int i 0; i assets.size(); i) { Row row sheet.createRow(i 1); Asset a assets.get(i); row.createCell(0).setCellValue(a.getAssetNo()); row.createCell(1).setCellValue(a.getAssetName()); row.createCell(2).setCellValue(a.getCategory()); row.createCell(3).setCellValue(a.getOriginalValue().toString()); row.createCell(4).setCellValue(a.getNetValue().toString()); } workbook.write(os); workbook.dispose(); // 释放临时文件 }new SXSSFWorkbook(100)表示内存中只保留100行多出来的行自动写入临时文件。originalValue用toString()而不是doubleValue()这样可以避免金额变成科学计数法。最后一定要调用dispose()否则临时文件会一直占用磁盘。导入Excel时道理相同。用WorkbookFactory.create(inputStream)会全量加载如果用户传了一个五万行的Excel内存直接撑爆。常见的做法是先用POI的XSSFReader流式读取或者直接用阿里EasyExcel。不过SSH老项目加新依赖需要权衡我一般是把导入限制在几百行内并且用XSSFWorkbook的Sheet循环读再加一个数量校验。最后说个教训我接手这个项目时自认为SSH很熟结果第一周全在跟Struts的Action扩展名和Spring事务过不去。后来我把这些踩坑事项写成一份排查清单每次给系统加需求就先对照一遍效率反而高了不少。这个项目做完我最大的感受是老技术栈不是不能用而是你得知道它的边界在哪、坑在哪。希望帮到你。本文还有配套的精品资源点击获取