简介:基于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 页面 404,MyBatis 报 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 里漏写了一条 statement,Spring 启动时直接抛异常。
2.2 web.xml 决定 Spring 与 SpringMVC 的启动顺序
web.xml是这个项目的启动总指挥。Spring 根容器和 SpringMVC 子容器必须严格按照先父后子的顺序创建,否则 Service 注入不进 Controller。
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:springmvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</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.xml | Service、Mapper、数据源、事务 | <context:component-scan>、SqlSessionFactoryBean、MapperScannerConfigurer、DataSourceTransactionManager |
| springmvc.xml | Controller、视图解析、静态资源 | <context:component-scan>、<mvc:annotation-driven/>、InternalResourceViewResolver |
| mybatis-config.xml | MyBatis 自身行为 | 下划线转驼峰、别名、日志、二级缓存 |
三者不是并列关系。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-package="com.express.service"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.express.mapper"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean>sqlSessionFactory里的mapperLocations负责把resources/mapper/下的 XML 全部加载成 statement;MapperScannerConfigurer负责扫描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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 id="selectByCondition" resultType="com.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 test="expressNo != null and expressNo != ''"> AND express_no = #{expressNo} </if> <if test="status != null"> AND status = #{status} </if> <if test="stationId != null"> AND station_id = #{stationId} </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select> <select id="countByCondition" resultType="long"> SELECT COUNT(1) FROM express_order <where> <if test="expressNo != null and expressNo != ''"> AND express_no = #{expressNo} </if> <if test="status != null"> AND status = #{status} </if> <if test="stationId != null"> AND station_id = #{stationId} </if> </where> </select><where>标签会自动去掉第一个条件前面的AND,这样不用在 SQL 里写WHERE 1=1。#{}是预编译占位符,MyBatis 最终会把它替换成?再走 PreparedStatement,天然防 SQL 注入。分页参数我习惯直接用offset和pageSize,而不是拼在 SQL 字符串里,因为 LIMIT 后面如果用了${},等于把前端参数直接拼进 SQL,这是典型的注入入口。
注意if判断里写的是status != null,这意味着传入status=0时会走到AND status = 0。如果用 OGNL 写status != '' and status != null,MyBatis 会先把 0 自动转成字符串再比较,0 不等于空串,结论没变,但凭空多一层转换,代码读起来也别扭。
3.3 MyBatis 列名映射与 #{}/${} 的坑
SSM 老项目里实体类属性几乎都是驼峰命名,而数据库列名是下划线,两者不配合就会出各种诡异问题。最快的解决方案是在mybatis-config.xml里开全局开关:
<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings> </configuration>mapUnderscoreToCamelCase开启后,receiver_name自动映射到receiverName。logImpl设成STDOUT_LOGGING是把执行的 SQL 打到控制台,排错时能直接看到参数绑定情况,代价是日志量变大,线上项目记得关掉。
常见的三个坑列成表方便对号入座:
| 现象 | 直接原因 | 处理方式 |
|---|---|---|
| 查出来 List 有数据,但每个对象的属性全是 null | 没开mapUnderscoreToCamelCase,且没写 resultMap | 开全局设置,或在具体 XML 里写 resultMap |
| 启动报 Invalid bound statement | mapper 接口包名与 XML namespace 不一致 | 三者统一:接口全限定名、namespace、<mapper>的 resource 路径 |
| 中文写入 MySQL 变问号 | jdbc.url 没带编码参数 | 连接串加characterEncoding=utf8;MySQL 8 还要加serverTimezone=Asia/Shanghai |
另外,${}和#{}的分工要拎清。#{}用于占位参数,${}用于拼接结构,比如ORDER BY ${sortColumn}。我一般把排序字段白名单写在 Service 里,前端传create_time或status,Service 映射成固定字符串再交给${},绝不直接把请求参数传进 XML。
4. 快递管理系统核心流程:从揽收到签收的一条完整调用链
4.1 快递状态机与接口对应关系
快递系统本质是一个状态机系统。我在设计接口时会把状态迁移直接写进接口文档,开发时不至于各写各的:
| 动作 | 状态迁移 | 对应接口 | 主要校验 |
|---|---|---|---|
| 揽收 | 0 → 1 | POST /order/collect | 运单号不能重复;必须填寄收件人 |
| 发车 | 1 → 2 | POST /order/transport | 运单属于当前网点 |
| 派送 | 2 → 3 | POST /order/deliver | 必须指定派送员 |
| 签收 | 3 → 4 | POST /order/sign | 只有派送中状态能签收 |
| 异常 | 3 → 5 | POST /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 Map<String, Object> sign(@RequestParam Long id, @RequestParam Long operatorId) { Map<String, 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 status=4,而是用compareAndSetStatus做条件更新,这是为了防止两个请求同时进来、同时通过状态判断,导致重复签收。更新影响行数是 1 才插入轨迹,事务内两条 SQL 要么都成功、要么都回滚。
Mapper 对应方法:
<update id="compareAndSetStatus"> 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 class="com.express.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>exclude-mapping必须显式放行登录页和静态资源,否则 CSS、JS、验证码全被拦,页面长得和纯文本一样。如果快递系统里有管理员和普通收派员两种角色,我一般会在拦截器里只做登录校验,角色判断放到 Service 层,因为 Controller 拿到的操作对象不同,权限粒度也不同。比如收派员只能查自己的派送任务,管理员可以看全量订单,这在 Service 方法里用当前用户 id 作为查询条件更直接。
5. 把 ssm.zip 在本地跑起来,再做一个低风险的状态字典改造
5.1 版本组合与启动顺序
这类 SSM 项目的版本敏感度极高,跑不起来的 80% 情况是版本不对。我建议按这个组合起步:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 | 老代码大量依赖 JDK 8 特性,JDK 11 也能跑但没必要冒险 |
| Tomcat | 8.5 | 支持 Servlet 3.1,web.xml 里版本声明能对上 |
| MySQL | 5.7 | 驱动用com.mysql.jdbc.Driver,不需要 serverTimezone |
| Maven | 3.6.3 | 和 IDEA 内置 Maven 解耦 |
启动顺序我建议先用 Maven 在命令行过一次,排除 IDE 缓存干扰:
mvn clean package -DskipTests构建成功后再到 IDEA 里配置 Tomcat,Deployment 选择express-ssm:war exploded,Application context 填/express-ssm。访问http://localhost:8080/express-ssm/login.jsp,能看到登录页,说明 Spring 容器、SpringMVC、MyBatis 三层已经串起来了。
注意看控制台日志里有没有Root WebApplicationContext: initialization completed,这一行出现,代表父容器加载完成。如果卡在Creating bean named 'sqlSessionFactory',直接检查db.properties的连接串:
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/express_db?useUnicode=true&characterEncoding=utf8&useSSL=false jdbc.username=root jdbc.password=root如果本机是 MySQL 8,把驱动改成com.mysql.cj.jdbc.Driver,连接串加serverTimezone=Asia/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 从几万降到几十,说明列表查询这条链路从功能到性能都跑顺了。
本文还有配套的精品资源,点击获取