☰
微信小程序+SSM快递管理平台:订单状态机与联调实战
2026/9/29 1:38:01 网站建设 项目流程

简介:这份资源是基于微信小程序的快递管理平台毕业设计源码包,后端采用 Java 与 SSM(Spring Boot/SpringMVC),客户端为微信小程序,并配套 MySQL 5.7 与 Tomcat 运行环境,适合正在做 Java Web 或小程序方向毕业设计的学生参考。压缩包共 1263 个文件,主要包含 104 个 Java 后端源码、127 个 Vue 管理端页面、74 个 WXML 页面、76 个 WXSS 样式、166 个 JS 脚本,以及 2 个 SQL 数据库脚本和项目功能介绍文档,另有 3 个 bat 脚本,分别负责安装、运行与构建,以及常见 IDE 项目配置,整体大小约 15.86MB。项目经过严格调试可直接运行,目录结构完整,从数据库脚本、后端接口、管理端页面到小程序端可逐层对应,便于理解 SSM 与微信小程序协同开发的完整链路。已有 2665 人学习下载,源码附带功能介绍文档,适合毕业设计二次开发,也可作为快递业务或小程序应用开发的入门范本。

1. 微信小程序+SSM做快递管理平台:一条从下单到签收的完整闭环

一家日均三五百票的校园快递驿站,最累的不是搬货,是查件:用户在微信里问“我的件到了吗”,客服在聊天记录里翻半天。这种场景催生了一批快递管理平台,而基于微信小程序的快递管理平台正好卡在“用户不用装App”和“开发者不用搞太复杂”之间。后端用SSM(Spring+SpringMVC+MyBatis),前端用微信小程序原生写法,订单从用户提交到快递员签收,全程有记录、有状态、可统计。这套资源适合三类人:做毕业设计需要完整落地项目的学生、想练Java后端接口设计的新人,以及想给驿站小团队搞一套内部管理系统的小开发者。它最大的价值不是某个炫技功能,而是把登录、下单、接单、核销、查询这一整条链路完整打通了。

2. 快递业务的数据建模:订单状态机与四张核心表

2.1 先用一张状态机把业务边界卡死

我拿到这类项目第一件事不是看代码,而是看状态机。快递管理平台里典型的角色有三种:用户、快递员、管理员。用户只关心下单和查询;快递员关心接单、取件、派送、签收;管理员关心的是订单是否卡在某个环节没人处理。对应下来,订单状态用六个数字就够了:

  • 1 待接单:用户已下单,还没有快递员认领
  • 2 待取件:快递员已接单,去用户处取件
  • 3 运输中:包裹已从用户手中取走,在转运或分拨
  • 4 待签收:包裹到达末端网点或驿站,等待收件人领取
  • 5 已签收:收件人取走,订单闭环
  • 6 已取消:下单后、接单前用户主动取消

用数字而不是用中文状态字符串,一是存储体积小,二是索引和比较更快,三是前端拿到的永远是数字,文案由前端或字典表负责翻译,避免前后端各写一套状态名对不上。

状态机定好以后,接口设计就跟着清晰了:每个状态变更接口都只做“从某一个状态变到下一个状态”这一件事,不允许跳变。比如“待取件”只能由“待接单”变过来,“已签收”只能由“待签收”变过来。这条规则看似简单,但在后端实现时很容易被忽略,第3章会讲怎么用一条update语句把它卡死。

2.2 订单主表:字段少不代表设计可以偷懒

订单表是整个系统的核心,直接贴建表语句:

CREATE TABLE order_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '业务订单号', user_id BIGINT NOT NULL COMMENT '下单用户ID', courier_id BIGINT DEFAULT NULL COMMENT '接单快递员ID,下单时为空', sender_name VARCHAR(32) NOT NULL COMMENT '寄件人姓名', sender_phone VARCHAR(20) NOT NULL COMMENT '寄件人电话', sender_addr VARCHAR(255) NOT NULL COMMENT '寄件地址', receiver_name VARCHAR(32) NOT NULL COMMENT '收件人姓名', receiver_phone VARCHAR(20) NOT NULL COMMENT '收件人电话', receiver_addr VARCHAR(255) NOT NULL COMMENT '收件地址', weight DECIMAL(6,2) DEFAULT NULL COMMENT '预估重量,kg', status TINYINT NOT NULL DEFAULT 1 COMMENT '1待接单 2待取件 3运输中 4待签收 5已签收 6已取消', take_code VARCHAR(8) DEFAULT NULL COMMENT '取件码,快递员接单后生成', remark VARCHAR(255) DEFAULT NULL COMMENT '用户备注', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status (status), KEY idx_courier (courier_id), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快递订单主表';

