简介:一份围绕Java物流配送管理系统展开的毕业论文文档,面向计算机专业学生、课程设计或毕业设计参考者,聚焦B/S架构下物流业务的信息化管理与降本增效问题。文档以JSP、SSH框架和Oracle数据库为技术主线,涵盖引言、相关技术综述、需求分析与概要设计、系统架构、功能模块描述、用例图及领域模型等内容,并具体讨论车辆管理、配送点配送范围管理、配送管理、员工管理、权限管理、订单管理和交接单管理等模块,篇幅完整、章节层次清晰,对系统用例与数据库选型均有交代。资源包共1个doc文件,约1.27MB,便于直接查阅论文的整体结构、技术选型与模块设计表述,也可作为同类Java Web管理系统选题和写作的参考。已有624人学习下载,读者可从中了解从需求分析到系统设计的完整论文组织方式,并借鉴SSH分层开发、Oracle数据库应用及功能模块划分等可复用内容。
1. 从一份毕业设计源码说起:这套 Java 物流配送管理系统到底管什么
第一次把这套 Java 物流配送管理系统解压开,多数人会习惯性去找index.jsp双击看效果,结果页面白屏、控制台一片红。它不是那种开箱即用的脚手架工程,而是一份带完整需求分析、类图、顺序图和建表脚本的毕业论文配套系统:B/S 结构,JSP 做视图,Struts2 + Spring + Hibernate 做分层,Oracle 扛后台数据。跑通之后你能看到两个角色并存的后台——省公司管理员维护车辆、配送点、权限和员工,站点管理员管配送范围、订单、交接单和发车。适合拿它练 SSH 分层落地、照着论文补答辩材料,或者当个物流后台原型改造成自己的项目。下面按「先看清结构、再跑起来、最后动手改」的顺序拆一遍。
2. 分层骨架拆解:SSH 四层怎么落到物流业务上
这套系统的技术栈并不新,但它把 SSH 的分层做得比较规矩,规矩到你可以拿它当模板抄。理解它的关键不是记住 Struts2、Spring、Hibernate 各自是什么,而是搞清楚一个「添加车辆」的请求从浏览器发出后,到底在哪些类之间跳了几次。
2.1 为什么这套论文项目绕不开 SSH + JSP + Oracle
论文正文里写得很直白:MyEclipse 做开发环境,Oracle 做后台库,Java Web 技术基于 SSH 框架。这个组合在当年的高校毕设里几乎是标配,原因不复杂——Struts2 负责 MVC 分离和请求分发,Spring 当容器把 Action、Service、DAO 串起来并托管事务,Hibernate 把 JDBC 那堆Connection、PreparedStatement、ResultSet的样板代码省掉,Oracle 则提供了序列、约束、事务这些关系型数据库的完整能力,正好匹配物流业务里「订单—交接单—车辆」这种强关联数据。
把职责摊开看会更清楚:
| 层次 | 承担的技术 | 在物流系统里的具体职责 |
|---|---|---|
| 视图层 | JSP + JSTL + Struts2 标签 | 车辆列表、配送点表单、订单录入页 |
| 控制层 | Struts2 Action | 接收表单参数、组织 PageBean、决定跳转哪个 JSP |
| 业务层 | Spring 管理的 Service | 删除配送点前的「工作中」校验、交接单生成规则 |
| 持久层 | Hibernate DAO | 实体对象的增删改查、HQL 分页 |
| 数据层 | Oracle | 七张核心表、序列主键、外键约束 |
这套分层最大的价值在于「业务规则有地方放」。像「配送点处于工作中状态时不允许删除」这种判断,如果写在 JSP 里就是灾难,写在 Action 里会越堆越乱,放在 Service 里才有人管。论文里给出的类图也是按这个思路画的:CarManageAction只依赖CarManageService接口,CarManageServiceImpl再依赖CarManageDao接口,实现类藏在后面。接口先行这一点,对后面二次开发特别有用——你想把 Hibernate 换成 MyBatis,只要重写 DAO 实现,Service 以上几乎不用动。
2.2 一次请求走完的五层链路:jsp → Action → Service → DAO → Hibernate
论文里那句「jsp -> Action -> Service -> DAO -> Hibernate」是整个系统的主动脉。以「新增车辆」为例,走一遍完整链路,你就能明白每个文件该放什么。
先看 JSP 表单,字段名必须和 Action 里的属性名对上,这是 Struts2 参数注入的前提:
<!-- car_add.jsp:字段 name 必须与 CarManageAction 的属性名一致 --> <form action="addCar.action" method="post"> <input type="text" name="car.carId" placeholder="车辆编号" /> <input type="text" name="car.carType" placeholder="车型" /> <input type="text" name="car.loadWeight" placeholder="载重(吨)" /> <input type="text" name="car.volume" placeholder="容积(方)" /> <input type="text" name="car.manufacturer" placeholder="生产厂商" /> <input type="text" name="car.nowStatus" value="空闲" readonly /> <input type="submit" value="保存" /> </form>这里的car.前缀不是装饰,它对应 Action 里一个名为car的实体属性,Struts2 会调用setCar(TCar)再逐字段赋值。写完表单别忘了在struts.xml里登记这个 action 名,否则提交上去就是 404。
Action 层不写业务,只做三件事:接参数、调 Service、给结果:
// CarManageAction.java:控制层,只负责参数接收与页面跳转 public class CarManageAction extends ActionSupport { private TCar car; // 与表单 car.xxx 对应 private CarManageService carService; // 由 Spring 注入 private PageBean pageBean; // 分页结果,回传给 JSP private int page = 1; // 当前页码,默认第一页 // 新增车辆:校验载重与容积后交给 Service public String addCar() { if (car.getLoadWeight() == null || car.getVolume() == null) { this.addActionError("载重和容积不能为空"); return INPUT; } carService.addCar(car); return "carList"; // 跳到车辆列表的 action } // 分页查询:页码从请求参数来,每页条数写死在 Service 里 public String queryCarPage() { pageBean = carService.queryForPage(page, 10); return SUCCESS; } public void setCar(TCar car) { this.car = car; } public void setCarService(CarManageService carService) { this.carService = carService; } public PageBean getPageBean() { return pageBean; } public void setPage(int page) { this.page = page; } }注意这里的 Service 是接口类型加 setter,注入交给 Spring,Action 本身不new任何实现类。page默认值 1 很关键——用户直接访问列表页时不会带页码参数,没有默认值就会NullPointerException。
Service 层是业务规则的落脚点,也是事务边界所在:
// CarManageServiceImpl.java:事务在此层开启,异常时回滚 public class CarManageServiceImpl implements CarManageService { private CarManageDao carDao; // 接口注入,便于替换实现 @Transactional(rollbackFor = Exception.class) public void addCar(TCar car) { // 业务校验:同一编号不允许重复登记 if (carDao.getByCarId(car.getCarId()) != null) { throw new RuntimeException("车辆编号已存在:" + car.getCarId()); } car.setNowStatus("空闲"); // 新车默认空闲,才能参与调度 carDao.addCar(car); } public PageBean queryForPage(int page, int pageSize) { int total = carDao.getAllRowCount("from TCar"); // 总记录数 List list = carDao.queryForPage("from TCar order by carId", page, pageSize); return new PageBean(page, pageSize, total, list); // 四件套封装 } public void setCarDao(CarManageDao carDao) { this.carDao = carDao; } }DAO 实现里才是 Hibernate 真正出场的地方,save、delete、createQuery都在这一层:
// CarManageDaoImpl.java:继承 HibernateDaoSupport 拿到 Session public class CarManageDaoImpl extends HibernateDaoSupport implements CarManageDao { public void addCar(TCar car) { this.getHibernateTemplate().save(car); // 对象入库,无需手写 insert } public TCar getByCarId(String carId) { List list = this.getHibernateTemplate() .find("from TCar where carId = ?", carId); return list.isEmpty() ? null : (TCar) list.get(0); } public int getAllRowCount(String hql) { return this.getHibernateTemplate().find(hql).size(); } public List queryForPage(String hql, int page, int pageSize) { // Hibernate 分页从 0 开始计数,这里要减 1 return this.getHibernateTemplate().findByQuery(hql, (page - 1) * pageSize, pageSize); } }链路走完,你会发现每一层只干一件事:Action 不碰 SQL,Service 不碰 Session,DAO 不判业务。这种「各管一段」的写法在论文项目里能成立,靠的就是 Spring 在中间做注入和事务代理,struts.xml做请求路由。
2.3 工程目录结构与四份配置文件的摆法
目录结构直接反映了分层,按包名扫一眼就知道东西该往哪放:
logistics/ ├── src/ │ ├── com/logistics/action/ # Struts2 Action:CarManageAction 等 │ ├── com/logistics/service/ # 业务接口 │ ├── com/logistics/service/impl/ # 业务实现,事务在这里 │ ├── com/logistics/dao/ # DAO 接口 │ ├── com/logistics/dao/impl/ # Hibernate 实现 │ ├── com/logistics/entity/ # TCar、TDistributionSite 等实体 │ ├── com/logistics/util/ # PageBean、编码过滤器 │ ├── applicationContext.xml # Spring:bean 装配 + 事务 + 数据源 │ ├── struts.xml # Struts2:action 路由 + 结果视图 │ └── jdbc.properties # 数据库连接四要素 ├── WebRoot/ │ ├── WEB-INF/web.xml # 过滤器、监听器、Struts2 前端控制器 │ ├── jsp/car/ # 车辆模块页面 │ ├── jsp/site/ # 配送点模块页面 │ └── css/ js/ images/ # 静态资源 └── lib/ # ssh 相关 jar 包四份配置里最容易出错的是web.xml和applicationContext.xml的衔接。web.xml必须同时挂上 Spring 的ContextLoaderListener和 Struts2 的StrutsPrepareAndExecuteFilter,顺序不能反:
<!-- web.xml:监听器先起 Spring 容器,再由 Struts2 接管请求 --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <filter> <filter-name>struts2</filter-name> <filter-class>org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter</filter-class> </filter> <filter-mapping> <filter-name>struts2</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>如果监听器没起或者配置文件路径写错,启动时不会报错,但一提交表单就是NullPointerException——Action 里的 Service 全是 null。这个「黑匣子」一样的空指针,后面避坑章还会专门讲。
applicationContext.xml里要把 Action、Service、DAO 三层 bean 全登记,同时配好数据源、Hibernate 会话工厂和事务通知:
<!-- applicationContext.xml:三层 bean + 事务代理 --> <bean id="carManageAction" class="com.logistics.action.CarManageAction" scope="prototype"> <property name="carService" ref="carManageService" /> </bean> <bean id="carManageService" class="com.logistics.service.impl.CarManageServiceImpl"> <property name="carDao" ref="carManageDao" /> </bean> <bean id="carManageDao" class="com.logistics.dao.impl.CarManageDaoImpl"> <property name="sessionFactory" ref="sessionFactory" /> </bean> <!-- 事务通知:Service 层方法统一加事务 --> <tx:advice id="txAdvice" transaction-manager="transactionManager"> <tx:attributes> <tx:method name="add*" propagation="REQUIRED" /> <tx:method name="delete*" propagation="REQUIRED" /> <tx:method name="query*" read-only="true" /> </tx:attributes> </tx:advice>Action 的scope必须是prototype,因为 Struts2 每个请求都新建 Action 实例,单例会导致用户 A 的分页参数串到用户 B 的页面上。这个坑我在别的项目里翻过车,改起来就一行,但排查能耗掉半天。配置写完,这层骨架就算立住了,接下来才有资格谈数据库和模块实现。
3. 数据库设计拆解:七张核心表撑起车辆、订单与交接单
论文里数据库设计那章占了不小篇幅,字段说明表列得很细。对二次开发来说,真正要盯的是三件事:主键怎么生成、哪些字段是状态机、哪些外键决定了「不能删」。这三件事想清楚,后面写 Service 校验就是照着抄。
3.1 表清单与实体关系
系统里的核心表围绕两个管理员角色展开,省公司管资源,站点管执行:
| 表名 | 对应实体 | 用途 | 关键字段 |
|---|---|---|---|
| T_CAR | TCar | 车辆档案与状态 | carId、carType、loadWeight、nowStatus |
| T_DISTRIBUTION_SITE | TDistributionSite | 配送点档案 | distributionSiteId、siteName、siteScale |
| T_DISTRIBUTION_SCOPE | TDistributionScope | 配送点覆盖范围 | scopeId、siteId、firstPrice、secondPrice |
| T_ORDER | TOrder | 客户订单 | orderId、siteId、orderStatus、address |
| T_HANDOVER | THandover | 交接单 | handoverId、orderId、carId、employeeId |
| T_EMPLOYEE | TEmployee | 员工档案 | employeeId、siteId、post |
| T_ADMIN | TAdmin | 管理员与权限 | adminId、adminType、siteId |
关系上,一个配送点对多个配送范围、多个订单、多个员工;一张交接单关联一个订单、一辆车、一名员工。论文的领域模型图里把这些连线画得很清楚,落到代码就是实体里的Set和<many-to-one>。
状态字段是最容易被忽略的设计点。TCar.nowStatus只有「空闲 / 配送中」两种值,TOrder.orderStatus有「待配送 / 已生成交接单 / 已发车 / 已签收 / 已退订」,THandover则有「待发车 / 已发车 / 已签收」。这些字符串值散落在 Service 的判断里,改一个字就全线崩,所以我在自己的实现里会统一抽常量。
3.2 Oracle 建表脚本与序列主键
论文用 Oracle,主键生成习惯跟 MySQL 完全不同:没有自增,靠序列加触发器,或者在 Hibernate 里配native让它自己选。这里给车辆表和配送范围表的建表脚本,字段类型与论文里的实体保持一致:
-- 车辆表:编号用 VARCHAR2 便于业务编码,载重容积用 NUMBER CREATE TABLE T_CAR ( CAR_ID VARCHAR2(20) PRIMARY KEY, CAR_TYPE VARCHAR2(50) NOT NULL, LOAD_WEIGHT NUMBER(10,2), -- 载重,单位吨 VOLUME NUMBER(10,2), -- 容积,单位立方米 MANUFACTURER VARCHAR2(100), BUYTIME DATE, NOW_STATUS VARCHAR2(10) DEFAULT '空闲' NOT NULL ); -- 配送范围表:一个配送点可有多条范围记录 CREATE TABLE T_DISTRIBUTION_SCOPE ( SCOPE_ID VARCHAR2(20) PRIMARY KEY, SITE_ID VARCHAR2(20) NOT NULL, SCOPE_NAME VARCHAR2(100) NOT NULL, FIRST_PRICE NUMBER(10,2), -- 首重价格 SECOND_PRICE NUMBER(10,2), -- 续重价格 DISTRIBUTION_TIME VARCHAR2(50), -- 承诺时效 REMARK VARCHAR2(200), CONSTRAINT FK_SCOPE_SITE FOREIGN KEY (SITE_ID) REFERENCES T_DISTRIBUTION_SITE (DISTRIBUTION_SITE_ID) ); -- 订单表:状态字段决定后续能不能生成交接单 CREATE TABLE T_ORDER ( ORDER_ID VARCHAR2(20) PRIMARY KEY, SITE_ID VARCHAR2(20) NOT NULL, SENDER_NAME VARCHAR2(50), RECEIVER_ADDR VARCHAR2(200), ORDER_STATUS VARCHAR2(20) DEFAULT '待配送' NOT NULL, CREATE_TIME DATE DEFAULT SYSDATE );字段类型有几个讲究:金额和重量统一用NUMBER(10,2),别用FLOAT,Oracle 里浮点做等值比较会出玄学问题;NOW_STATUS给了默认值,插入时就算 Action 忘了赋值也不会是 null;外键FK_SCOPE_SITE是有意加的,它保证范围记录不会挂在不存在的配送点上,代价是删配送点时报 ORA-02292——这就是后面要处理的「工作中不可删」之外的第二种删除失败。
主键生成我一般两种做法选一种。省事的是在hbm.xml里配assigned,编号由业务层生成,比如「CJ + 年月日 + 三位流水」,好处是编号可读、能对账;另一种是配sequence,让 Oracle 序列统一发号。论文项目用前者居多,因为车辆编号、配送点编号在需求里本身就是业务字段,管理员要手填。
3.3 实体类与 Hibernate 映射文件
实体就是数据库表的对象化,字段名与表字段一一对应,BigDecimal对应NUMBER,Date对应DATE:
// TCar.java:字段类型与 Oracle 表字段严格对齐 public class TCar implements java.io.Serializable { private String carId; // 对应 CAR_ID private String carType; private BigDecimal loadWeight; // NUMBER(10,2) 用 BigDecimal 接 private BigDecimal volume; private String manufacturer; private Date buytime; private String nowStatus; // 空闲 / 配送中 // getter / setter 省略 }映射文件写全字段和类型,别偷懒让 Hibernate 猜:
<!-- TCar.hbm.xml:主键 assigned 由业务层生成 --> <hibernate-mapping> <class name="com.logistics.entity.TCar" table="T_CAR"> <id name="carId" type="java.lang.String"> <column name="CAR_ID" length="20" /> <generator class="assigned" /> </id> <property name="carType" type="java.lang.String"> <column name="CAR_TYPE" length="50" not-null="true" /> </property> <property name="loadWeight" type="java.math.BigDecimal"> <column name="LOAD_WEIGHT" precision="10" scale="2" /> </property> <property name="nowStatus" type="java.lang.String"> <column name="NOW_STATUS" length="10" not-null="true" /> </property> </class> </hibernate-mapping>主键生成器写assigned,就必须在 Service 里显式setCarId(),否则保存时抛id to load is required。这个报错信息很误导人,看起来像查询问题,实际是主键没赋值。
3.4 分页封装:PageBean 与 HQL 分页的配合
后台列表页没有不分页的,车辆、配送点、订单、员工都得翻页。论文类图里PageBean反复出现在 Action 的私有属性里,说明它是统一的分页载体:
// PageBean.java:四件套封装,JSP 只认这几个属性 public class PageBean { private int page; // 当前页 private int pageSize; // 每页条数 private int totalCount; // 总记录数 private List list; // 当前页数据 private int totalPage; // 总页数,构造时算好 public PageBean(int page, int pageSize, int totalCount, List list) { this.page = page; this.pageSize = pageSize; this.totalCount = totalCount; this.list = list; // 向上取整,避免最后一页被截断 this.totalPage = (totalCount + pageSize - 1) / pageSize; } // getter 省略,JSP 用 ${pageBean.list} 直接取 }totalPage用(total + size - 1) / size而不是total / size,是因为整除会漏掉最后一页,20 条数据每页 10 条刚好,21 条就会少一页。这个细节不写进构造里,每个调用方都得重复算一遍,迟早有人算错。
4. 核心模块拆解:车辆、配送点、配送范围与交接单的实现细节
模块章是这套系统最值得抄的部分,因为它把「增删改查 + 状态 + 关联校验」这四种典型场景全铺开了。车辆管理是纯 CRUD 加状态位,配送点与配送范围是一对多,订单到交接单是状态流转,权限和员工则是数据隔离。四种模式吃透,物流以外的后台系统也能套。
4.1 车辆管理:状态位决定能不能被调度
车辆模块的业务点集中在「状态」上。省公司买车、淘汰车、改信息,配送点只能查;只有空闲车辆才能被交接单选中。所以addCar里要强制置空状态,deleteCar前要判断是否在配送中:
// CarManageServiceImpl.java:删除前必须确认车辆空闲 public void deleteCar(String carId) { TCar car = carDao.getByCarId(carId); if (car == null) { throw new RuntimeException("车辆不存在:" + carId); } // 配送中的车不允许淘汰,否则交接单会指向空车 if ("配送中".equals(car.getNowStatus())) { throw new RuntimeException("该车辆正在执行配送任务,不能删除"); } carDao.deleteCar(car); }这段判断放在 Service 而不是 Action,是因为交接单模块将来也可能调用「作废车辆」,规则只有一份才不会两边不一致。异常直接往外抛,由 Struts2 的全局异常映射跳到错误页,页面上给管理员看那句中文提示就够。
列表页的查询条件也要收在 Service 里,别让 Action 拼 HQL:
// 按状态过滤的分页查询:空闲车辆用于调度下拉框 public PageBean queryCarPageStatus(String status, int page, int pageSize) { String hql = "from TCar where 1=1"; List params = new ArrayList(); if (status != null && status.trim().length() > 0) { hql += " and nowStatus = ?"; params.add(status); } hql += " order by carId desc"; int total = carDao.getAllRowCount(hql, params.toArray()); List list = carDao.queryForPage(hql, params.toArray(), page, pageSize); return new PageBean(page, pageSize, total, list); }where 1=1这种写法有人嫌丑,但在条件可选的场景里最省事,拼接时不用判断前面有没有 where。参数用?占位再加数组传,别用字符串拼进去,车辆编号里带单引号就能把 SQL 拼坏。
4.2 配送点与配送范围:一对多关系和「工作中不可删」
配送点和配送范围是典型的一对多。论文需求里写得很清楚:省公司管理员可以删配送点,但「若该配送点工作中,则无法删除」;配送点管理员只能管自己站点的范围。这两条规则一个落在 Service,一个落在数据过滤。
删除配送点前要先查它名下有没有未完成的订单:
// DistributionSiteServiceImpl.java:双重校验后才允许删除 public void deleteDistributionSite(String siteId) { TDistributionSite site = siteDao.getById(siteId); if (site == null) { throw new RuntimeException("配送点不存在"); } // 第一道:有没有未完成订单挂在它名下 int running = orderDao.countBySiteAndStatus(siteId, "待配送") + orderDao.countBySiteAndStatus(siteId, "已生成交接单"); if (running > 0) { throw new RuntimeException("该配送点还有 " + running + " 笔未完成订单,不能删除"); } // 第二道:先把名下的配送范围清掉,绕开外键约束 scopeDao.deleteBySiteId(siteId); siteDao.deleteDistributionSite(site); }这里先删范围再删站点,顺序反了就是 ORA-02292 外键冲突。很多人图省事直接在数据库上加ON DELETE CASCADE,结果范围记录静默消失,出了问题查不到痕迹。我在业务层显式删,日志里能看到删了几条,反而踏实。
配送点管理员的数据隔离靠的是登录时把siteId塞进 Session,查询时强制带上:
// DistributionScopeAction.java:站点管理员只能看自己的范围 public String queryDistributionScope() { TAdmin admin = (TAdmin) ServletActionContext.getRequest() .getSession().getAttribute("loginAdmin"); // 省公司管理员 siteId 为空,看全部;站点管理员只看本点 String siteId = "1".equals(admin.getAdminType()) ? null : admin.getSiteId(); scopeList = scopeService.queryBySite(siteId); return SUCCESS; }adminType用「1 / 2」区分省公司和站点,这个约定散落在各个 Action 里,改的时候要全局搜一遍。二次开发时我会把它换成枚举,至少不会有人把 1 写成'1'字符串导致判断永远为假。
配送范围的增删同样有状态约束,需求里写「若该配送点在工作中,则无法删除」,落地就是删之前查一次配送点状态,和车辆那套逻辑同构。
4.3 订单到交接单:一次状态流转的完整实现
配送管理是整套系统里唯一涉及多表协作的模块。站点管理员把订单按目的地归并,生成交接单,再选车选人发车。这条链路里每一步都要改状态,改漏一步后续全乱。
生成交接单的核心逻辑是「同站点、同目的地、待配送」的订单合并成一张单:
// HandoverServiceImpl.java:按目的地归并订单生成交接单 @Transactional(rollbackFor = Exception.class) public String createHandover(String siteId, String address, String employeeId) { // 1. 捞出该站点该目的地所有待配送订单 List<TOrder> orders = orderDao.findBySiteAndAddr(siteId, address, "待配送"); if (orders.isEmpty()) { throw new RuntimeException("该目的地没有待配送订单"); } // 2. 生成交接单号:站点 + 时间戳后六位 String handoverId = siteId + System.currentTimeMillis() % 1000000; THandover handover = new THandover(); handover.setHandoverId(handoverId); handover.setSiteId(siteId); handover.setEmployeeId(employeeId); handover.setHandoverStatus("待发车"); handover.setCreateTime(new Date()); handoverDao.save(handover); // 3. 订单状态推进,并回写所属交接单号 for (TOrder order : orders) { order.setOrderStatus("已生成交接单"); order.setHandoverId(handoverId); orderDao.update(order); } return handoverId; }三步必须在同一个事务里。第 2 步成功、第 3 步失败的话,数据库里会留下一张没有订单的空交接单,列表页看着正常,点进去啥也没有。@Transactional写在方法上,配合applicationContext.xml里的<tx:advice>,任何一步抛异常都整体回滚。
发车这一步要同时锁车和推进状态:
// 发车:车辆置为配送中,交接单与订单状态同步推进 @Transactional(rollbackFor = Exception.class) public void sendCar(String handoverId, String carId) { THandover handover = handoverDao.getById(handoverId); if (!"待发车".equals(handover.getHandoverStatus())) { throw new RuntimeException("该交接单已发车或已签收"); } TCar car = carDao.getByCarId(carId); if (car == null || !"空闲".equals(car.getNowStatus())) { throw new RuntimeException("所选车辆不可用"); } car.setNowStatus("配送中"); carDao.update(car); handover.setCarId(carId); handover.setHandoverStatus("已发车"); handoverDao.update(handover); // 交接单下的订单统一改为已发车 orderDao.updateStatusByHandover(handoverId, "已发车"); }先判状态再判车辆,顺序不能反。如果先锁车再判交接单状态,遇到重复提交就会把一辆车锁死,前端刷新一下页面车就变「配送中」了,管理员只能手工改库。这种并发问题在老项目里很常见,加个状态前置判断能挡住九成。
签收环节需求里特意提了一句「确认接收人已签字后方可进行交接单的删除」,落到实现就是一个签字标记字段,删除前校验它。这条规则看着像形式主义,实际是业务闭环——没有签收凭证的单据不能销毁,出了纠纷要能追溯。
4.4 权限与员工:两级管理员的数据边界
权限模块在论文里叫「站点权限管理」,省公司管理员修改各配送点管理员的配送地点权限。实现上分两层:一层是菜单和按钮的可见性,靠 JSP 里的<s:if>判断adminType;另一层是数据边界,靠 Session 里的siteId过滤查询。
员工管理则是标准的归属关系,新员工必须挂在某个配送点下,离职删除前要确认该员工名下没有进行中的交接单:
// EmployeeServiceImpl.java:有在途任务的员工不允许删除 public void deleteEmployee(String employeeId) { int running = handoverDao.countByEmployeeAndStatus(employeeId, "待发车") + handoverDao.countByEmployeeAndStatus(employeeId, "已发车"); if (running > 0) { throw new RuntimeException("该员工有 " + running + " 张未完成交接单"); } employeeDao.delete(employeeId); }整个权限体系没有用 Spring Security 或 Shiro,就是登录时写 Session、每个 Action 入口判一下。对毕设规模够了,但二次开发上生产就得补拦截器,否则每个 Action 都要复制一遍判断,漏一个就是越权。这部分改造思路放到最后一章讲。
5. 常见问题排查:跑这套 SSH 老项目最容易卡住的五个现场
老项目跑不起来,九成不是代码逻辑错,而是环境、配置、版本三者对不上。下面五条是我在本地复现这套系统时实际撞上的,每条按「现象 → 原因 → 解决」记,照着对基本能通。
5.1 启动就报 ClassNotFoundException: oracle.jdbc.driver.OracleDriver
现象:Tomcat 启动日志刷红,提示找不到 Oracle 驱动类,随后数据源初始化失败,整个 Spring 容器起不来。
原因:Oracle 的 JDBC 驱动不在 Maven 中央仓库,lib目录里没放ojdbc14.jar或ojdbc6.jar,或者放了但没加进项目的 Build Path / 部署程序集,编译期不报错,运行期找不到。
解决:把对应 JDK 版本的驱动 jar 丢进WebRoot/WEB-INF/lib,同时在 MyEclipse 的 Deployment Assembly 里确认它会被打进WEB-INF/lib。驱动类名要和 Oracle 版本匹配,老驱动写oracle.jdbc.driver.OracleDriver,新驱动可以写oracle.jdbc.OracleDriver,连不上时先看这里。
# jdbc.properties:SID 与 SERVICE_NAME 两种写法别混用 jdbc.driver=oracle.jdbc.driver.OracleDriver jdbc.url=jdbc:oracle:thin:@127.0.0.1:1521:orcl jdbc.username=logistics jdbc.password=logisticsURL 里1521后面跟冒号是 SID 写法,跟斜杠是服务名写法,混了就是ORA-12505。
5.2 中文变问号:表单提交到 Oracle 全是乱码
现象:JSP 页面输入「配送点一号」,列表页显示正常,重启后从数据库读出来变成问号或方块。
原因:三处编码不一致——JSP 页面pageEncoding、请求体编码、Oracle 字符集。老项目最常见的组合是页面 UTF-8、请求默认 ISO-8859-1、数据库NLS_CHARACTERSET是ZHS16GBK。
解决:JSP 头部统一<%@ page contentType="text/html;charset=UTF-8" %>,web.xml里加 Spring 的CharacterEncodingFilter并设forceEncoding=true,建库时字符集选AL32UTF8。已经建错的库不建议改字符集,重建更稳。
5.3 LazyInitializationException:查配送点时带不出范围列表
现象:配送点列表页正常,点进详情要展示该点下的配送范围时报could not initialize proxy - no Session。
原因:实体里配了<set lazy="true">,Hibernate 会话在 Service 方法结束时就关了,JSP 渲染时再去触发懒加载,Session 已经没了。
解决:三种改法按代价从低到高——把关联改成lazy="false"并在 DAO 里用left join fetch一次抓出来;或者在 Service 里手动Hibernate.initialize(site.getScopes());再或者在web.xml里配OpenSessionInViewFilter把 Session 生命周期拉到整个请求。我一般选第一种,抓取策略写在 HQL 里最可控,OpenSessionInView会把连接持有到页面渲染完,并发一高连接池就告急。
5.4 删除配送点报 ORA-02292:违反完整性约束
现象:点删除,页面弹出「违反完整约束条件」,控制台是ORA-02292: integrity constraint violated - child record found。
原因:T_DISTRIBUTION_SCOPE、T_ORDER上有外键指向配送点,直接删父表记录被数据库拦下。
解决:业务层按「先子后父」顺序删,先清范围、确认无未完成订单,再删站点。别第一反应去加ON DELETE CASCADE,级联删除在订单这种有金额数据的表上非常危险,删错一个配送点,历史订单全没了。
5.5 事务不生效:删了一半,订单状态没回滚
现象:生成交接单时故意让第 3 步抛异常,结果交接单还在,订单状态没变,两边数据对不上。
原因:Action 里直接new CarManageServiceImpl()创建了 Service,绕过了 Spring 的事务代理,@Transactional根本没人解析。
解决:Service 必须由 Spring 注入,Action 里只声明接口加 setter,applicationContext.xml里配好<tx:advice>和aop:config的切点。判断事务有没有生效有个土办法:在 Service 方法里打个日志,再故意抛异常,看日志是不是只打了前半段。
6. 二次开发:把这套论文项目改成能演示、能继续迭代的版本
论文项目有个通病:功能齐、结构正,但演示起来干巴巴,代码也不适合直接往新项目上搬。想让它既能答辩又能当底子继续做,改造要挑性价比最高的几处下手,别一上来就重构。
第一件事是把权限判断从 Action 里抽出来。现在每个 Action 开头都在判adminType,加一个 Struts2 拦截器统一处理,登录态、角色、站点归属一次校验完,Action 里只留业务。拦截器里判断不通过就返回一个全局login结果,struts.xml里配global-results指到登录页,省掉几十处重复代码。
第二件事是给状态字段上枚举。"空闲"、"配送中"、"待发车"这些字符串散在 Service、JSP、SQL 里,改一个字要全局搜。抽成CarStatus、OrderStatus两个枚举,JSP 里用<s:if test="car.nowStatus.name() == 'IDLE'">,数据库里存枚举的 code,展示层做一次映射。这一步做完,后面加「已取消」「异常件」这类新状态才不会牵一发动全身。
第三件事是补一个可演示的数据流。答辩现场最怕点了半天没数据,准备一份初始化 SQL,把两个角色、三个配送点、五辆车、十条订单、两张交接单灌进去,再写个一键脚本:
-- init_demo.sql:答辩演示用最小数据集 INSERT INTO T_ADMIN VALUES ('A001', 'admin', '省公司管理员', '1', NULL); INSERT INTO T_ADMIN VALUES ('A002', 'site01', '一号站点管理员', '2', 'S001'); INSERT INTO T_CAR VALUES ('C001', '厢式货车', 5.00, 20.00, '某车辆厂', SYSDATE, '空闲'); INSERT INTO T_DISTRIBUTION_SITE VALUES ('S001', '一号配送点', '某市某区', '中型', '演示数据'); INSERT INTO T_ORDER VALUES ('O001', 'S001', '张三', '某市某路 1 号', '待配送', SYSDATE); COMMIT;演示路径固定成「登录省公司 → 加车 → 切站点管理员 → 录订单 → 生成交接单 → 选车发车 → 查车辆状态变配送中」,一条线走完,七个模块全串起来,比逐个菜单点一遍有说服力得多。
如果打算长期迭代,我的建议是别在 SSH 上原地加功能,而是分两步走:第一步保留 JSP 和 Oracle,只把 Struts2 换成 Spring MVC,Action 改 Controller,成本可控;第二步再把 Hibernate 换成 MyBatis,SQL 显式写出来,复杂查询反而好维护。分两步是因为一次性全换,出了问题分不清是框架的锅还是业务的锅。迁移过程中用老系统的页面做对照,同一个车辆列表,新旧两边条数、分页、状态筛选必须一致,这叫等价验证,比写单元测试快。
还有个具体技巧:把PageBean的构造参数顺序固定成「页码、每页条数、总数、数据」,全项目只允许这一个构造入口。我在一个类似项目里见过三个不同版本的 PageBean,参数顺序还不一样,调分页时对着调用方数参数数了半天,血泪经验。从那以后我每次加新模块,第一件事就是确认分页组件只有一份,再多写一个就必须重构掉。希望这套拆解能帮到你,把手里这份源码真正跑起来、改得动。
本文还有配套的精品资源,点击获取