简介:Java物流配送管理系统毕业论文是一份面向计算机及相关专业毕业生与课程设计学习者的文档型资料,围绕基于B/S结构、SSH框架、JSP技术与Oracle数据库的物流配送管理平台展开,涉及车辆、配送点配送范围、配送、员工、权限、订单及交接单等业务模块,可用于毕业设计选题、论文结构参考与系统实现思路借鉴。压缩包内共1个doc文件,大小约1.27MB,以论文正文形式集中呈现,便于通读与检索。已有624人学习或下载,具备一定的参考热度。文档包含摘要、相关技术综述、需求分析与概要设计、系统架构、功能模块描述、用例图与领域模型等内容,能够帮助读者理解SSH项目从需求到设计的组织方式,并对照车辆、配送、订单等模块梳理业务边界与权限设计,适合需要完成同类物流管理系统论文与原型设计的学生参考。
1. 从一份毕业设计说起:这套 Java 物流配送管理系统到底交付了什么
很多人看到“物流配送管理系统”这几个字,第一反应是又一个增删改查的练手项目。真把这份 java 物流配送管理系统毕业论文 资源包解开,会发现它比想象中完整:一篇五章结构的论文,配一套基于 B/S 的 SSH 后台,两个角色——省公司管理员和站点管理员,七个业务模块——车辆、配送点、配送范围、订单、配送、交接单、员工与权限。论文里用例图、领域模型、类图、顺序图都有,代码里 Action、Service、DAO、实体一层不少,而且能互相对得上。适合两类人:赶毕业设计需要一套能跑、能讲、能改的骨架;或者想看看 SSH 分层在一个有真实业务约束的系统里是怎么落地的。后面按“是什么 → 怎么跑 → 怎么改”的顺序往下拆。
2. 技术选型与分层落地:一次请求从 JSP 到 Oracle 的完整链路
这套系统的技术栈写得很直白:MyEclipse 做 IDE,Tomcat 做容器,Oracle 做库,JSP 做视图,SSH 做骨架。听起来是老组合,但老组合有个好处——每一层职责边界非常清晰,你看一眼目录就知道哪个包该放什么,这对要接手改代码的人反而是好事。
2.1 为什么是 Struts2 + Spring + Hibernate 这套组合
论文里对三者的分工描述得比较准确,我按实际代码视角再翻译一遍。Struts2 负责 MVC 里的 C 和一部分 M,它的 Action 既是请求入口,也是数据组装点:接表单、调业务、把结果塞进 request 或 valueStack 再转发给 JSP。Hibernate 负责持久层,把“对表写 SQL”换成“对对象做 save/update/delete”,主键生成、关联加载、事务提交这些琐事由它接管。Spring 站在中间当容器,所有 Action、Service、DAO 的实现类都注册成 Bean,依赖关系靠 setter 注入串起来,同时接管 Hibernate 的 SessionFactory 和事务边界。
这种分工带来一个很实际的结果:写业务的人不写 SQL,写页面的人不碰连接,出问题的时候可以按层定位。车辆查询查不出数据,先看 JSP 有没有拿到 list,再看 Action 有没有调到 Service,再看 DAO 的 HQL 和映射文件对不对,最后才怀疑库。这条排查路径能成立,靠的就是分层没被写穿。项目里有一处值得注意的细节:Action 中只持有 Service 接口,不持有 DAO,这是这套分层能不能立住的关键,一旦 Action 直接 new 一个 DaoImpl,事务和连接管理就全乱了。
2.2 一次请求的走位:jsp → Action → Service → DAO → Hibernate
论文第 2.2 节画了这条链路,实际代码也是照这个走的。以车辆列表页为例,用户在 JSP 上点“查询”,请求进 Struts2 的过滤器,按 struts.xml 里的 action 名找到对应的方法,Spring 把 Service 注入进去,Service 调 DAO,DAO 用 HibernateTemplate 执行 HQL,最后 Action 把 PageBean 放进值栈,转发回 JSP 渲染。整条链路里只有 Action 和 JSP 知道“页面”这回事,Service 以下全是业务和数据。
// CarManageAction.java —— 车辆管理入口,Struts2 的 Action 承担控制器职责 public class CarManageAction extends ActionSupport { // 只依赖 Service 接口,具体实现由 Spring 注入,Action 不碰 DAO private CarManageService carManageService; private List<TCar> tcarList; // 结果集,交给 JSP 用 <s:iterator> 遍历 private PageBean pageBean; // 分页对象:总条数、总页数、当前页数据 private int page = 1; // 当前页码,来自 URL 参数 ?page=2 private TCar car; // 表单提交或回显用的车辆实体 // 分页查询车辆列表 public String queryCarPage() { pageBean = carManageService.queryForPage(page, 10); tcarList = pageBean.getList(); return "carList"; // 对应 struts.xml 里 result 的视图名 } // 新增车辆:先落库,再回到列表页刷新 public String addCar() { carManageService.addCar(car); return queryCarPage(); } // 淘汰车辆:物理删除,调用前页面已做过二次确认 public String deleteCar() { carManageService.deleteCar(car); return queryCarPage(); } public void setCarManageService(CarManageService carManageService) { this.carManageService = carManageService; } // 其余 getter/setter 省略 }这段代码里几个参数值得说清楚。page默认给 1,是为了防止直接访问列表页时出现(1-1)*10之外的负数偏移;每页条数 10 是硬编码,想改成可配置就把它提到常量或配置文件里。return "carList"不是返回 JSP 路径,而是返回结果名,真正的跳转在 struts.xml 里配,这样页面换目录不用改 Java。deleteCar直接接收整个car对象,实际上只需要主键,这是 Struts2 参数绑定的便利也是隐患——前端必须把car.carId传上来,字段名写错就会变成删空。
Service 层负责把“分页”这件麻烦事包掉,DAO 只认偏移量和条数,这样换数据库、换分页插件都不用动上层:
// CarManageServiceImpl.java —— 分页的算法在这里,DAO 只负责取一段 public class CarManageServiceImpl implements CarManageService { private CarManageDao carManageDao; // 同样是接口注入 public PageBean queryForPage(int page, int pageSize) { // 先查总数,用于算总页数 int count = carManageDao.getAllRowCount("select count(*) from TCar"); // 偏移量 = (当前页 - 1) * 每页条数 int offset = (page - 1) * pageSize; List list = carManageDao.queryForPage( "from TCar order by carId desc", offset, pageSize); PageBean bean = new PageBean(); bean.setList(list); bean.setAllRow(count); bean.setPageSize(pageSize); bean.setCurrentPage(page); bean.setTotalPage((count + pageSize - 1) / pageSize); // 向上取整 return bean; } public void setCarManageDao(CarManageDao carManageDao) { this.carManageDao = carManageDao; } }getAllRowCount用select count(*),别图省事用list.size(),数据量一上来就是灾难。(count + pageSize - 1) / pageSize是整数向上取整,比Math.ceil少一次类型转换。DAO 里的 HQL 用实体名TCar而不是表名T_CAR,这是 Hibernate 的基本规矩,写表名会在启动时直接报映射异常。
2.3 实体、映射与 Oracle 表结构怎么对上
这套项目的持久化走的是 XML 映射,一个实体配一个xxx.hbm.xml,字段名和列名一一对应。以车辆为例,表里存的是载重、容积、购买时间、厂商、当前状态,实体里就是BigDecimal loadWeight、Date buytime、String nowStatus这些属性。类型选择上有个细节值得学:重量和体积用BigDecimal而不是double,因为运费和容积要参与计算,浮点误差在财务场景里是要命的。
-- T_CAR 车辆表(Oracle),主键用序列 + 触发器或 Hibernate 的 native 生成 CREATE TABLE T_CAR ( CAR_ID VARCHAR2(20) PRIMARY KEY, -- 车辆编号,业务主键 CAR_TYPE VARCHAR2(50), -- 车型,如 5 吨厢货 LOAD_WEIGHT NUMBER(10,2), -- 载重,单位吨 VOLUME NUMBER(10,2), -- 容积,单位立方米 MANUFACTURER VARCHAR2(100), -- 生产厂商 BUYTIME DATE, -- 购入日期 NOW_STATUS VARCHAR2(10) -- 状态:空闲 / 配送中,发车时校验 );NOW_STATUS这个字段是整个配送链路的开关,车辆管理里改它、配送管理里读它,两边共用一张表,所以状态值必须是有限枚举,不能随手写中文描述。实际开发里我一般会再加一列SITE_ID表示车辆归属哪个配送点,论文里的设计是省公司统一管车、站点申请调车,不加归属字段的话,站点管理员查车只能看到全部车辆,权限就形同虚设。这类“论文写了但代码没落地”的缺口,恰恰是二次开发最该先补的地方。
3. 核心模块拆解:车辆、配送点、配送范围三条主线的代码骨架
七个模块里,车辆、配送点、配送范围是基础数据,订单、交接单、配送是业务数据。基础数据没维护好,业务链路根本跑不起来——没车不能发车,没配送范围不知道该送哪,没配送点订单无处归属。所以读这套代码,建议从这三个模块入手,它们的写法几乎一模一样,摸清一个就能推出其余六个。
3.1 车辆管理:Action 到 DAO 的四段式增删改查
这个项目的每个基础数据模块都是同一个模板:XxxManageAction、XxxManageService、XxxManageServiceImpl、XxxManageDao、XxxManageDaoImpl,加上一个实体和一个映射文件。看起来类多,其实是把“改动会波及哪里”提前框死了。要加一个字段,实体加属性、映射加一行、JSP 加一个输入框,三处就完事,不会牵一发动全身。
DAO 层用HibernateTemplate包住 Session,好处是不用手写 try-finally 关闭会话,缺点是事务必须配在 Spring 的tx:advice里,否则每次操作都是独立提交,批量导入订单时会慢到怀疑人生。项目里的 HQL 参数一律用占位符?或命名参数,没有做字符串拼接,这点做得对——拼接 HQL 是注入的入口,答辩时被问到也说不清。
// CarManageDaoImpl.java —— 持久层实现,靠 HibernateTemplate 屏蔽 Session 细节 public class CarManageDaoImpl implements CarManageDao { private HibernateTemplate hibernateTemplate; public void addCar(TCar car) { hibernateTemplate.save(car); // 主键由映射文件里的 generator 生成 } public void alterCarManage(TCar car) { hibernateTemplate.update(car); // 全字段更新,页面必须回填完整 } public void deleteCar(TCar car) { hibernateTemplate.delete(car); // 按主键删除,传进来的对象只用到 carId } public int getAllRowCount(String hql) { // uniqueResult 返回 Long,直接强转 int 在数据量大时会溢出,稳妥写 intValue() Long count = (Long) hibernateTemplate.find(hql).get(0); return count.intValue(); } public List queryForPage(String hql, int offset, int pageSize) { // setFirstResult / setMaxResults 是 Hibernate 的标准分页,翻译成 Oracle 的 rownum return hibernateTemplate.execute(session -> session.createQuery(hql) .setFirstResult(offset) .setMaxResults(pageSize) .list()); } public void setHibernateTemplate(HibernateTemplate hibernateTemplate) { this.hibernateTemplate = hibernateTemplate; } }setFirstResult和setMaxResults是这套分页能跨库的原因,Oracle 方言下 Hibernate 会自动套rownum子查询。update是全字段更新,如果页面表单漏了某个字段,那个字段会被写成 null,这是这类项目最常见的“数据莫名清空”事故源头,后面第 5 章会专门讲。count.intValue()这个转换在百万级以下没问题,超过了就得改long,不过对一个毕业设计来说,先把功能跑通更实际。
3.2 配送点管理:删除前那道“工作中”校验
论文里对配送点管理写了一条约束:若该配送点正在工作中,则无法删除。这条约束看着不起眼,却是这套系统里少数几个真正的业务规则。落地方式是删除前先查一次状态,是“工作中”就抛异常或返回错误信息,而不是靠数据库外键硬拦——外键只能拦住有引用的,拦不住“状态是工作中但暂时没订单”的情况。
对应的代码在 Service 层做校验,Action 只负责把错误信息带到页面:
// DistributionSiteManageServiceImpl.java —— 删除前先判断站点是否可删 public void deleteDistributionSite(TDistributionSite site) { // 先按主键把完整对象查出来,防止页面传参被篡改状态 TDistributionSite db = distributionSiteManageDao.getById(site.getDistributionSiteId()); if (db == null) { throw new RuntimeException("配送点不存在,可能已被其他人删除"); } if ("WORKING".equals(db.getWorkStatus())) { // 状态位判断,不用异常栈打印,直接抛业务提示 throw new RuntimeException("该配送点正在配送中,请先完成交接单再删除"); } distributionSiteManageDao.deleteDistributionSite(db); }这里有两个容易写错的地方。一是拿页面传上来的对象直接判断,用户可以伪造一个workStatus=FREE的请求绕过去,所以必须回查一次数据库。二是workStatus的取值要和车辆状态、订单状态统一成同一套编码,项目里有的模块用中文、有的用英文,后期做统计报表时要做一层映射,非常别扭,我一般统一成大写英文常量放在一个Constants类里。
配送点还有一条隐含规则:新增配送点时会同步生成一个站点管理员账号,也就是员工表里的一条记录,角色是站点管理员,并且和distributionSiteId绑定。这个绑定关系在权限模块里被反复使用——站点管理员只能看到自己站点的订单和配送范围,靠的就是这一列。论文的类图里SitePower这个类画得比较抽象,实际实现就是一张“账号-站点-权限项”的关联表,改权限就是改这张表的行。
3.3 配送范围与站点权限:一对多关系怎么写才不塌
配送范围是配送点的子实体,一个站点可以划多个范围,每个范围有首重价格、续重价格、配送时长。这类一对多在 Hibernate 里两种写法:单向多对一(子表持父表 id)和双向一对多(父表持 Set)。项目用的是单向,TDistributionScope里存distributionSiteId,父实体不配集合。
单向的好处是不会出现“保存父实体时顺带把子集合同步一遍”的意外级联,坏处是查某站点的所有范围要单独写一条 HQL。在这个业务里,配送范围几乎不会随站点一起被读取,所以单向是对的。如果配了cascade="all"的双向,删除一个站点会把下面所有范围静默删掉,这种级联删除在业务系统里通常是不允许的。
// DistributionScopeManageDaoImpl.java —— 按站点查范围,注意带上站点 id 做隔离 public List queryDistributionScope(String siteId) { // 用命名参数防注入,同时避免拼字符串时漏掉单引号 String hql = "from TDistributionScope s where s.distributionSiteId = :siteId " + "order by s.distributionScopeId asc"; return hibernateTemplate.findByNamedParam(hql, "siteId", siteId); } // 修改范围价格:先按 id 取出,再逐字段赋值,避免全量 update 覆盖掉未修改字段 public void alterDistributionScope(String scopeId, BigDecimal firstPrice, BigDecimal secondPrice) { TDistributionScope scope = (TDistributionScope) hibernateTemplate.get(TDistributionScope.class, scopeId); if (scope == null) { throw new RuntimeException("配送范围不存在"); } scope.setFirstPrice(firstPrice); scope.setSecondPrice(secondPrice); hibernateTemplate.update(scope); }queryDistributionScope一定要带siteId条件。论文第 2.3.3 节写的是“配送点管理员可以查看所管理配送点的配送范围列表”,如果代码里不带这个条件,A 站点管理员就能看到 B 站点的价格,这是权限越界,答辩时是会被追着问的。alterDistributionScope采用“先查再改”的写法,虽然多一次查询,但能保证只动目标字段,比前端传一个完整对象上来直接update安全得多。
4. 订单到交接单再到发车:配送主链路的业务闭环
基础数据维护好之后,真正的业务从订单开始。订单进来、按目的地聚合成交接单、把交接单派给某个司机和某辆车、车发出去、客户签收后交接单才能删。这条链路串起了订单管理、配送管理、交接单管理三个模块,也是整套系统里唯一有“状态流转”的地方,写论文时最容易被问到,写代码时最容易出 bug。
4.1 订单录入与退订:状态位怎么设计才不打架
订单表里最关键的不是金额,是状态。项目里订单状态大致分三种:待配送、配送中、已退订。客户退订时不能物理删除,因为交接单可能已经生成了,删掉订单会让交接单里的明细对不上。所以退订做的是改状态,同时在列表里过滤掉已退订的记录。
这里有个设计上的取舍值得讲。论文里只写了“给予退订操作”,没写退订后交接单怎么办。实际做的时候有两条路:一是退订时检查该订单是否已被交接单引用,被引用就禁止退订,让操作员先撤销交接单;二是允许退订,交接单生成时再过滤一次状态。前者对数据一致性更友好,后者对操作员更宽容。这套项目走的是第一种,代码里有一段引用检查:
// OrderManageServiceImpl.java —— 退订前检查是否已被交接单引用 public void cancelOrder(String orderId) { TOrder order = (TOrder) orderManageDao.getById(orderId); if (order == null || "CANCELED".equals(order.getOrderStatus())) { return; // 幂等:重复点击退订不报错 } // 统计该订单被多少张交接单引用 int refCount = orderManageDao.countHandoverRef(orderId); if (refCount > 0) { throw new RuntimeException("该订单已生成交接单,请先撤销交接单后再退订"); } order.setOrderStatus("CANCELED"); orderManageDao.update(order); }countHandoverRef是一条select count(*),别用“查全部再 size”。方法里做了幂等判断,是因为退订按钮在页面上没有做防重复提交,用户手快点两下很常见,第二次进来订单已经是CANCELED,直接返回比抛异常体验好。状态值用英文常量,前面说过,不要再混中文。
4.2 交接单生成:按配送目的地把订单聚合起来
交接单本质是“一批同去向的订单 + 一个接收人 + 一次运输”。生成逻辑是:选中若干条待配送订单,按收货地址归到同一个配送范围,生成一张主单和若干明细。项目里的实现是先建主单拿到 id,再批量插明细,整个过程必须在一个事务里,否则会出现主单有、明细空的半截数据。
Spring 的事务配在 Service 方法上,PROPAGATION_REQUIRED即可,不需要REQUIRES_NEW——生成交接单是单次操作,不涉及嵌套调用外部事务。
// HandoverServiceImpl.java —— 生成交接单,主单 + 明细必须同一事务 @Transactional // 放在 Service 方法上,Action 不参与事务 public String createHandover(List<String> orderIds, String siteId, String receiver) { if (orderIds == null || orderIds.isEmpty()) { throw new RuntimeException("请至少选择一条订单"); } // 1. 过滤:只保留属于本站点、且状态为待配送的订单 List<TOrder> orders = orderManageDao.findByOrders(orderIds, siteId, "WAIT_SEND"); if (orders.isEmpty()) { throw new RuntimeException("所选订单均不可生成交接单"); } // 2. 建主单,交接单号用站点 + 时间戳,避免并发下重号 THandover handover = new THandover(); handover.setHandoverId(siteId + System.currentTimeMillis()); handover.setSiteId(siteId); handover.setReceiver(receiver); handover.setHandoverStatus("CREATED"); handoverDao.save(handover); // 3. 逐条写明细,并把订单状态推进到已入单 for (TOrder o : orders) { THandoverDetail d = new THandoverDetail(); d.setHandoverId(handover.getHandoverId()); d.setOrderId(o.getOrderId()); handoverDetailDao.save(d); o.setOrderStatus("IN_HANDOVER"); orderManageDao.update(o); } return handover.getHandoverId(); }findByOrders里带了siteId和状态两个条件,这一步是防止跨站点操作和重复入单。交接单号用siteId + 时间戳,简单够用,真要严谨应该用序列或雪花算法,但毕业设计场景下时间戳冲突概率极低。整段逻辑放在@Transactional里,任何一步抛异常都会回滚,包括已经 save 的主单。
4.3 班次调配与发车:车辆状态位是唯一开关
交接单生成后还是“纸面”状态,要指派司机和车辆才算真正进入配送。这一步在两个地方做校验:交接单必须是CREATED,车辆必须是FREE。发车时把车辆状态改成BUSY,把交接单状态改成SENT,两个更新必须在同一事务里,否则会出现“车已占用但单没发出去”或者反过来“单发了车还标着空闲”的鬼数据。
// DeliveryServiceImpl.java —— 调配 + 发车,两次状态更新必须原子 @Transactional public void dispatch(String handoverId, String carId, String employeeId) { THandover h = handoverDao.getById(handoverId); if (h == null || !"CREATED".equals(h.getHandoverStatus())) { throw new RuntimeException("交接单状态不允许调配"); } TCar car = (TCar) carDao.getById(carId); if (car == null || !"FREE".equals(car.getNowStatus())) { throw new RuntimeException("车辆不空闲,请重新选择"); } // 写班次记录:谁、哪辆车、哪张单 TBatch batch = new TBatch(); batch.setHandoverId(handoverId); batch.setCarId(carId); batch.setEmployeeId(employeeId); batch.setBatchStatus("SENT"); batchDao.save(batch); // 双状态推进 car.setNowStatus("BUSY"); carDao.update(car); h.setHandoverStatus("SENT"); handoverDao.update(h); }TCar的nowStatus是唯一开关,别再用第二个字段表示“是否在配送”,两套状态一定会有对不上的那天。并发场景下这段代码有竞态:两个人同时点发车,都查到FREE,都过了校验。要更稳的写法是加乐观锁版本号,或者在更新时带where nowStatus='FREE'条件并检查影响行数。毕业设计里通常不要求,但答辩老师很爱问这一句,提前想好怎么答。
5. 环境搭建与排查:从 Oracle 建表到第一次登录
这套东西跑不起来的概率比跑起来的概率高,不是因为代码烂,而是因为它对环境的依赖很硬:Oracle 不是 MySQL,装错版本、驱动不对、方言配错,任一条都能让你卡一整天。我按实际部署顺序把步骤和坑一起写,照着走能省不少时间。
5.1 建库建表与连接配置
先建表空间和用户,再执行建表脚本。Oracle 里表名和列名默认大写,Hibernate 映射文件里如果写小写又不加引号,启动时就会报“表或视图不存在”,这个报错信息极具误导性,会让人以为是没建表。稳妥做法是映射文件里统一大写,或者干脆在hibernate.cfg.xml里配hibernate.ejb.naming_strategy保持默认。
<!-- applicationContext.xml 中的数据源与 Hibernate 关键配置 --> <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource"> <property name="driverClassName" value="oracle.jdbc.driver.OracleDriver"/> <property name="url" value="jdbc:oracle:thin:@127.0.0.1:1521:orcl"/> <property name="username" value="logistics"/> <property name="password" value="logistics"/> <property name="maxActive" value="20"/> </bean> <bean id="sessionFactory" class="org.springframework.orm.hibernate3.LocalSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mappingResources"> <list> <value>com/logistics/entity/TCar.hbm.xml</value> <value>com/logistics/entity/TOrder.hbm.xml</value> <!-- 其余映射文件逐个列出,漏一个就少一张表 --> </list> </property> <property name="hibernateProperties"> <props> <!-- Oracle 方言必须配对,写成 MySQLDialect 分页直接报语法错 --> <prop key="hibernate.dialect"> org.hibernate.dialect.Oracle9iDialect </prop> <prop key="hibernate.show_sql">true</prop> <prop key="hibernate.format_sql">true</prop> </props> </property> </bean>url里orcl是实例名,不是你建的库名,很多人在这写数据库名然后连不上。mappingResources要一个个列,这是老版本 Spring 的写法,容易漏,漏了不会报错,只会在运行时报“未知实体”。show_sql打开,出问题时能直接看到 Hibernate 生成的 SQL,比猜快得多。
5.2 依赖与部署到 Tomcat
依赖分两块:Hibernate 3 相关的 jar(hibernate3.jar、antlr、commons-collections、javassist、dom4j、ehcache)和 Oracle 驱动ojdbc14.jar。ojdbc 不在 Maven 中央仓库,得手动放进WEB-INF/lib,忘了这一步,启动时报ClassNotFoundException: oracle.jdbc.driver.OracleDriver,一眼能认出来。
Struts2 的struts.xml里要把spring的 objectFactory 配上,否则 Action 里的 Service 永远是 null:
<!-- struts.xml:让 Struts2 从 Spring 容器里取 Action 实例 --> <constant name="struts.objectFactory" value="spring"/> <constant name="struts.i18n.encoding" value="UTF-8"/> <package name="car" extends="struts-default" namespace="/car"> <action name="carManage_*" class="carManageAction" method="{1}"> <result name="carList">/page/car/carList.jsp</result> <result name="success" type="redirectAction"> carManage_queryCarPage </result> </action> </package>class="carManageAction"是 Spring 里的 bean id,不是全类名,写全类名 Struts2 会自己 new 一个,注入全失效。method="{1}"是通配符,请求carManage_addCar会自动调addCar(),省掉一堆配置,但也意味着方法名不能被随便改,改之前先搜一遍 JSP。
5.3 常见问题排查:五个我踩过的坑
现象一:启动报Table or view does not exist,但表明明建了。原因多半是映射文件里写的小写表名,Oracle 自动转成大写存,查询时按小写找就找不到。解决:映射文件<class name="TCar" table="T_CAR">全大写,或者建表时给表名加双引号强制小写(不推荐,后患多)。
现象二:列表页查出来一堆乱码,中文全变问号。原因在三个地方:JSP 页面的pageEncoding、Tomcat 的连接器URIEncoding、数据库的字符集。三处任一不一致就会乱码。解决:JSP 统一<%@ page contentType="text/html;charset=UTF-8"%>,server.xml的 Connector 加URIEncoding="UTF-8",建库时字符集选AL32UTF8,别选ZHS16GBK。
现象三:Action 里的 Service 是 null,一点查询就空指针。原因是没在web.xml里配ContextLoaderListener,Spring 容器压根没起来,或者 Action 在 struts.xml 里写的是全类名而不是 bean id。解决:确认web.xml有 listener 和contextConfigLocation,确认 struts.xml 的 class 属性填的是 bean id。
现象四:查列表没问题,点进详情页报LazyInitializationException。原因是实体里配了lazy="true"的关联集合,在 Action 里访问时 Session 已经关了。解决:要么把关联改成fetch="join"用一条 SQL 拉回来,要么在 Service 层就把需要的集合初始化好(Hibernate.initialize),别把延迟加载的坑留到 JSP 里爆。
现象五:发车时报“车辆不空闲”,但数据库里明明是 FREE。原因是上一个事务没提交,或者页面缓存的旧数据覆盖了新状态。解决:先看show_sql打印的 update 有没有执行,再看 Service 方法上有没有@Transactional,最后检查是不是同一辆车被两张交接单同时占用——这种情况加乐观锁版本号,更新影响行数为 0 就抛业务异常。
6. 二次开发改造:把这套骨架改成能讲清楚的项目
这套代码最大的价值不是功能,是结构。它把“一个业务系统该有哪些层”摆得明明白白,改起来有迹可循。真要动手,我建议按三条线改,改动量和收益比最高。
第一条线是把硬编码收口。项目里分页条数、状态值、角色 id 全是散落的字符串和数字,改一个状态要全局搜索。做法是抽一个Constants类,把CAR_FREE、ORDER_WAIT_SEND、ROLE_SITE_ADMIN这些定义成public static final,再把 JSP 里的判断换成${car.nowStatus == 'FREE'}这种写法,配合<c:if>用常量对比。改完最直接的好处是,后面加“已签收”“已取消”这类新状态时,只需要在一个地方加常量,不用满项目找字符串。
| 改造点 | 原实现 | 建议做法 | 收益 |
|---|---|---|---|
| 状态值 | 中文/英文混用,散落在 JSP 和 Java 里 | 抽Constants常量类 | 加状态不漏改 |
| 分页 | 每页 10 条写死 | 提到配置文件或前端传参 | 列表页可调 |
| 权限 | 靠siteId手工过滤 | 拦截器统一注入当前站点 | 越权问题一次修完 |
| 事务 | 部分方法没加注解 | Service 层统一@Transactional | 半截数据消失 |
第二条线是补权限拦截。现在每个查询方法都靠手动传siteId做隔离,漏一处就是越权。做法是写一个 Struts2 拦截器,登录后把当前账号的siteId和角色放进 Session,拦截器在进入 Action 前把参数里的siteId强制覆盖成 Session 里的值。这样即便前端伪造了参数,也会被拦下来。改完之后,原来那些findBySiteId(siteId)里的参数来源就统一了,代码反而更短。
第三条线是把论文里画了但代码没落地的图补上。论文第 2.6 节画了类图,第 2.7 节画了顺序图,其中“交接单删除”的顺序图写的是“确认接收人已签字后方可删除”,但代码里大概率只做了状态判断,没做签字确认的落库。补法很简单:交接单表加一个SIGN_FLAG字段和一个签字时间,删除前校验这个标志。这类“文档有、代码无”的缺口,正是答辩时最能体现你读懂了的点,也是二次开发最值得做的部分。
最后说个习惯。我每次接手这种别人写的 SSH 老项目,第一件事不是看代码,是把show_sql打开,把登录、查列表、新增、删除四条主流程各点一遍,把打印出来的 SQL 全部复制到一个文本里。这四段 SQL 就是这个系统的“血泪经验地图”——哪张表被谁改、哪个字段在什么地方被更新,一目了然。从那以后我每次改这类项目都强制走一遍这个流程,比通读代码快得多,也少踩很多“改了 A 处 B 处崩”的坑。希望帮到你。
本文还有配套的精品资源,点击获取