几个容易漏的细节。第一,order_no用业务号,不要让前端拿着自增id到处传,一是id可枚举容易被爬,二是以后接入打印面单、对接物流接口时,业务号更通用。生成方式常见的是日期加随机数,例如20250114153000123456,我习惯在Service层生成,不用数据库函数,方便换库。

第二,courier_id允许为null,因为下单那一刻根本不知道谁会接单。很多新手建表在这里设NOT NULL,结果代码里到处写假值占位,纯属自找麻烦。

第三,take_code取件码在快递员接单时生成,不是下单时生成。驿站场景下,取件码是给收件人取件用的,用户下单时用不上。取件码我一般用6位数字,随机生成后做唯一校验,避免两个包裹同时在架上号码冲突。

第四,索引不要贪多。这张表日常查询靠status、courier_id、create_time三个索引就够,再加索引,写放大成本反而高。

2.3 状态记录表:物流轨迹是日志,不是订单字段的副本

很多简化版项目只有一个订单表,物流轨迹靠“猜”,也就是用时间字段倒推,结果完全没法回答“这单为什么卡了两天”这种问题。正经做法是单独建一张状态日志表:

CREATE TABLE order_status_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '订单号', from_status TINYINT NOT NULL COMMENT '变更前状态', to_status TINYINT NOT NULL COMMENT '变更后状态', operator_id BIGINT DEFAULT NULL COMMENT '操作人ID,用户或快递员', operator_type TINYINT NOT NULL COMMENT '1用户 2快递员 3系统', remark VARCHAR(255) DEFAULT NULL COMMENT '变更说明', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单状态流转日志';

这张表的作用有两个。一是前端“物流轨迹”页面直接按order_no查它,按时间正序返回,就能渲染出“你已下单→快递员已接单→包裹已取走→快件已到达→已签收”的时间线,不需要在业务代码里拼字符串。二是出纠纷时能还原操作链:谁在什么时间把订单从什么状态改到什么状态,一查便知,这是管理员最需要的功能。

注意:日志写入必须和订单状态更新放在同一个事务里。常见的翻车现场是,先更新订单表成功了,再插日志失败,事务回滚后日志少了关键一步,用户端轨迹断了一截。

2.4 用户表和快递员表:最小字段原则

用户表和快递员表不要按大而全的思路去设计,快递管理平台不是CRM系统,字段够用就行。

CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL COMMENT '微信openid', nickname VARCHAR(32) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, role TINYINT NOT NULL DEFAULT 1 COMMENT '1用户 2快递员', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_openid (openid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';

强调两点。第一,用openid做唯一键,这是微信小程序登录体系里的天然用户标识,比让用户注册账号密码省事得多;管理员的账号密码走后端配置,不揉进这张表。第二,role字段决定小程序端一进来渲染用户界面还是快递员界面,不用拆成两张用户表。快递员如果还需要月考核、派件量和罚款记录,那再单独扩展快递员档案表,不要在一开始就堆字段。

3. 后端接口落地:SSM的Controller、Service、Mapper怎么写才不翻车

3.1 三层职责先对齐,团队协作才不打架

SSM是Spring+SpringMVC+MyBatis的组合。很多新人写SSM最大的问题是Controller里写完所有逻辑,Service形同虚设,Mapper里还放着循环查询,代码跑起来没问题,但扩展和维护全是坑。我在这类项目里习惯这样划分:Controller只做参数接收、基础校验和结果包装;Service专心写业务规则,包括事务、状态判断、订单号生成;Mapper只写SQL,一个方法对应一条SQL,不掺业务判断。

依赖方向必须单向:Controller依赖Service,Service依赖Mapper。这是分层最核心的纪律,靠它才能保证以后想加缓存、加消息队列,不需要把Controller推倒重来。

3.2 登录接口和token拦截器

微信小程序端没有传统浏览器的Session概念,Cookie的兼容性也差,所以登录不能把希望全寄托在HttpSession上。常见做法是:小程序端wx.login拿到code,传到后端,后端调微信的jscode2session接口换取openid,再为这个用户生成一个token返回给小程序。后续每次请求在请求头带Authorization: token,后端用拦截器校验。

用户表和登录的关联在sys_user表的openid上。核心接口如下:

@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { if (dto.getCode() == null || "".equals(dto.getCode())) { return Result.error("code不能为空"); } return userService.login(dto.getCode()); } }

