
简介基于SSM的物流管理系统是一套完整可运行的项目源码适合正在做课程设计、毕业设计或需要学习Spring、SpringMVC、MyBatis框架整合的开发者参考。系统面向管理员、员工、客户三类角色实现员工与客户管理、基础信息维护、货物信息与运输跟踪、统计报表及反馈处理等核心功能界面采用EasyUI与jQuery业务覆盖物流配送全流程。资源包共含3305个文件整体约78.76MB以Java源码、JSP页面、MySQL数据库脚本、XML配置以及png/gif图片、css/js前端文件为主既包含后端逻辑也包含可直接使用的界面资源并额外提供项目文档与软件工具。所有代码均经过严格调试可稳定运行支持远程调试系统内置多角色权限控制目录结构清晰便于二次开发。目前已有1807人学习下载对理解SSM项目分层、物流业务数据设计和前后端交互有实际参考价值。1. 三个角色共用一套代码的 SSM 物流系统先分清边界再谈实现一套物流管理系统管理员、员工、客户三种角色都在同一个 JSP 工程里操作表面看是权限过滤问题实际是业务边界和状态机设计问题。这套基于 SSM 的物流管理系统也可当快递物流、物流配送系统使用正是典型做法Spring 管理对象、SpringMVC 处理请求、MyBatis 执行 SQL前端用 EasyUI 和 jQuery 搭后台操作界面MySQL 存全部业务数据。项目里 UserAction、YonghuAction、SpchuAction、SpjinAction、UzhanneiAction 这一类类名保留了老 Java Web 项目的拼音命名习惯也完整保留了从浏览器请求到 Service 再到 Mapper 的调用链。对做数据库课程设计、需要一套能直接运行的 SSM 项目做二次开发、或者想搞懂 EasyUI DataGrid 与 MyBatis 分页如何协作的人这套代码值得拆开看。下文直接从表结构、映射文件、页面交互和 Tomcat 部署排错四层展开每步都可以照着改。2. 从业务反推 MySQL 表结构运输状态机与角色权限落库2.1 货物运输的生命周期决定核心字段物流系统的核心不是四个字的“增删改查”而是货物状态能不能在不同角色之间持续流转。客户下单后货物进入站点员工操作入库、出库、装车运输途中更新运单进展到达后客户签收或员工确认异常件还要单独标记。落到数据库里就是货物主表 goods 的 status 字段与运输表 transport 的 status 字段在联动。只设置一张表的字段会造成列表页状态和详情页不一致列表显示“运输中”详情却看不到承运方和发车时间客户打电话来问进展时只能靠手翻记录。一般把状态定义成整型枚举存库前端用 formatter 转成中文。表结构里存数字界面上不直接读数字好处是扩展新状态不需要动数据库脚本比如中途增加“已到达待派送”时只加一个枚举值。常见状态定义如下status含义可操作角色0待入库员工1在库员工2运输中员工可操作客户只读3已签收员工/客户4异常管理员角色权限不要只靠前端隐藏按钮后端每个 Action 方法都要做一次身份校验否则绕过页面直接 POST 就能把货物状态改掉。这个小项目里权限和状态是两条独立线索但会汇聚在运输操作上出库时校验当前登录人是员工角色同时校验货物状态是“在库”两个条件缺一个就拒绝执行。2.2 六张核心表的建表 SQL从项目功能描述反推至少需要用户、站点、货物、运输、配送范围、反馈六类数据。下面是最小可运行的 MySQL 建表脚本编码统一 utf8mb4引擎 InnoDB。CREATE DATABASE wuliu DEFAULT CHARACTER SET utf8mb4; USE wuliu; CREATE TABLE sys_user ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 登录密码, role TINYINT NOT NULL COMMENT 1管理员 2员工 3客户, real_name VARCHAR(50) COMMENT 真实姓名, phone VARCHAR(20) COMMENT 联系电话, station_id INT COMMENT 所属站点客户可空, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_role (role) ) ENGINEInnoDB COMMENT系统用户; CREATE TABLE station ( station_id INT PRIMARY KEY AUTO_INCREMENT, station_name VARCHAR(100) NOT NULL, city VARCHAR(50), address VARCHAR(200), manager VARCHAR(50), phone VARCHAR(20) ) ENGINEInnoDB COMMENT站点; CREATE TABLE goods ( goods_id INT PRIMARY KEY AUTO_INCREMENT, goods_no VARCHAR(32) NOT NULL UNIQUE COMMENT 运单号, goods_name VARCHAR(100), goods_type VARCHAR(20) COMMENT 普货/易碎/冷链, weight DECIMAL(10,2), volume DECIMAL(10,2), status TINYINT DEFAULT 0 COMMENT 0待入库 1在库 2运输中 3已签收 4异常, sender_name VARCHAR(50), sender_phone VARCHAR(20), receiver_name VARCHAR(50), receiver_phone VARCHAR(20), station_id INT COMMENT 当前所在站点, user_id INT COMMENT 经手员工, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_status (status), KEY idx_goods_no (goods_no) ) ENGINEInnoDB COMMENT货物; CREATE TABLE transport ( transport_id INT PRIMARY KEY AUTO_INCREMENT, goods_id INT NOT NULL, from_station INT, to_station INT, carrier VARCHAR(50) COMMENT 承运方, driver_phone VARCHAR(20), status TINYINT DEFAULT 0 COMMENT 0待发车 1运输中 2已到达 3异常, start_time DATETIME, arrive_time DATETIME, remark VARCHAR(255), KEY idx_goods_id (goods_id), KEY idx_status (status) ) ENGINEInnoDB COMMENT运输任务; CREATE TABLE delivery_range ( range_id INT PRIMARY KEY AUTO_INCREMENT, region VARCHAR(100) COMMENT 配送区域, station_id INT, cost DECIMAL(10,2) COMMENT 配送费, estimated_days INT COMMENT 预计天数, enable TINYINT DEFAULT 1 ) ENGINEInnoDB COMMENT配送范围; CREATE TABLE feedback ( feedback_id INT PRIMARY KEY AUTO_INCREMENT, user_id INT COMMENT 提交人, content VARCHAR(500), reply VARCHAR(500), status TINYINT DEFAULT 0 COMMENT 0未回复 1已回复, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT反馈信息;说明几个容易被忽略的点。goods 表单独存收发件人冗余字段而不是单独关联两张客户表是为了保证运单快照不被客户资料修改影响这也是实际物流系统里常见的做法客户改了手机号不影响历史运单。transport 表不直接改 goods 状态而是由 Service 层在同一个事务里把 goods.status 改为 2同时插入一条 transport 记录两张表的状态字段始终由同一段代码维护。运单号 goods_no 做成唯一键前端生成时用时间戳加随机数即可不要用主键自增去当运单号会泄露单量。station_id 在 sys_user 和 goods 里都有查询“某站点员工处理过哪些货”时可以直接关联不用额外建关联表。2.3 配送范围与费用计算的查询逻辑客户角色有“配送范围”菜单本质是查 delivery_range 表并按站点过滤。客户下单时前端选中区域后端要返回该区域的送达费用和预计天数这个查询很简单但注意要过滤 enable 字段停用的配送范围不能参与下单。SELECT r.range_id, r.region, r.cost, r.estimated_days, s.station_name FROM delivery_range r LEFT JOIN station s ON r.station_id s.station_id WHERE r.enable 1 AND r.station_id #{stationId} ORDER BY r.cost;LEFT JOIN 在这里是必要的因为配送范围可能因为站点调整暂时没有绑定站点用内连接会把这部分范围记录过滤掉界面上员工会看到区域数量突然变少。cost 字段建议用 DECIMAL(10,2) 而不是 FLOAT避免订单金额出现 0.1 加 0.2 不等于 0.3 这类浮点误差。2.4 统计信息模块的 SQL 实现项目功能里有统计信息常见需求是“当月运单总量、签收量、异常量”“各站点运输量排行”。统计 SQL 直接落到 MySQL 聚合查询不要在 Java 里做循环累加前者一条语句完成后者要多次连接数据库。SELECT DATE_FORMAT(create_time, %Y-%m) AS month, COUNT(*) AS total_count, SUM(CASE WHEN status 3 THEN 1 ELSE 0 END) AS signed_count, SUM(CASE WHEN status 4 THEN 1 ELSE 0 END) AS abnormal_count FROM goods WHERE create_time DATE_SUB(CURDATE(), INTERVAL 6 MONTH) GROUP BY DATE_FORMAT(create_time, %Y-%m) ORDER BY month DESC;这条语句在 SSM 里只需要一个 Mapper 方法返回 ListMapString,Object 就能直接给前端统计页面使用。CURDATE 与 DATE_SUB 配合保证统计区间随系统日期自动滚动不用在每年初改代码。3. SSM 集成与 Action 层职责UserAction 之外的隐藏工作流3.1 工程结构与配置文件的组织方式SSM 工程通常按 controller、service、mapper、entity、util 分包。源码包里能看到 UserAction.class、SpchuAction.class 这类编译后的类文件说明这是传统 Web 工程直接编译到 WEB-INF/classes 的布局不是标准 Maven 目录。用 IDEA 导入时选择 Web Application 方式把 lib 目录下的 jar 包挂进项目依赖就能省去 Maven 重新下载的环节当然也可以按 Maven 骨架重排依赖改用坐标声明后续维护更顺手但工作量会大一些。Spring 与 SpringMVC 的容器边界要分清Spring 容器扫描 service、mapper 等业务组件SpringMVC 容器只扫描 controller并加载视图解析器和处理器适配器。如果在 Spring 的 applicationContext.xml 里也扫描了 Controller会遇到事务代理失效和请求映射混乱的现象。常见做法是 DataSource 用 Druid 或 C3P0SqlSessionFactoryBean 注入 DataSource 并指定 mapper-locations最后用 MapperScannerConfigurer 扫描 Mapper 接口。下面的配置是可以直接套用的骨架!-- spring-mybatis.xml 核心片段 -- context:component-scan base-packagecom.wuliu.service/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/wuliu?useUnicodetrueamp;characterEncodingutf8amp;useSSLfalseamp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword value123456/ property namemaxActive value20/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:com/wuliu/mapper/*.xml/ property nametypeAliasesPackage valuecom.wuliu.entity/ property nameconfigLocation valueclasspath:mybatis-config.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.wuliu.mapper/ /bean参数含义url 里的 useUnicode 与 characterEncoding 必须成对出现漏掉 useSSLfalse 在 MySQL 8.0 驱动下会持续告警serverTimezone 不加用 mysql-connector-java 8.x 连本地 MySQL 5.7 经常抛时区异常。maxActive 只是连接池参数之一完整配置还要考虑 initialSize、minIdle、maxWait课程项目里按 20 配已经足够。mybatis-config.xml 里通常会开 mapUnderscoreToCamelCasetrue让 user_id 自动映射到 userId少写大量 resultMap。3.2 Action 类命名映射出的业务模块这套项目的类名按拼音来看类名就能还原业务模块。UserAction 负责登录、注销和管理员个人信息YonghuAction 负责员工和客户的账号管理界面SpchuAction 处理货物出库SpjinAction 处理货物入库sp 对应“商品/货品”chu/jin 对应“出/进”UzhanneiAction 从拼写上对应“用户站内”或“站内业务”在物流场景里最合理的职责是站内调拨或站内货物状态维护。面对这种老代码第一件事不是改代码而是画一张类与功能的对照表。类名推测业务对应页面UserAction登录、管理员信息login.jsp、admin.jspYonghuAction员工/客户账号管理user_list.jspSpjinAction货物入库登记goods_in.jspSpchuAction货物出库/生成运输任务goods_out.jspUzhanneiAction站内调拨、库存调整station_inner.jsp这个推断方法值得记下来在老项目里根据拼音猜类名业务比直接全局搜中文关键词更可靠因为很多 JSP 页面的提交地址就写在后端类名上。改造时如果觉得类名不够直观重命名 Action 不影响功能但要注意 SpringMVC RequestMapping 的路径也要同步改JSP 里所有引用这个地址的 ajax 和表单都要查一遍。3.3 登录拦截器与角色权限的落地写法三种角色共用一套页面最怕的是直接输入 URL 绕过登录页。所以 SpringMVC 工程里通常会配一个 HandlerInterceptor 做登录和角色校验。拦截器的执行顺序比在 Controller 里重复写 session 判断要可控得多。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); Object loginUser session.getAttribute(loginUser); if (loginUser null) { response.sendRedirect(request.getContextPath() /login.jsp); return false; } // 这里还可以继续校验角色和请求路径的匹配关系 return true; } }注册拦截器时要注意 excludePathPatterns把登录请求、静态资源、EasyUI 的 css/js/font 全部排除掉否则登录页本身都加载不出来。mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/css/**/ mvc:exclude-mapping path/js/**/ mvc:exclude-mapping path/assets/**/ bean classcom.wuliu.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptors角色校验不建议写在同一个拦截器里做细致分支因为管理员、员工、客户能访问的路径会随需求调整。更稳的做法是拦截器只保证“已登录”角色权限在具体 Action 方法入口用注解或自定义校验方法判断改动单点功能时不容易误伤其他角色。3.4 Controller、Service、Mapper 三层协作示例以 SpchuAction 的出库动作为例一个完整的出库包含三个操作校验货物状态、更新货物状态、插入运输记录。这三步必须在一个事务里否则会出现货物已出库但运输单没生成的脏数据。Controller RequestMapping(/spchu) public class SpchuAction { Autowired private TransportService transportService; RequestMapping(/out) ResponseBody public MapString, Object out(RequestParam Integer goodsId, RequestParam Integer toStation, RequestParam String carrier, RequestParam String driverPhone) { MapString, Object result new HashMap(); try { // 状态校验放到 Service 内部Action 不做业务判断 transportService.createTransport(goodsId, toStation, carrier, driverPhone); result.put(code, 200); result.put(msg, 出库成功); } catch (IllegalStateException e) { result.put(code, 400); result.put(msg, e.getMessage()); } catch (Exception e) { result.put(code, 500); result.put(msg, 系统异常: e.getMessage()); } return result; } }Service 层实现里必须用 Transactional这里的关键在于 Spring 默认只对 RuntimeException 回滚。物流业务里“货物不在库”“目的站点不存在”这类校验失败如果直接抛 Exception事务不会自动回滚正确做法是抛 IllegalStateException 或定义一个继承 RuntimeException 的 BusinessException。Service public class TransportServiceImpl implements TransportService { Autowired private GoodsMapper goodsMapper; Autowired private TransportMapper transportMapper; Override Transactional(rollbackFor BusinessException.class) public void createTransport(Integer goodsId, Integer toStation, String carrier, String driverPhone) { Goods goods goodsMapper.selectById(goodsId); if (goods null || goods.getStatus() ! 1) { throw new BusinessException(当前货物不可出库需在库状态才能出库); } goods.setStatus(2); goodsMapper.updateStatus(goods); Transport transport new Transport(); transport.setGoodsId(goodsId); transport.setFromStation(goods.getStationId()); transport.setToStation(toStation); transport.setCarrier(carrier); transport.setDriverPhone(driverPhone); transport.setStatus(0); transportMapper.insert(transport); } }注意代码里 insert 后并没有把 transportId 返回给页面因为页面只需要知道成功或失败后续查询列表自然能看到这条运输任务。如果需要回显运单号Service 方法可以改成返回 Transport 对象。这里一个容易踩的坑是 goodsMapper.updateStatus(goods) 整条记录更新会把其他字段覆盖成 null所以 Mapper 里的更新 SQL 尽量只 set 需要变更的字段或者先查询完整实体再更新。3.5 MyBatis 动态 SQL 与多表关联查询运输列表页通常要同时展示运单号、货物名称、出发站和目的站这需要 transport 关联 goods 和 station 两张表。resultType 直接用实体类也可以但字段命名容易对不上用 resultMap 更稳。select idselectTransportPage resultMaptransportResult SELECT t.transport_id, t.goods_id, t.from_station, t.to_station, t.carrier, t.driver_phone, t.status, t.start_time, t.arrive_time, g.goods_no, g.goods_name, g.status AS goods_status, s1.station_name AS from_station_name, s2.station_name AS to_station_name FROM transport t LEFT JOIN goods g ON t.goods_id g.goods_id LEFT JOIN station s1 ON t.from_station s1.station_id LEFT JOIN station s2 ON t.to_station s2.station_id where if testgoodsNo ! null and goodsNo ! AND g.goods_no LIKE CONCAT(%, #{goodsNo}, %) /if if teststatus ! null and status ! -1 AND t.status #{status} /if /where ORDER BY t.create_time DESC LIMIT #{offset}, #{limit} /select这里的 标签会自动处理第一个条件前的 AND比直接写 WHERE 11 更干净。LIKE 查询用 CONCAT 拼接通配符而不是参数里已经带 %因为员工偶尔会在搜索框输入 % 导致查出全部数据。LEFT JOIN 保证即使站点信息被删除运输记录依然能查出来。LIMIT 的 offset 由 PageHelper 或手写分页工具计算后传入SQL 里不要直接写 LIMIT #{page}, #{rows}语义不对。4. EasyUI JSP 前端DataGrid 分页与货物状态流转4.1 页面结构与菜单权限的联动EasyUI 后台布局是典型的上下-左-右结构顶部标题栏左侧 accordion 菜单右侧 tabs 承载具体页面。菜单用动态数据渲染比写死 HTML 更容易做权限控制常见做法是登录成功后把用户角色放进 sessionJSP 里用 JSTL 的 c:if 判断菜单是否显示。div idmenu_box c:if test${sessionScope.role 1} div classmenu-item onclickaddTab(员工管理, ${ctx}/yonghu/list)员工管理/div div classmenu-item onclickaddTab(统计信息, ${ctx}/stats/index)统计信息/div /c:if c:if test${sessionScope.role 1 || sessionScope.role 2} div classmenu-item onclickaddTab(货物入库, ${ctx}/spjin/in)货物入库/div div classmenu-item onclickaddTab(货物出库, ${ctx}/spchu/out)货物出库/div /c:if div classmenu-item onclickaddTab(货物查询, ${ctx}/goods/list)货物查询/div /div这种写法要同时配合后端拦截器不能只依赖 JSP 判断。前端隐藏只是体验优化后端接口必须根据登录用户的角色做二次校验防止直接输入 URL 访问无权页面。菜单和接口之间的权限映射建议维护一张菜单编码到 Action 路径的对照表比在拦截器里逐个写路径要省事。4.2 DataGrid 表格与后端 JSON 格式的对接EasyUI DataGrid 的数据格式固定为{total: 总数, rows: 行数据}。实际开发中最常踩的坑就是接口返回了 List 而不是带有 total 的 Map导致表格始终渲染不出数据控制台还看不到错误。后端标准写法如下参数名 page、rows 是 DataGrid 默认传过来的不能改成 start、limit 这类自命名参数。Controller RequestMapping(/transport) public class TransportAction { Autowired private TransportService transportService; RequestMapping(/list) ResponseBody public MapString, Object list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer rows, String goodsNo, Integer status) { // 使用 PageHelper 分页插件startPage 后紧跟查询才有效 PageHelper.startPage(page, rows); ListTransportVO list transportService.queryPage(goodsNo, status); PageInfoTransportVO pageInfo new PageInfo(list); MapString, Object result new HashMap(); result.put(total, pageInfo.getTotal()); result.put(rows, pageInfo.getList()); return result; } }前端页面不需要手动发 AjaxDataGrid 在初始化时自动请求 url查询条件通过 queryParams 传。注意 PageHelper 的 startPage 必须写在查询语句之前且同线程内中间不能再执行其他 SQL否则分页会作用到错误的查询上。table iddg classeasyui-datagrid stylewidth:100%;height:460px >div idtb stylepadding:5px;height:auto span运单号:/span input idsearch_goodsNo stylewidth:150px span状态:/span select idsearch_status option value-1全部/option option value0待入库/option option value1在库/option option value2运输中/option option value3已签收/option /select a hrefjavascript:searchData() classeasyui-linkbutton查询/a /divfunction searchData() { $(#dg).datagrid(load, { goodsNo: $(#search_goodsNo).val(), status: $(#search_status).val() }); } function statusFormatter(value, row, index) { var map {0: 待入库, 1: 在库, 2: 运输中, 3: 已签收, 4: 异常}; return map[value] null ? 未知 : map[value]; } function opFormatter(value, row, index) { if (row.status 2) { return a hrefjavascript:signTransport( value )签收/a; } return ; }下拉框里的 value 用字符串还是数字要统一。如果 select 的 option value 写成数字jQuery 的 val() 返回的是字符串而后端用 Integer status 接收MyBatis 判断 status ! -1 时字符串 “-1” 和数字 -1 会走出不一样的分支。我在实际项目里习惯把“全部”的 value 设为 -1后端判断统一用status ! null and status ! -1这样新增状态枚举时不需要改这条判断。4.4 货物状态流转的 ajax 操作页面上的“签收”“发车”“到达”按钮本质是同一个模式确认当前状态执行状态迁移。开发时把状态迁移逻辑收敛到 Service 层而不是每个页面写一段 updateStatus 调用否则很容易出现客户在待发车状态点了签收的业务漏洞。合法的状态迁移路径应该先定义清楚当前状态允许操作目标状态操作角色0 待入库入库1 在库员工1 在库出库/异常2 运输中/4 异常员工2 运输中到达/异常2 已到达/4 异常员工2 已到达签收3 已签收员工/客户签收操作的前端代码可以统一成一个函数传不同的状态值进去复用function signTransport(transportId) { $.messager.confirm(确认, 确认该货物已由客户签收, function(r) { if (!r) { return; } $.ajax({ url: ctx /transport/sign, type: POST, data: {id: transportId}, dataType: json, success: function(data) { if (data.code 200) { $.messager.show({title: 提示, msg: 签收成功}); $(#dg).datagrid(reload); } else { $.messager.alert(提示, data.msg, error); } } }); }); }后端 sign 接口的实现在事务里同时做两件事把 transport.status 改为 2已到达或 3已签收把 goods.status 改为 3。这里必须用状态条件更新来避免并发重复操作简单做法是 SQL 里带上状态条件UPDATE transport SET status2 WHERE transport_id#{id} AND status1返回影响行数为 0 说明已经有人操作过直接提示“该运单已处理”。5. 从源码到完美运行Tomcat 部署与远程调试的关键检查点5.1 数据库导入要过的三条检查线拿到 SQL 脚本不要直接双击执行。先检查脚本里建库语句用的字符集再检查数据表引擎是否为 InnoDB最后确认导入工具的连接字符集。常见错误是脚本头部声明 utf8但 Navicat 本地连接用了 gbk中文备注全部变成乱码另一种情况是 SQL 文件里混入了旧库的 DROP 语句在 MySQL 8.0 上执行报错中断。稳妥方式是命令行导入mysql -uroot -p --default-character-setutf8mb4 wuliu.sql导入完成后用SHOW TABLE STATUS确认表引擎和行数再执行几条 SELECT 看中文是否正常。这一步能过滤掉大部分“运行不起来其实是数据没导对”的情况。5.2 配置文件里最容易翻车的三个位置第一处是 JDBC 的 url。老项目里常见jdbc:mysql://localhost:3306/wuliu不带参数在 MySQL 5.7 加 mysql-connector-java 5.1.49 能跑换到 8.0.33 驱动就报 SSL 和时区错误补上 useSSLfalse、characterEncodingutf8、serverTimezoneAsia/Shanghai 即可。第二处是 mybatis 的 mapper-locations 路径配置文件在 resources 根目录时写成 classpath:mapper/.xmlXML 和 Mapper 接口同一包时用 classpath:com/wuliu/mapper/*.xml 更稳。第三处是 Spring 扫描范围如果 Spring 容器和 SpringMVC 容器扫描了同一个 Controller启动时会出现多次映射的报错。5.3 用 JDWP 做远程调试的最佳姿势源码包说明里写了可远程调试实际就是给 Tomcat 开启 JDWP 端口。Windows 下运行 catalina.bat jpda start默认端口 8000Linux 下编辑 catalina.sh 设置 JPDA_ADDRESS8000 后再启动。IDEA 里新建 Remote 配置Host 填服务器 IPPort 填 8000直接 Debug 启动断点就能在本地命中远端代码。注意两个细节。一是 Tomcat 9 之后 JDWP 默认只监听 localhost跨机器调试需要把 JPDA_ADDRESS 写成0.0.0.0:8000否则本地连接总是被拒二是服务器防火墙只放行 8000 端口即可这个端口只服务调试协议不承载业务请求。调试完成后立刻停掉 jpda 模式避免调试端口长期暴露。如果本地代码和服务器代码版本不一致断点会错行所以远程调试前先把两端代码同步到同一个提交点这是排查“命中断点但变量值全不对”的最优先手段。本文还有配套的精品资源点击获取