同城搬家货运系统实战:Spring Boot订单与支付核心源码解析

发布时间:2026/10/21 5:03:31
同城搬家货运系统实战:Spring Boot订单与支付核心源码解析 做Java后端这几年我陆续带过不少新人也看过很多开源项目但真正让我觉得“拿来就能跑、跑起来能讲、讲完能自己改”的实战项目其实不多。同城搬家货运系统算是其中一个典型——业务链路长、涉及角色多、并发场景真实既有地图定位这种外部依赖又有订单状态机和支付回调这种核心逻辑。这篇文章我把完整源码的核心模块、设计思路和实操过程中踩过的坑一起拆开讲清楚想拿来做毕设、接私活或者入门微服务架构的同学都可以参考。1. 项目概述与业务功能拆解1.1 这个系统到底在解决什么问题先想明白一件事同城搬家货运和普通电商有本质区别。电商是“商品从仓库到用户手里”链路相对固定搬家货运是“车从司机位置到用户位置再把货物从A地搬到B地”核心资源是运力和位置。所以这个系统的业务闭环大概是这样的用户通过小程序或App下单填写起点、终点、车型、是否需要搬运工、搬货时间系统根据规则算出费用用户支付订金或全款然后司机端出现可抢订单司机抢单后联系用户完成运输后用户确认收货、支付尾款、双方互相评价。这里面有几个关键设计点订单状态不是简单的新建到完成中间穿插着支付状态、司机状态、位置状态计价不是一口价而是起步价里程费楼层费大件费的组合规则抢单是典型的高并发写场景一个订单同时推给几百个司机怎么保证只有一个司机抢到整个项目拆成用户端、司机端、管理后台三个入口后端一套服务支撑。我在实际部署过程中发现这种“一个服务对接多个前端”的结构最合理既能统一权限和业务逻辑又不会像拆微服务那样把简单事情复杂化。1.2 功能清单与MVP模块划分拿到这种项目第一步不是写代码而是先画功能清单。我当时把这个系统整理成了这样功能模块用户端司机端后台管理核心要点账号认证手机号验证码登录身份证驾驶证审核入驻司机资质审核用户和司机是两套表但登录逻辑可复用下单计价填写订单信息、实时计价无计价规则配置计价引擎是独立模块订单分配下单后等待接单抢单大厅、抢单超时自动取消并发控制重点运输流程查看司机位置、确认送达接单、开始运输、送达订单轨迹监控状态机核心支付结算微信/支付宝支付查看收入明细订单对账、提现审批支付回调处理评价体系对司机打分评价对用户反评口碑管理与订单解耦如果是做MVP版本我建议把优惠券、多人拼车、实时语音、路径规划这些功能全部砍掉先把“下单→派单→运输→支付→评价”这条主链路跑通。我见过太多人一开始就想着把地图轨迹回放做得很炫结果基础的下单流程都还没走通。2. 技术选型与服务端架构设计2.1 后端技术栈为什么是Spring Boot MyBatis Plus很多人在技术选型上容易走两个极端要么用Spring Boot单体硬扛所有功能要么一上来就Spring Cloud全家桶。说实话同城货运这个体量的项目单体绰绰有余。我最终定的方案是Spring Boot 2.7.x MyBatis Plus 3.5.xMySQL 8.0 Redis 6.x地图服务使用某第三方地图开放平台的路线规划与逆地理编码接口支付接入微信支付原生V3接口消息推送用WebSocket抢单大厅用Redis缓存订单列表为什么不用Spring Cloud因为同城货运的核心业务在一个城市内闭环不存在跨机房、多语言异构这种复杂场景。等做到多城市部署、需要独立运营后台的时候再按模块拆分也不迟。当时我遇到过一个反面例子某公司用微服务把订单、支付、用户拆开了结果一次简单的改价要同时发三个服务联调成本翻倍。MyBatis Plus选它很简单CRUD不用写XML内置分页插件逻辑删除开箱即用很适合中小型管理系统快速落地。唯一需要注意的是复杂多表关联查询还是老老实实写XML别用Wrapper硬套。2.2 数据库设计核心表是怎么设计的数据库是同城货运系统的骨架我梳理一下最核心的几张表。用户表t_user存储普通用户信息手机号加唯一索引密码不是必须的因为登录走验证码。额外需要一个status字段区分正常/冻结防止恶意下单。司机表t_driver除了基本资料还包括身份证照片地址、驾驶证照片地址、审核状态、当前经度纬度、在线状态。这里有个很容易踩的坑司机的位置字段必须建联合索引driver_status, longitude, latitude否则抢单大厅按距离筛选时慢查询非常明显。订单表t_order这是全系统最核心的表字段比较多。我把关键字段列一下CREATE TABLE t_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单编号, user_id bigint(20) NOT NULL COMMENT 下单用户ID, driver_id bigint(20) DEFAULT NULL COMMENT 接单司机ID, start_address varchar(255) NOT NULL COMMENT 起点地址, start_lng decimal(10,6) NOT NULL COMMENT 起点经度, start_lat decimal(10,6) NOT NULL COMMENT 起点纬度, end_address varchar(255) NOT NULL COMMENT 终点地址, end_lng decimal(10,6) NOT NULL COMMENT 终点经度, end_lat decimal(10,6) NOT NULL COMMENT 终点纬度, car_type tinyint(4) NOT NULL COMMENT 车型1小面 2金杯 3厢货, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, status tinyint(4) NOT NULL COMMENT 订单状态, pay_status tinyint(4) NOT NULL COMMENT 支付状态, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_driver_id (driver_id), KEY idx_status (status), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT搬家订单表;订单轨迹表t_order_track记录每次状态变更的流水包括操作人、变更前状态、变更后状态、备注、经纬度。这张表的意义不是功能需要而是排查问题时救命用的。我实际排查过一单纠纷用户说司机没来司机说已经到了最后就是靠轨迹表的数据还原了真相。计价规则表t_pricing_rule这是很多初学者容易忽略的表。每个城市的起步价、每公里单价、楼层费、大件搬运费都不一样做成表配置才能灵活调整而不是写死在代码里。2.3 工程结构与分层设计后端工程我按标准的分层结构来组织move-car-server/ ├── common/ // 统一返回体、异常处理、常量枚举 ├── controller/ // 接口层只做参数接收和结果封装 ├── service/ // 业务逻辑层核心都在这 ├── mapper/ // MyBatis Plus的Mapper接口 ├── entity/ // 数据库实体 ├── dto/ // 前端交互对象避免直接暴露实体 ├── config/ // Redis、WebSocket、支付等配置 └── job/ // 定时任务超时订单取消、支付状态检查分层最核心的原则是Controller里绝对不写业务逻辑。我见过有人直接把计价规则写在Controller里看起来省事但后续要给管理后台加个改价功能时完全没法复用。正确的做法是计价逻辑单独抽一个PricingService下单接口和后台试算功能都调它。统一返回体这块我用的是最简洁的Result类code0表示成功非0表示业务异常。异常控制用全局异常处理器业务异常抛自定义BizException这样前端能根据code给出友好提示。3. 核心功能实操实现3.1 计价模块搬家费用到底怎么算计价是整个系统的核心业务之一也是业务方最爱调整的部分。我先说需求同城搬家费用 起步价 里程费 楼层费 大件搬运费 超时等候费。起步价包含一定的基础里程比如5公里内收80元超过部分按每公里5元计算如果起点或终点涉及楼层每层加10元有电梯不加有冰箱、钢琴这种大件每件加50元司机等候超过15分钟后每分钟加1元。很多人可能觉得直接一条SQL搞定的事但实际写代码时要注意计价规则是变动的我建议把规则配置放进数据库而不是写死常量。核心计价引擎的伪代码逻辑如下public class PricingEngine { public BigDecimal calculate(PriceRequest request) { // 1. 根据城市和车型取出计价规则 PricingRule rule pricingRuleMapper.findByCityAndCarType(request.getCity(), request.getCarType()); // 2. 计算里程费实际距离 - 起步里程不足则按0处理 BigDecimal distance request.getDistance(); // 单位公里 BigDecimal extraDistance distance.subtract(rule.getBaseDistance()).max(BigDecimal.ZERO); // 3. 起步价 超出里程 * 每公里单价 BigDecimal amount rule.getBasePrice() .add(extraDistance.multiply(rule.getPerKmPrice())); // 4. 楼层费 BigDecimal floorFee calculateFloorFee(request, rule); // 5. 大件费 BigDecimal largeItemFee rule.getLargeItemPrice() .multiply(BigDecimal.valueOf(request.getLargeItemCount())); return amount.add(floorFee).add(largeItemFee); } }这里有两个工作必须做对。第一金额计算必须用BigDecimal不能用double和float不然一分钱对不上财务那边一定找你麻烦。第二订单提交时的计价结果要“拍照存档”也就是把快照存进订单表不能等结算时再重新计算因为规则可能在你运输过程中调整了。另外距离怎么获取我接的是第三方地图的路线规划接口返回的驾车距离作为计费依据。但我保留了一个换算函数如果第三方接口挂了就回退用经纬度球面距离公式算。这个降级策略让我躲过了不止一次地图服务的可用性波动。3.2 司机接单与抢单怎么控制并发不超卖抢单是同城货运系统里最刺激的场景。用户下单后订单进入抢单大厅附近几百个司机同时刷到谁手快谁接走。最直接的实现方式司机App轮询抢单大厅接口点抢单时调后端接口更新订单driver_id。这里有一个重大隐患——两个司机同时抢一单都执行了update t_order set driver_idxxx where idxxx and driver_id is null虽然数据库行锁能保证只有一个成功但在高并发场景下大量请求直接怼数据库MySQL连接池瞬间耗尽。我的解法是先用Redis做一道拦截再用数据库做兜底public Result grabOrder(Long orderId, Long driverId) { // 1. 先查Redis中该订单是否已被抢 Boolean success redisTemplate.opsForValue() .setIfAbsent(order:grabbed: orderId, driverId.toString(), 24, TimeUnit.HOURS); if (Boolean.FALSE.equals(success)) { return Result.error(手慢了订单已被抢走); } // 2. 成功拿到Redis锁后再更新数据库 int rows orderMapper.updateDriver(orderId, driverId); if (rows 1) { // 抢单成功异步通知用户 return Result.success(); } else { // 数据库更新失败说明订单状态已变化删除Redis锁 redisTemplate.delete(order:grabbed: orderId); return Result.error(订单状态异常); } }这里有几点细节需要说明。第一setIfAbsent是原子操作能保证同一时刻只有一个线程成功拿到锁。我早期用的“先get再set”写法在高并发下必然出现两个司机都get为空、然后都set成功的情况。第二为什么先操作Redis再更新数据库因为Redis的响应是亚毫秒级能扛住极高的并发数据库连接资源则被保护下来了。但Redis锁和数据库状态一定要做一致性兜底所以数据库更新失败时务必删锁否则订单锁死在Redis里。第三很多项目会在这里引入Redisson的分布式锁锁超时时间设30秒配合看门狗机制自动续期避免业务执行时间超过锁过期时间。但如果只是抢单这种单行更新的场景setIfAbsent配合事务内更新已经足够。引入重型分布式锁反而徒增复杂度。抢单大厅的数据推送我采用的是“Redis有序集合存储待抢单司机端轮询列表”的方案。司机端每3秒拉取一次距离最近的待抢单列表不直接推送广播省去了WebSocket长连接维护的成本。等订单量上来了再换成WebSocket心跳保活也来得及。3.3 订单状态机如何防止状态乱跳订单状态管理是这个项目里最容易写成一团乱麻的部分。用户端操作、司机端操作、后台操作、支付回调、定时任务任何一个动作都可能改变订单状态如果不加约束随时会出现从“已完成”退回到“运输中”这种离谱状态。我定义的状态枚举如下待支付(0) — 用户提交订单后待支付待接单(1) — 支付完成后进入抢单大厅已接单(2) — 司机抢单成功运输中(3) — 司机出发用户可实时查看待确认(4) — 司机确认送达等用户确认已完成(5) — 用户确认收货已取消(6) — 用户或系统取消已退款(7) — 取消后完成退款状态的流转约束我用的是图形化的状态机定义然后在代码里用Map做校验而不是写一堆if-else。看图比读代码直观维护起来也省心。public class OrderStatusMachine { private static final MapInteger, SetInteger TRANSITIONS new HashMap(); static { // 待支付 - 待接单 / 已取消 TRANSITIONS.put(OrderStatus.PENDING_PAY, Set.of( OrderStatus.PENDING_PICKUP, OrderStatus.CANCELED)); // 待接单 - 已接单 / 已取消 TRANSITIONS.put(OrderStatus.PENDING_PICKUP, Set.of( OrderStatus.PICKED_UP, OrderStatus.CANCELED)); // 已接单 - 运输中 / 已取消 TRANSITIONS.put(OrderStatus.PICKED_UP, Set.of( OrderStatus.IN_TRANSIT, OrderStatus.CANCELED)); // 运输中 - 待确认 TRANSITIONS.put(OrderStatus.IN_TRANSIT, Set.of( OrderStatus.PENDING_CONFIRM)); // 待确认 - 已完成 / 已退款 TRANSITIONS.put(OrderStatus.PENDING_CONFIRM, Set.of( OrderStatus.COMPLETED, OrderStatus.REFUNDED)); } public static boolean canTransit(Integer from, Integer to) { SetInteger allowed TRANSITIONS.get(from); return allowed ! null allowed.contains(to); } }为什么用这种方案因为状态流转不是“值A改成值B”那么简单很多时候还要附带业务动作比如进入“运输中”状态时要通知用户“司机已出发”进入“待确认”状态时要触发支付尾款的创建。如果直接在业务代码里乱改状态很容易漏掉这些联动通知。状态机很关键的处理是所有变更状态的操作都要带前置状态SQL要写成“update t_order set statusnew_status where idxxx and statusold_status”利用行锁做乐观锁的兜底。我在实践里发现这套方案不仅防并发连别人手滑在数据库客户端改错状态的场景也能兜回来。3.4 支付回调与退款处理幂等性怎么保证支付是另一个容易出问题的环节。支付流程是后端生成预支付单返回给前端拉起微信支付支付成功后微信服务器异步回调后端接口通知结果。这个回调有几个特点不定时、可能重复、可能乱序。同一个支付结果微信可能会重试推送两三次如果你处理回调的逻辑不是幂等的就会造成重复派单、重复入账。我的做法是在数据库里加一张payment_notify表用商品订单号做唯一索引。回调到达时先尝试插入一条通知记录如果插入失败说明已经处理过直接返回成功。只有插入成功的请求才走后续的业务逻辑。Transactional(rollbackFor Exception.class) public void handlePayNotify(NotifyRequest request) { // 1. 幂等校验订单号唯一索引 PaymentNotify notify new PaymentNotify(); notify.setOrderNo(request.getOutTradeNo()); try { paymentNotifyMapper.insert(notify); } catch (DuplicateKeyException e) { // 已处理过直接返回 return; } // 2. 更新订单支付状态 Order order orderMapper.selectByOrderNo(request.getOutTradeNo()); if (order.getPayStatus() PayStatus.UNPAID) { orderMapper.updatePayStatus(order.getId(), PayStatus.PAID); // 3. 触发订单进入待接单状态 orderStatusService.makeOrderPickable(order); } }这里面还有一个容易踩坑的地方支付回调里不能完全信任外部传来的金额字段。我当时对接微信支付V3时发现回调报文里的金额字段是可以被伪造的当然有签名校验但签名只能证明报文来自微信稳妥的做法是重新用订单编号调用微信的查询接口拉取官方金额和自己订单表里的应付金额对比对不上就不处理。至于退款流程是用户发起取消申请后台审核通过调用微信退款接口然后等待退款回调。退款也要做状态记录我直接复用payment_notify表加一个notify_type字段区分支付和退款。3.5 消息推送与位置上传高频率接口的优化这里的核心是两个接口司机位置上传和用户端订单位置查询。如果设计得不好这两颗耗电大户会让司机骂娘、用户骂街。司机端每隔3秒上传一次经纬度后端用Redis哈希存司机最新位置不落库。用户端查看订单轨迹时先从Redis拉司机当前坐标渲染再把历史轨迹从轨迹表查出来画线。这样既满足了实时位置展示又不会因为频繁写库把MySQL IO耗尽。司机位置上传接口本身要加限流我用的是Redis计数器每3秒允许一次超频率的直接丢弃不做无效读写。4. 项目难点与排坑实录4.1 地图坐标偏移与逆地理编码的坑地图这块是外部依赖中坑最多的地方。我当时接入的是高德地图API有一个坑特别典型用户选地址时前端拿到的是当前地图SDK偏转后的坐标GCJ-02坐标系但逆地理编码时如果直接拿这个坐标去请求结果会出现几百米的偏移。原因是国内要求公开地图坐标必须经过加密偏移处理和原始GPS坐标WGS-84有偏差需要做坐标转换。解决办法是在后端做一套坐标转换工具类在落库之前统一把坐标转化为GCJ-02标准存档。订单表和轨迹表不允许直接存GPS原始坐标。另外一个坑是逆地理编码的调用频率限制。用户在地图页拖拽定位标一秒可能触发好几个逆地理编码请求QPS直接打到接口配额上限。我的做法是前端拖拽结束后300毫秒做防抖再发请求后端再加一层限流缓冲。4.2 抢单并发场景下Redis锁的失效问题抢单逻辑看似简单我在测试环境就踩过一次“幽灵锁”的坑。当时线上出现一个现象A司机抢单成功但订单状态没有变成“已接单”而且在Redis里锁还存在导致这个订单再也无法被其他司机抢走。排查过程很曲折。先把场景复现出来两台服务器同时在线客户端A请求先到Redis setnx成功但在更新数据库这步业务事务因等待一个死锁超时回滚了此时代码里删Redis锁的操作在catch块里也执行了但删锁的时候恰好另一个司机B的setnx请求已经成功写入于是A的删除操作把B设置的锁给删了接着B的数据库更新语句因为状态已经被A的事务回滚前改过更新也失败了B的delete锁操作又错误地删掉了下一个人的锁……最终结果就是锁被不断错误释放订单虽然在Redis里标记为“有人抢”实际却没有司机能真正接单成功。这个问题的根因是更新数据库失败时不能盲目删锁因为此时可能已经有其他司机成功拿到锁并更新了数据库。我的修复方案是删除锁之前先比对Redis锁里的value是不是自己设置的标识是自己的才删这就是Redisson实现里“锁的持有者校验”的简化版String lockValue redisTemplate.opsForValue().get(order:grabbed: orderId); if (driverId.toString().equals(lockValue)) { redisTemplate.delete(order:grabbed: orderId); }这个坑花了整整一个下午才定位也让我认识到并发问题在单机测试时永远测不出来必须要多线程压测、多实例部署才能暴露写并发代码一定要设想“如果我在执行中挂了”会怎么样。4.3 慢查询与地理索引优化项目上线后的第一周我就收到数据库报警某个查询平均耗时3秒多。定位到SQL后发现是抢单大厅的分页查询慢SELECT * FROM t_order WHERE status 1 ORDER BY create_time DESC LIMIT 50;数据量只有几千条的时候这个SQL毫无压力但当数据量到几十万在待接单状态堆积了大量历史订单后这个查询要用文件排序拿掉前面所有的匹配行再取50条。优化方案有两个第一确认业务里司机只关心附近几公里的订单所以在SQL里加上经纬度范围过滤SELECT * FROM t_order WHERE status 1 AND start_lat BETWEEN #{minLat} AND #{maxLat} AND start_lng BETWEEN #{minLng} AND #{maxLng} ORDER BY create_time DESC LIMIT 50;第二给status和create_time建联合索引。这个SQL优化后响应时间从3秒降到了300毫秒以内。顺带说一句很多人在订单表里建索引时不考虑组合查询的实际场景而是一股脑把每个字段都挂索引结果索引膨胀比表还大查询优化器反而选择全表扫描。4.4 支付回调丢单时的补偿机制还有一次比较惊险的情况用户确实付了款但订单一直停留在“待支付”状态客服投诉已经涌进来。排查后发现微信支付回调服务器到我们的回调接口之间出现网络闪断微信连续重试了几次还是没成功最终放弃了推送。这种场景不能只靠回调必须有一个主动对账的定时任务。我当时加了一个定时任务每隔5分钟扫描支付状态为待支付但创建时间超过10分钟的订单去微信支付查询订单状态发现已支付就自动补单。定时任务看起来简单但要注意两个问题一是扫描范围别太大建议每次取前100条并在时间窗口内循环避免对数据库造成压力二是对账任务处理成功一定要有日志我在日志里额外打印了订单号、支付时间、回调原始报文这样一旦出问题可以快速排查。这类补偿机制几乎是所有涉及支付的项目的必备环节不能因为有了回调就以为高枕无忧。5. 源码部署与二次开发建议5.1 本地如何快速跑起来项目源码拿到手后本地启动步骤如下。第一步准备环境JDK 1.8以上、Maven 3.6以上、MySQL 8.0、Redis 6.x。第二步数据库初始化在MySQL中创建move_car数据库导入sql/init.sql脚本。脚本里包含建表和基础计价规则数据。第三步修改配置打开application.yml把数据源地址改成你自己的localhost用户名密码Redis配置同样要改。地图服务key和微信支付的配置可以先留空不影响下单流程的联调测试。第四步启动后端服务本地端口默认8080。启动成功后访问Swagger页面就能看到所有接口文档。我把支付接入和地图接入做成可选配置这样初学的同学先跑通主流程再一步步把外部依赖接入不会被一堆配置项劝退。5.2 从MVP到商用版本还能怎么扩展做完主链路以后如果想往商用级别靠拢我有几个建议的扩展方向。第一个是派单策略。抢占模式适合业务早期但车辆利用率不高高峰期会出现大量订单没人接、低谷期司机抢单不积极。商用系统往往引入智能派单算法根据司机位置、接单率、用户评价综合打分用延迟队列做预分配。第二个是多城市运营。每个城市的计价规则、车型供需要隔离这就要引入城市维度做数据隔离同时后台管理系统也要支持多城市独立运营配置。第三个是更完善的风控体系。目前项目只做了基础的手机号和实名认证。商用环境还要考虑设备指纹检测同一个司机多设备重复接单、位置伪造识别司机是否真的按轨迹移动、异常退单监控。我自己在做这个项目的时候最大的收获不是代码量有多少而是把一个完整的业务链路拆成可执行的模块从订单到计价、从并发到支付、从地图到补偿每一个环节的坑都踩了一遍。把这些经验沉淀下来面对真实业务时心里有底得多。最后分享一个工程习惯每次开发新功能前先画一张“数据流转图”把涉及的表、状态、外部接口都标出来再动手写代码。这比上来就写Mapper层高效得多也是我踩过无数次坑之后才养成的习惯。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询