Controller里只做了一件事:判空,剩下的全部扔给Service。login这个Service方法内部做的是:用code换openid,查sys_user表是否存在,不存在就插入新用户,存在则更新最近登录时间,再生成一个随机token,把token和用户角色一起返回。

token的生成,我一般直接用UUID去掉横线,也可以用Redis做有效期管理和主动失效。快递管理平台的并发量不大,初期不折腾Redis,用一个内存tokenMap也够用,架构上留出换成Redis的口子就行。

登录拦截器是SSM里最容易被写歪的地方:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || "".equals(token)) { response.setStatus(401); return false; } Long userId = TokenHolder.getUserId(token); if (userId == null) { response.setStatus(401); return false; } UserContext.set(userId); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }

TokenHolder负责从内存或Redis里按token查用户,查到就返回userId,查不到就是无效token。拦截器的执行时机在Controller之前,所以这里只判断“token是否有效”,不做业务查询,避免每次请求都多打一次数据库。UserContext是ThreadLocal包装的工具类,保存当前请求的用户ID;afterCompletion里必须clear,否则线程池复用线程时,会把上一个请求的用户串到下一个请求上。这个坑极其隐蔽,线上出现过A用户下单记到B用户头上的事故。

接着在SpringMVC配置里注册拦截器,并指定拦截路径:

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/api/**"/> <mvc:exclude-mapping path="/api/user/login"/> <bean class="com.express.interceptor.AuthInterceptor"/> </mvc:interceptor> </mvc:interceptors>

mapping指拦截所有/api下的路径,exclude-mapping把登录接口排除掉,否则用户还没登录就被拦截器拦死。还有更细的做法:把不同角色能访问的接口也在这里做权限区分,但快递管理平台的权限点不多,我在Service层按角色判断就够了,不额外引权限框架。

3.3 下单接口:事务边界要包住两张表的写入

下单接口的流程是:校验参数,生成order_no,插入订单表,插入状态日志表,返回订单号。关键在两条insert必须同生共死,用@Transactional把Service方法包起来:

@Override @Transactional(rollbackFor = Exception.class) public Order createOrder(OrderDTO dto, Long userId) { String orderNo = generateOrderNo(); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setSenderName(dto.getSenderName()); order.setSenderPhone(dto.getSenderPhone()); order.setSenderAddr(dto.getSenderAddr()); order.setReceiverName(dto.getReceiverName()); order.setReceiverPhone(dto.getReceiverPhone()); order.setReceiverAddr(dto.getReceiverAddr()); order.setWeight(dto.getWeight()); order.setStatus(1); orderMapper.insert(order); OrderStatusLog log = new OrderStatusLog(); log.setOrderNo(orderNo); log.setFromStatus(0); log.setToStatus(1); log.setOperatorId(userId); log.setOperatorType(1); log.setRemark("用户下单"); orderStatusLogMapper.insert(log); return order; }

注意@Transactional默认只处理RuntimeException,rollbackFor = Exception.class的意思是连普通异常也一起回滚。下单如果插了订单表但日志表没插上,后台订单虽然还在,但用户看到的轨迹就没开头,所以务必要显式指定。这里log里的from_status写0,表示“订单创建前的空状态”,这个0只出现在日志表里,不参与订单主表的状态流转。参数校验放在Controller里的设计能省事,但下单涉及重量和地址这种业务字段,我更倾向在Service入口用Bean Validation再校验一遍,防止有人绕过Controller直调Service。

3.4 状态变更接口:一条update语句防状态跳变

状态变更接口是快递系统里最容易出逻辑漏洞的地方。新手写法是先select查一下当前状态,在Java里if判断,再update。这里有两个问题:并发下两个请求读到同一个状态都能通过判断;查询再更新的两步之间可能有其他请求插进来。

我习惯的做法是直接在Mapper层做“条件更新”,把期望的当前状态作为update的where条件:

@Update("UPDATE order_info SET status = #{toStatus}, update_time = NOW() " + "WHERE order_no = #{orderNo} AND status = #{fromStatus}") int compareAndSet(@Param("orderNo") String orderNo, @Param("fromStatus") int fromStatus, @Param("toStatus") int toStatus);

Service层拿返回的影响行数判断成败:

public boolean changeStatus(String orderNo, int fromStatus, int toStatus, Long operatorId) { int row = orderMapper.compareAndSet(orderNo, fromStatus, toStatus); if (row != 1) { throw new BizException("当前订单状态不允许该操作"); } OrderStatusLog log = new OrderStatusLog(); log.setOrderNo(orderNo); log.setFromStatus(fromStatus); log.setToStatus(toStatus); log.setOperatorId(operatorId); log.setOperatorType(2); log.setRemark("状态变更"); orderStatusLogMapper.insert(log); return true; }

这个写法有两个好处。一是原子性由数据库行锁保证,不需要在应用层加锁;二是并发下只有一个请求能update成功,另一个影响行数为0,直接抛出业务异常,从机制上杜绝状态跳变。这里我强调一遍:任何涉及订单状态的接口,都不要先查再改。

4. 小程序端对接:请求封装、角色切换与订单列表渲染

4.1 先看app.json和小程序目录结构

后端接口立住后,小程序端要做的不是“每个页面自己请求接口”,而是先把公共模块搭起来。一个典型的快递管理小程序目录是这样:

pages/ login/ 登录页 orderCreate/ 用户下单页 orderList/ 订单列表页 orderDetail/ 订单详情页 courier/ 快递员工作台 utils/ request.js 请求统一封装 date.js 日期格式化 app.js app.json app.wxss

app.json里注册页面和底部tab栏,这是小程序最容易被忽略的配置文件。tabBar的pagePath必须是app.json里pages数组已存在的页面,首页必须放第一个。我见过多次项目跑起来白屏,最后发现是app.json里的路径和实际目录名不一致。

4.2 请求统一封装:把token和错误处理收进一个地方

小程序每个页面都直接调wx.request很容易失控,我习惯在utils/request.js里统一封装,把baseUrl、token、401跳转、错误Toast全部收拢:

const baseUrl = 'https://express.example.com' function request(url, method, data) { return new Promise((resolve, reject) => { wx.request({ url: baseUrl + url, method: method, data: data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') }, success(res) { if (res.statusCode >= 200 && res.statusCode < 300) { if (res.data.code === 0) { resolve(res.data.data) } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }) reject(res.data) } } else if (res.statusCode === 401) { wx.removeStorageSync('token') wx.reLaunch({ url: '/pages/login/login' }) reject(res) } else { wx.showToast({ title: '服务器异常', icon: 'none' }) reject(res) } }, fail(err) { wx.showToast({ title: '网络请求失败', icon: 'none' }) reject(err) } }) }) } module.exports = { request: request }

封装里最重要的约定是后端返回JSON统一格式:{ code: 0, msg: "...", data: {...} }。code为0表示成功,非0表示业务失败;HTTP层面只要不是2xx或者401,统一走异常分支。401单独拎出来是因为token过期时要做静默跳登录页,而不是给用户弹一条莫名的错误。注意fail分支里不要做重复Toast,wx.request的fail可能是断网、可能是请求超时、也可能是小程序后台被切走,这时只提示“网络请求失败”,把日志打到console里,线上一旦“网络请求失败”频率变高,再根据日志区分原因。

4.3 登录页和后端换openid

小程序端登录页的代码逻辑是固定的:

Page({ onLoad() { this.login() }, login() { wx.login({ success: (res) => { request('/api/user/login', 'POST', { code: res.code }).then((data) => { wx.setStorageSync('token', data.token) wx.setStorageSync('role', data.role) if (data.role === 2) { wx.reLaunch({ url: '/pages/courier/courier' }) } else { wx.reLaunch({ url: '/pages/orderList/orderList' }) } }) } }) } })

wx.login拿到的code是一次性的,有效期只有五分钟,后端换完openid以后这个code就失效,所以小程序端不能存code长期使用。token拿回来以后存本地缓存,但必须记住token会过期,后端返回401时要清掉缓存。如果你想让本地登录态更严格,可以再存一个expireTime字段,每次启动先查缓存是否过期,但最终还是要等后端401兜底,小程序端本地判断只是减少一次无效请求。

role字段决定小程序端进哪个主页面。快递员进工作台看待接单列表,普通用户进订单列表页,这是角色区分最朴素的做法。如果角色更多,可以在app.js里维护一个globalData,或者把菜单权限做进后端菜单表,但快递管理平台没必要在第一天就上动态权限设计。

4.4 订单列表页:把状态数字翻译成UI

订单列表页是用户看到的核心页面。JS里请求列表后,先把状态数字翻译成文案和操作按钮,再一次性setData,避免频繁更新:

Page({ data: { orders: [] }, onShow() { this.loadOrders() }, loadOrders() { request('/api/order/list', 'GET', {}).then((orders) => { orders.forEach((item) => { item.statusText = this.statusText(item.status) item.canCancel = item.status === 1 }) this.setData({ orders: orders }) }) }, statusText(status) { const map = { 1: '待接单', 2: '待取件', 3: '运输中', 4: '待签收', 5: '已签收', 6: '已取消' } return map[status] || '未知' } })

WXML侧用wx:for渲染列表,并根据状态显示操作按钮:

<view class="order-card" wx:for="{{orders}}" wx:key="orderNo"> <text class="order-no">单号: {{item.orderNo}}</text> <text class="order-status">{{item.statusText}}</text> <view class="addr-line"> <text>{{item.senderName}} {{item.senderPhone}}</text> <text>{{item.senderAddr}}</text> </view> <view class="addr-line"> <text>{{item.receiverName}} {{item.receiverPhone}}</text> <text>{{item.receiverAddr}}</text> </view> <button wx:if="{{item.canCancel}}" bindtap="cancelOrder"><settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>

如果项目用的是Spring Boot,记得在application.yml里确认mybatis.configuration.map-underscore-to-camel-case: true。如果项目写到一半才加这个配置,要把所有涉及实体映射的SQL都跑一遍回归,因为人工起别名的方法常常只覆盖了部分查询。

6. 联调验证与上线前检查:把接口自测养成习惯

6.1 跑接口之前,先准备一份接口检查清单

交付这类项目时,我不会直接打开小程序点一遍,而是先用Postman或Apifox把后端每个接口按“正常路径、异常路径、边界参数”三类过一遍。下面这个表是快递管理平台最常见的检查项:

接口正常路径异常路径边界参数
用户登录code有效、新老用户都能进code为空、code伪造code过期
下单字段齐、返回订单号收件人电话缺失收件人电话位数异常
订单列表区分用户/快递员权限无token请求空订单列表
接单待接单状态可接已签收订单再接重复接单
签收待签收状态可签收状态不符合被条件更新拒绝取件码错误

正常路径验证功能通不通,异常路径验证代码有没有做防御,边界参数验证会不会产生脏数据。三类都过了,再连小程序端联调,这时候发现的才可能是前后端协作问题。

6.2 联调时翻车最多的几个点

第一条是接口返回结构不一致。同一个项目里,有的接口直接返回数组,有的返回{code,data,msg},前端request封装统一处理就会炸。我要求后端所有接口必须走同一个Result对象,不允许Controller里自造返回结构。

第二条是字段命名前后端对不上。后端返回orderNo,前端写order_no,查不到数据先看调试面板里的JSON字段名,别急着改代码。这类问题一小时能排查完,就怕不会看network面板。

第三条是状态码语义不统一。HTTP 200和业务code 0要分开,HTTP 200表示服务端处理了请求,业务code 0表示业务成功,二者不要混为一谈。订单被取消时接口返回HTTP 200加业务code 4001,前端按业务错误提示,不要直接给用户看“服务器异常”。

6.3 上线前的不放心清单

小程序端发布前,把下面几项全部过一遍:

检查项具体操作常见翻车点
request合法域名必须是备案过的HTTPS域名漏配或配了未备案域名
app.json页面路径全部页面存在且首字母小写路径和目录不一致导致白屏
AppSecret只留后端,不进小程序代码前端硬编码密钥被扒
真机调试Android和iOS各测一轮只在开发工具验证
时间字段后端统一返回字符串格式两端各自转换导致差8小时

这几条是“不放心清单”的骨架,每次发版前我都从头跑一遍,跑完才敢提交审核。当初做这套项目时,我在时间字段和MyBatis映射上先后熬掉两个通宵,从那以后,凡是从后端返回前端的时间,一律先转成字符串再走网络,前端拿到什么就渲染什么,绝不让两端各自再做一次时区转换;凡是涉及状态的接口,强制用条件更新;每次联调前,第一个接口一定先打印完整请求和响应JSON,把数据层看透,后面所有问题都是小问题。这份资源里的工程源码、数据库SQL和部署文档都在包里,建议你按第2章的建表语句重新核一遍表结构,再按第6章的清单做一次接口自测,跑通之后你才算真正掌握了它。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询