SSM快递管理系统实战:框架整合、动态SQL与状态机设计 📅 发布时间:2026/9/17 2:42:22 👁 浏览次数: 简介基于SSM框架Spring MVC Spring MyBatis与MySQL数据库实现的快递管理系统源码专为Java课程设计、毕业设计以及SSM入门学习者打造涵盖快递信息录入、查询、状态管理等常见业务模块。整个资源包共857个文件压缩后约78.41MB涵盖Java源码、JSP页面、XML配置、SQL数据库脚本、JAR依赖库、CSS样式及界面截图与GIF演示动画等类型目录结构清晰便于按模块学习和排查问题。系统本身具有操作简单、灵活性好、安全性高、运行稳定等特点可根据实际应用场景适当修改和扩展。目前已有103人学习下载配套内容基本覆盖从环境配置、数据库设计到前后端交互的完整链路适合用来快速搭建SSM项目、理解整合流程并在此基础上完成课程设计或毕业设计。1. 拿到 ssm.zip 之后先别急着解压运行拿到基于SSM快递管理系统ssm.zip这个压缩包的人第一反应通常都是解压、导入 IDE、点 Tomcat 启动。我见过不少人在这一步卡上一整天JSP 页面 404MyBatis 报 Invalid bound statement中文乱码页面能开但登录永远失败。SSM 项目写业务代码只占三成工作量剩下七成都在处理 Spring 容器、SpringMVC 路由和 MyBatis 映射这三层之间的咬合。这个快递管理系统正好是一个标准的练手样本JSP 做前台展示Controller 收口请求Service 处理业务规则Mapper 负责 SQL。适合两类人一类是用它完成课程设计的在校生另一类是要快速接手老代码做功能改造的在职工程师。前者先跑起来并看懂每一段配置后者重点看它的状态设计和二次开发边界。2. 解包后从哪下手SSM 快递管理系统的目录结构与三套配置链路2.1 先认清这份 SSM 源码的骨架解开 zip 之后不要急着开 IDE先扫一遍目录。一个标准的 Maven 结构 SSM 项目长这样express-ssm/ ├── pom.xml ├── src/main/java/com/express/ │ ├── controller/ # SpringMVC 控制器 │ ├── service/ # 业务接口 │ │ └── impl/ # 业务实现 │ ├── mapper/ # MyBatis Mapper 接口 │ ├── entity/ # 实体类 │ └── common/ # 常量、工具类、拦截器 ├── src/main/resources/ │ ├── applicationContext.xml # Spring 根容器 │ ├── springmvc.xml # SpringMVC 子容器 │ ├── mybatis-config.xml # MyBatis 全局配置 │ ├── db.properties # 数据源 │ └── mapper/ # Mapper XML └── src/main/webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ # JSP 页面 └── static/ # css/js/images先看根目录有没有pom.xml。有就说明它依赖 Maven 管理需要你本地配好 Maven 和 JDK如果没有 pom.xml 而只有WebContent和lib那就是 Eclipse Dynamic Web Project依赖全在 lib 文件夹里手动维护导入后要额外小心 jar 版本冲突。再看src/main/resources/mapper/下的 XML 数量和mapper/接口数量是否一一对应。SSM 项目最常见的启动失败原因就是接口方法写了一堆XML 里漏写了一条 statementSpring 启动时直接抛异常。2.2 web.xml 决定 Spring 与 SpringMVC 的启动顺序web.xml是这个项目的启动总指挥。Spring 根容器和 SpringMVC 子容器必须严格按照先父后子的顺序创建否则 Service 注入不进 Controller。web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd version3.1 listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:springmvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping /web-app这里有两个关键点。第一ContextLoaderListener必须在 DispatcherServlet 之前声明它负责加载applicationContext.xml把 Service、Mapper、事务管理器放进父容器DispatcherServlet 只加载springmvc.xml父子容器通过getParent()关联。第二url-pattern用/而不是/*。用/*会让 JSP 也被 DispatcherServlet 拦截HandlerMapping 匹配不到对应的 Controller页面全部走 404。2.3 三份 XML 配置文件的职责边界很多 SSM 混了半年的人还分不清三份 XML 谁管谁。放到快递管理系统里对应关系非常清楚配置文件管哪些对象典型配置项applicationContext.xmlService、Mapper、数据源、事务context:component-scan、SqlSessionFactoryBean、MapperScannerConfigurer、DataSourceTransactionManagerspringmvc.xmlController、视图解析、静态资源context:component-scan、mvc:annotation-driven/、InternalResourceViewResolvermybatis-config.xmlMyBatis 自身行为下划线转驼峰、别名、日志、二级缓存三者不是并列关系。applicationContext.xml 是根容器springmvc.xml 是它的子容器mybatis-config.xml 通过 SqlSessionFactoryBean 被 applicationContext.xml 加载。我也见过把 mybatis-config.xml 的内容全部塞进 SqlSessionFactoryBean 属性的写法项目一样能跑但排查问题时还是独立成文件更清爽。补一句现在翻出来改这类 SSM 项目时别急着把 Spring 升到 5.3、MyBatis 升到 3.5。老代码里大量拦截器和 JSP 标签是跟着 Spring 4.x/5.0 那代走的升版本连web-app的 schema 都要换得不偿失。2.4 MapperScannerConfigurer 是 SSM 整合的粘合点Service 里Autowired一个 Mapper 接口为什么不用写实现类就能注入答案全在 applicationContext.xml 这段配置里context:component-scan base-packagecom.express.service/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nameconfigLocation valueclasspath:mybatis-config.xml/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.express.mapper/ /bean tx:annotation-driven transaction-managertransactionManager/ bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /beansqlSessionFactory里的mapperLocations负责把resources/mapper/下的 XML 全部加载成 statementMapperScannerConfigurer负责扫描com.express.mapper包下的接口为每个接口生成动态代理。这两者缺一不可而且basePackage必须和 XML 文件的 namespace 指向同一个接口。提示一旦启动时报Invalid bound statement (not found)先检查 target/classes 里有没有对应的mapper/*.xml。IDEA 默认只拷贝 resources 目录如果 XML 放在 java 源码目录下就会漏编译需要在 pom.xml 里加resources配置把 mapper 目录也打进去。3. 快递订单表设计成什么样MyBatis 动态 SQL 才不会写崩3.1 快递订单核心表运单主表加轨迹表SSM 快递管理系统不管界面做得多花哨核心数据一定落在这两张表上。第一张是运单主表记录快递从揽收到签收的所有静态信息第二张是轨迹表记录每一次状态变化的操作人和时间。CREATE TABLE express_order ( id INT NOT NULL AUTO_INCREMENT, express_no VARCHAR(32) NOT NULL COMMENT 运单号界面可扫可输, sender_name VARCHAR(32) NULL COMMENT 寄件人, sender_phone VARCHAR(20) NULL, sender_address VARCHAR(200) NULL, receiver_name VARCHAR(32) NOT NULL, receiver_phone VARCHAR(20) NOT NULL, receiver_address VARCHAR(200) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待揽收 1已揽收 2运输中 3派送中 4已签收 5异常, station_id INT NULL COMMENT 当前处理网点, operator_id INT NULL COMMENT 最近操作员, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_express_no (express_no), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT快递运单主表; CREATE TABLE express_track ( id BIGINT NOT NULL AUTO_INCREMENT, express_no VARCHAR(32) NOT NULL, status_before TINYINT NULL COMMENT 操作前状态, status_after TINYINT NULL COMMENT 操作后状态, node_desc VARCHAR(100) NULL COMMENT 例如快件已到达XX网点, operator_id INT NULL, operate_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_express_no (express_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT运单轨迹表;状态字段我建议用TINYINT而不是VARCHAR。原因很实际列表页高频按 status 过滤int 类型字段加上索引后查询效率更高而且状态是有限集合用数字可以在 Java 侧用枚举统一管理而不是在 SQL 里到处写WHERE status 运输中哪天有人把汉字改了一个标点全表数据就废了。express_no必须加唯一索引这是快递业务里唯一的业务主键寄件人、客服、派送员都靠它沟通。注意不要让express_no直接用自增 id因为运单号一般要带网点前缀和日期用单独字段更灵活。3.2 多条件列表查询用 where if 动态拼 SQL快递管理系统的列表页通常有三个筛选项运单号、状态、当前网点。用 MyBatis 的动态 SQL 做成一个方法比写三个固定 SQL 再在 Service 里 if 判断要干净得多。select idselectByCondition resultTypecom.express.entity.ExpressOrder SELECT express_no, sender_name, sender_phone, sender_address, receiver_name, receiver_phone, receiver_address, status, station_id, operator_id, create_time, update_time FROM express_order where if testexpressNo ! null and expressNo ! AND express_no #{expressNo} /if if teststatus ! null AND status #{status} /if if teststationId ! null AND station_id #{stationId} /if /where ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select select idcountByCondition resultTypelong SELECT COUNT(1) FROM express_order where if testexpressNo ! null and expressNo ! AND express_no #{expressNo} /if if teststatus ! null AND status #{status} /if if teststationId ! null AND station_id #{stationId} /if /where /selectwhere标签会自动去掉第一个条件前面的AND这样不用在 SQL 里写WHERE 11。#{}是预编译占位符MyBatis 最终会把它替换成?再走 PreparedStatement天然防 SQL 注入。分页参数我习惯直接用offset和pageSize而不是拼在 SQL 字符串里因为 LIMIT 后面如果用了${}等于把前端参数直接拼进 SQL这是典型的注入入口。注意if判断里写的是status ! null这意味着传入status0时会走到AND status 0。如果用 OGNL 写status ! and status ! nullMyBatis 会先把 0 自动转成字符串再比较0 不等于空串结论没变但凭空多一层转换代码读起来也别扭。3.3 MyBatis 列名映射与 #{}/${} 的坑SSM 老项目里实体类属性几乎都是驼峰命名而数据库列名是下划线两者不配合就会出各种诡异问题。最快的解决方案是在mybatis-config.xml里开全局开关configuration settings setting namemapUnderscoreToCamelCase valuetrue/ setting namelogImpl valueSTDOUT_LOGGING/ /settings /configurationmapUnderscoreToCamelCase开启后receiver_name自动映射到receiverName。logImpl设成STDOUT_LOGGING是把执行的 SQL 打到控制台排错时能直接看到参数绑定情况代价是日志量变大线上项目记得关掉。常见的三个坑列成表方便对号入座现象直接原因处理方式查出来 List 有数据但每个对象的属性全是 null没开mapUnderscoreToCamelCase且没写 resultMap开全局设置或在具体 XML 里写 resultMap启动报 Invalid bound statementmapper 接口包名与 XML namespace 不一致三者统一接口全限定名、namespace、mapper的 resource 路径中文写入 MySQL 变问号jdbc.url 没带编码参数连接串加characterEncodingutf8MySQL 8 还要加serverTimezoneAsia/Shanghai另外${}和#{}的分工要拎清。#{}用于占位参数${}用于拼接结构比如ORDER BY ${sortColumn}。我一般把排序字段白名单写在 Service 里前端传create_time或statusService 映射成固定字符串再交给${}绝不直接把请求参数传进 XML。4. 快递管理系统核心流程从揽收到签收的一条完整调用链4.1 快递状态机与接口对应关系快递系统本质是一个状态机系统。我在设计接口时会把状态迁移直接写进接口文档开发时不至于各写各的动作状态迁移对应接口主要校验揽收0 → 1POST /order/collect运单号不能重复必须填寄收件人发车1 → 2POST /order/transport运单属于当前网点派送2 → 3POST /order/deliver必须指定派送员签收3 → 4POST /order/sign只有派送中状态能签收异常3 → 5POST /order/exception必须填写异常原因状态用数字 0 到 5数据库里不存中文描述。前端展示时再映射这个映射放在 Java 枚举里统一管理比散落在 JSP 的c:choose里好维护得多。4.2 签收接口的实现controller、service 与 mapper 各写什么签收是快递管理系统里最典型的一个操作链路短但事务、并发、权限都涉及。Controller 层只做参数接收和结果封装Controller RequestMapping(/order) public class OrderController { Autowired private OrderService orderService; PostMapping(/sign) ResponseBody public MapString, Object sign(RequestParam Long id, RequestParam Long operatorId) { MapString, Object result new HashMap(); boolean ok orderService.signOrder(id, operatorId); result.put(code, ok ? 0 : 1); result.put(msg, ok ? 签收成功 : 当前状态不允许签收); return result; } }ResponseBody把返回的 Map 序列化成 JSON前端用 jQuery 发 Ajax 就能直接读。示例里 operatorId 是前端传的正式项目更稳妥的写法是从session里取当前登录用户并在拦截器里确认它的网点权限。Service 是业务规则真正落地的地方Transactional(rollbackFor Exception.class) public boolean signOrder(Long orderId, Long operatorId) { ExpressOrder order orderMapper.selectById(orderId); if (order null || order.getStatus() ! 3) { return false; } int rows orderMapper.compareAndSetStatus(orderId, 3, 4); if (rows 1) { expressTrackMapper.insertTrack(orderId, order.getExpressNo(), 客户签收, operatorId); return true; } return false; }第一步先按主键查订单是为了拿到express_no和当前状态轨迹表要记录运单号。第二步不直接update set status4而是用compareAndSetStatus做条件更新这是为了防止两个请求同时进来、同时通过状态判断导致重复签收。更新影响行数是 1 才插入轨迹事务内两条 SQL 要么都成功、要么都回滚。Mapper 对应方法update idcompareAndSetStatus UPDATE express_order SET status #{newStatus}, update_time NOW() WHERE id #{orderId} AND status #{expectStatus} /update这个 SQL 的精髓在AND status #{expectStatus}。它把“先查再改”变成了“按条件改”数据库层面保证只有状态匹配时才会更新成功。如果返回 0说明状态已经被别人改过本次签收直接失败。比先查后改多一条语句要严谨得多。4.3 登录与网点权限拦截器处理SSM 项目里权限控制最常见的做法是 SpringMVC 拦截器这也是老项目普遍采用的方式。拦截器认的就是session里的用户对象public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user request.getSession().getAttribute(loginUser); if (user null) { response.sendRedirect(request.getContextPath() /login.jsp); return false; } return true; } }配置到 springmvc.xml 里用mvc:interceptors声明路径规则mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/login.jsp/ mvc:exclude-mapping path/static/**/ bean classcom.express.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptorsexclude-mapping必须显式放行登录页和静态资源否则 CSS、JS、验证码全被拦页面长得和纯文本一样。如果快递系统里有管理员和普通收派员两种角色我一般会在拦截器里只做登录校验角色判断放到 Service 层因为 Controller 拿到的操作对象不同权限粒度也不同。比如收派员只能查自己的派送任务管理员可以看全量订单这在 Service 方法里用当前用户 id 作为查询条件更直接。5. 把 ssm.zip 在本地跑起来再做一个低风险的状态字典改造5.1 版本组合与启动顺序这类 SSM 项目的版本敏感度极高跑不起来的 80% 情况是版本不对。我建议按这个组合起步组件推荐版本说明JDK1.8老代码大量依赖 JDK 8 特性JDK 11 也能跑但没必要冒险Tomcat8.5支持 Servlet 3.1web.xml 里版本声明能对上MySQL5.7驱动用com.mysql.jdbc.Driver不需要 serverTimezoneMaven3.6.3和 IDEA 内置 Maven 解耦启动顺序我建议先用 Maven 在命令行过一次排除 IDE 缓存干扰mvn clean package -DskipTests构建成功后再到 IDEA 里配置 TomcatDeployment 选择express-ssm:war explodedApplication context 填/express-ssm。访问http://localhost:8080/express-ssm/login.jsp能看到登录页说明 Spring 容器、SpringMVC、MyBatis 三层已经串起来了。注意看控制台日志里有没有Root WebApplicationContext: initialization completed这一行出现代表父容器加载完成。如果卡在Creating bean named sqlSessionFactory直接检查db.properties的连接串jdbc.drivercom.mysql.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/express_db?useUnicodetruecharacterEncodingutf8useSSLfalse jdbc.usernameroot jdbc.passwordroot如果本机是 MySQL 8把驱动改成com.mysql.cj.jdbc.Driver连接串加serverTimezoneAsia/Shanghai这是最常见的一处版本断层。5.2 改造练习用枚举替换 JSP 里的状态判断跑通之后做一个小改造练手。打开views/order/list.jsp大概率会看到一段这样的代码c:choose c:when test${order.status 0}待揽收/c:when c:when test${order.status 1}已揽收/c:when c:when test${order.status 2}运输中/c:when c:when test${order.status 3}派送中/c:when c:when test${order.status 4}已签收/c:when c:otherwise异常/c:otherwise /c:choose这段代码最大的问题是六个状态散落在每个 JSP 页面里两个人就维护不齐。我会新建一个枚举统一管理public enum OrderStatusEnum { WAITING(0, 待揽收), COLLECTED(1, 已揽收), TRANSPORTING(2, 运输中), DELIVERING(3, 派送中), SIGNED(4, 已签收), ABNORMAL(5, 异常); private final int code; private final String desc; OrderStatusEnum(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } public static String descOf(Integer code) { if (code null) { return 未知; } for (OrderStatusEnum status : values()) { if (status.code code) { return status.desc; } } return 未知; } }然后在ExpressOrder实体里加一个方法把 status 转成描述public String getStatusDesc() { return OrderStatusEnum.descOf(this.status); }JSP 里直接输出expressOrder.statusDesc即可c:choose那一段全部删除。改造完的验证步骤分别造出 0 到 5 六种状态的订单打开列表页和详情页逐一比对改造前后页面文案是否一致再打开订单管理页用EXPLAIN SELECT * FROM express_order WHERE status 0;看 type 和 rows如果 status 字段走了 idx_status、rows 从几万降到几十说明列表查询这条链路从功能到性能都跑顺了。本文还有配套的精品资源点击获取