从Django到WebSocket:堂食外卖点餐与骑手配送系统实战

发布时间:2026/10/14 7:45:49
从Django到WebSocket:堂食外卖点餐与骑手配送系统实战 几个月前接到一个朋友的求助他想用 PythonDjango 给连锁餐厅做一套自己的点餐配送系统既要支持店内扫码点餐又要接外卖订单还得有一套骑手端给送餐员派单和跟踪配送状态。一开始他想直接用第三方外卖平台的商家后台但三个门店共用后厨出餐堂食和外卖的订单经常混在一块会员活动也没法灵活配第三方后台根本接不住这种既要又要还要的需求。于是我们确定了 Python Django Django REST Framework Channels 这套组合最终做出来的成品就是标题里的堂食餐厅外卖点餐骑手配送系统。这篇文章我把开发过程里真正能复用的部分完整拆一遍从数据建模、接口设计、WebSocket 实时推送到骑手配送闭环和上线前必须处理的并发坑。适合谁看如果你正在用 Django 做餐饮类项目或者准备接手一个点餐系统但对配送链路没想清楚又或者只是想看看怎么在 Django 里引入 WebSocket 做实时推送——这篇都对口。我会尽量把每个环节的为什么也讲清楚而不是只贴代码。1. 先把业务拆干净堂食、外卖、骑手配送这三套流程怎么共存很多新手拿到这个需求的第一反应是做一个点餐系统但真正开始建模之后会发现堂食和外卖的订单生命周期差别很大如果一开始就套同一个 Order 表硬写后面会越改越痛苦。我建议动手之前先把角色和流程画清楚。1.1 一个账号体系下的四类角色这个系统涉及的角色至少有四类堂食顾客、外卖顾客、餐厅员工前台、后厨、店长、骑手。堂食顾客和外卖顾客虽然都叫顾客但操作入口和行为模式完全不同堂食顾客扫码进入点餐页选择桌台加购下单等餐吃完结账。账号可以不做强制登录以桌台会话为主。外卖顾客浏览菜单加购填收货地址在线支付然后盯着订单状态等骑手送来。餐厅员工查看所有订单处理堂食的上餐状态、外卖的接单和出餐状态。骑手只看到分配给自己的配送任务取餐、送餐、确认送达。如果把堂食顾客和外卖顾客都放进同一套用户体系用 User 模型加 role 字段就行但在订单层面必须区分订单类型。我的建议是单独建一个 Order 表加 order_type 字段区分堂食和外卖而不是分成两张表各写一套逻辑。因为对后厨来说他们不关心订单来自哪条链路只关心待出餐列表对财务来说所有营收都要汇总到同一张订单流水表里。一张订单主表的好处是后续做报表、对账、统计翻台率都简单得多。1.2 堂食点餐和外卖下单的流程差异堂食流程的核心是桌台 现场结账外卖流程的核心是地址 在线支付 骑手配送。两种订单虽然都叫订单关键字段差别很大。堂食订单要关联桌台编号、用餐人数支付方式可以是结账台支付或扫码支付不需要收货地址外卖订单必须关联收货地址、联系人、联系电话、配送费、预计送达时间而且必然有完整的支付状态流转。所以在建订单表时建议把堂食和外卖共用的字段放主表把外卖特有的字段单独放一个扩展表或者干脆在 Order 模型上允许大量字段为空。两种方案都行我实际更推荐主订单表 外卖信息附加表的方式因为 Orders 表会被高频查询字段太多太宽会影响性能而且很多堂食订单用不上地址配送这些字段空着也浪费。1.3 技术选型为什么是 Django DRF Channels选型层面这套系统我用的是 Django 4.2 Django REST Framework Channels Redis MySQL。为什么不选 Node.js 或 Spring Boot主要原因是团队对 Python 栈最熟而且 Django 的 ORM 和 Admin 后台对业务系统的快速搭建帮助极大。餐厅管理端有大量的列表 搜索 筛选项 编辑表单这类 CRUD 页面Django Admin 稍微做点定制就能覆盖一大部分需求能省下大量前后端联调时间。实时推送是点餐系统的刚需后厨屏幕要实时弹出新订单用户端要看到订单状态从已支付变成商家已接单骑手配送中。传统做法是前端轮询但点餐场景更新频率高、用户量大轮询的体验和成本都不好。所以我选了 Channels在 Django 里直接跑 WebSocket配合 Redis 做 Channel Layer消息推下去有亚秒级延迟而且推送逻辑还能复用 Django 的认证和 ORM。Redis 在这里的第二个角色是缓存门店菜单、菜品分类这种读多写少的数据可以缓存订单并发扣库存也需要 Redis 的原子操作作为兜底。MySQL 存核心业务数据Redis 管推送和热点缓存这个分工基本能应对单店到几十店的规模。2. 数据模型设计把菜单、订单、配送翻译成 Django 模型模型设计是整个项目的地基。这一节我直接给出核心模型的代码片段并解释为什么这样设计。2.1 基础建模门店、桌台、菜品与分类先贴门店和菜品相关的模型from django.db import models class Store(models.Model): name models.CharField(max_length64) address models.CharField(max_length128) phone models.CharField(max_length20) is_active models.BooleanField(defaultTrue) created_at models.DateTimeField(auto_now_addTrue) class Table(models.Model): store models.ForeignKey(Store, on_deletemodels.CASCADE, related_nametables) table_no models.CharField(max_length10) # 桌台号比如 A01 capacity models.IntegerField(default4) # 可坐人数 status models.CharField(max_length20, defaultidle) # idle/occupied qr_code models.CharField(max_length128, blankTrue) # 扫码链接标识 class Category(models.Model): store models.ForeignKey(Store, on_deletemodels.CASCADE, related_namecategories) name models.CharField(max_length32) sort_order models.IntegerField(default0) class Dish(models.Model): store models.ForeignKey(Store, on_deletemodels.CASCADE, related_namedishes) category models.ForeignKey(Category, on_deletemodels.PROTECT, related_namedishes) name models.CharField(max_length64) description models.TextField(blankTrue) price models.DecimalField(max_digits8, decimal_places2) image models.ImageField(upload_todishes/, blankTrue) stock models.IntegerField(default0) # 0 表示不限量 status models.CharField(max_length10, defaulton) # on/off几个容易忽略的点菜品价格不能直接用外键商品当前价。订单生成时要把菜名、价格、图片快照冗余到订单项上否则以后改菜单价格历史订单全乱套。菜品分类用 PROTECT 外键而不是 CASCADE。分类下还有菜品时不能随手把分类删掉导致菜品一起消失PROTECT 会逼你先处理菜品再删分类。stock 字段先预留出来0 表示不限量。餐饮里很多菜本来就是当日售完即止限量菜才需要真正扣库存。桌台表里的 qr_code 字段用来生成桌台专属的扫码点餐链接链接里带上 store_id 和 table_id用户扫码后直接把桌台信息带给下单接口。2.2 订单主表和订单项为什么必须做快照订单相关的核心模型如下class Order(models.Model): ORDER_TYPE_CHOICES ( (dine_in, 堂食), (takeaway, 外卖), ) STATUS_CHOICES ( (pending_payment, 待支付), (paid, 已支付待接单), (accepted, 商家已接单), (preparing, 制作中), (delivering, 配送中), (completed, 已完成), (cancelled, 已取消), (refunding, 退款中), (refunded, 已退款), ) order_no models.CharField(max_length64, uniqueTrue) store models.ForeignKey(Store, on_deletemodels.PROTECT, related_nameorders) table models.ForeignKey(Table, nullTrue, blankTrue, on_deletemodels.SET_NULL, related_nameorders) user models.ForeignKey(User, nullTrue, blankTrue, on_deletemodels.SET_NULL, related_nameorders) order_type models.CharField(max_length20, choicesORDER_TYPE_CHOICES) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultpending_payment) total_amount models.DecimalField(max_digits10, decimal_places2, default0) delivery_fee models.DecimalField(max_digits6, decimal_places2, default0) remark models.CharField(max_length255, blankTrue) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) class OrderItem(models.Model): order models.ForeignKey(Order, on_deletemodels.CASCADE, related_nameitems) dish models.ForeignKey(Dish, nullTrue, on_deletemodels.SET_NULL) dish_name models.CharField(max_length64) # 下单时的菜名快照 dish_price models.DecimalField(max_digits8, decimal_places2) # 快照价格 quantity models.IntegerField(default1) subtotal models.DecimalField(max_digits10, decimal_places2) class TakeawayInfo(models.Model): order models.OneToOneField(Order, on_deletemodels.CASCADE, related_nametakeaway_info) receiver_name models.CharField(max_length32) receiver_phone models.CharField(max_length20) address models.CharField(max_length255) expected_time models.DateTimeField(nullTrue, blankTrue)这里有一个非常关键的设计决策Order 和 OrderItem 做成主从表。为什么不在 Order 里用一个 JSONField 存菜品列表早期原型为了开发快确实可以但一旦订单量上来你要按菜品维度统计销量、按时间段计算营收、做退款时部分退菜JSON 字段都会让你苦不堪言。用独立的 OrderItem 行记录以后上数据报表、做推荐、算毛利都顺手得多。另一个关键点是 OrderItem 里的dish_name 和 dish_price 是下单那一刻的冗余快照。菜品改价、改名都不影响历史订单商家打单、财务对账都依赖这个快照字段。dish 外键可以留空SET_NULL哪怕菜品删了订单照样可追溯。2.3 配送单与骑手和订单的关系要独立建模外卖订单的配送信息不要直接塞在 Order 表里。配送有自己完整的状态流待接单、已接单、已取餐、配送中、已送达还涉及配送超时、骑手取消、用户改地址等异常情况。所以单独建两张表class Rider(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, related_namerider_profile) store models.ForeignKey(Store, on_deletemodels.CASCADE, related_nameriders) phone models.CharField(max_length20) status models.CharField(max_length20, defaultoffline) # offline/online/busy current_lat models.DecimalField(max_digits9, decimal_places6, nullTrue, blankTrue) current_lng models.DecimalField(max_digits9, decimal_places6, nullTrue, blankTrue) updated_at models.DateTimeField(auto_nowTrue) class DeliveryOrder(models.Model): order models.OneToOneField(Order, on_deletemodels.CASCADE, related_namedelivery_order) rider models.ForeignKey(Rider, nullTrue, blankTrue, on_deletemodels.SET_NULL, related_namedelivery_orders) status models.CharField( max_length20, choices( (pending, 待接单), (accepted, 已接单), (picked, 已取餐), (on_the_way, 配送中), (completed, 已送达), (cancelled, 已取消), (timeout, 超时), ), defaultpending, ) assigned_at models.DateTimeField(nullTrue, blankTrue) picked_at models.DateTimeField(nullTrue, blankTrue) completed_at models.DateTimeField(nullTrue, blankTrue)配送单和订单用 OneToOneField 连接是因为一笔外卖订单最多只有一条配送记录双向都只关联一条。不要搞成配送单列表否则会出现一笔订单被两个骑手同时配送的脏数据。骑手表单独存 Rider 信息和 User 一对一不要把骑手的接单状态、定位信息塞进 User 表。这是我在实际项目中踩过的坑一开始想省事把配送状态放到 Order 表里结果堂食订单大量出现delivering状态毫无意义后面想给配送加超时字段根本没地方加。拆成独立表之后订单生命周期和配送生命周期各管各的逻辑干净很多。2.4 订单状态机的定义与流转规则状态机是整个系统最容易写乱的部分。我的建议是在业务层用一个常量映射把合法流转关系定死至少要让代码变成一张大白话表格而不是散落在各个 if 语句里。堂食订单常见链路pending_payment - paid - preparing - completed外卖订单多一步配送pending_payment - paid - accepted - preparing - delivering - completed。我用一个简单字典来约束合法跳转ORDER_STATUS_TRANSITIONS { pending_payment: {paid, cancelled}, paid: {accepted, refunding, cancelled}, accepted: {preparing, cancelled}, preparing: {delivering, completed}, delivering: {completed, timeout, cancelled}, completed: set(), cancelled: set(), refunding: {refunded}, refunded: set(), } def transition_order_status(order, new_status): if new_status not in ORDER_STATUS_TRANSITIONS.get(order.status, set()): raise ValueError(f非法状态流转: {order.status} - {new_status}) order.status new_status order.save(update_fields[status, updated_at])有了这张表前端展示按钮、后端接收状态更新请求都能做统一校验避免用户靠接口直接乱改订单状态。前端也可以直接拿这张映射来渲染下一步可操作按钮。3. 核心 API 实现堂食下单、外卖下单与支付回调这一节把订单链路的核心接口串起来。我没有用 Django 原生视图全部基于 Django REST Framework 的 APIView 和 Serializer 来做这样权限控制、序列化、文档生成都更顺手。3.1 堂食扫码点餐的下单逻辑堂食用户的请求大致是POST /api/dine-in/orders { table_id: 12, items: [ {dish_id: 1, quantity: 2}, {dish_id: 5, quantity: 1} ], remark: 不要辣 }后端主要做几件事校验桌台、校验菜品上下架状态、计算总价、生成订单和订单项、把桌台状态改成 occupied。核心代码如下class DineInOrderView(APIView): def post(self, request): table Table.objects.select_related(store).get(idrequest.data.get(table_id)) if table.status ! idle: return Response({code: 400, msg: 桌台已有人用餐}, status400) items_data request.data.get(items, []) dish_ids [item[dish_id] for item in items_data] dishes {d.id: d for d in Dish.objects.filter(id__indish_ids, statuson)} if len(dishes) ! len(set(dish_ids)): return Response({code: 400, msg: 部分菜品已下架}, status400) order Order.objects.create( order_nogenerate_order_no(), storetable.store, tabletable, order_typedine_in, statuspending_payment, total_amount0, ) total 0 for item in items_data: dish dishes[item[dish_id]] subtotal dish.price * item[quantity] OrderItem.objects.create( orderorder, dishdish, dish_namedish.name, dish_pricedish.price, quantityitem[quantity], subtotalsubtotal, ) total subtotal order.total_amount total order.save(update_fields[total_amount]) table.status occupied table.save(update_fields[status]) return Response({code: 0, data: {order_id: order.id, order_no: order.order_no}})眼尖的读者会发现这里我没扣库存也没做事务。堂食场景菜做不出来可以现场和顾客沟通一般不需要严格扣库存但如果你的菜单里有限量菜品就必须在生成订单项时扣掉对应数量并且用事务包起来这个放到最后一节详细讲。3.2 外卖下单与配送信息绑定外卖下单接口的风格类似但多两个步骤创建 TakeawayInfo以及生成配送单。整个流程建议用一个数据库事务包住from django.db import transaction class TakeawayOrderView(APIView): transaction.atomic def post(self, request): store_id request.data.get(store_id) items_data request.data.get(items, []) takeaway_data request.data.get(takeaway_info, {}) store Store.objects.get(idstore_id) order Order.objects.create( order_nogenerate_order_no(), storestore, userrequest.user if request.user.is_authenticated else None, order_typetakeaway, statuspending_payment, total_amount0, ) total 0 for item in items_data: dish Dish.objects.select_for_update().get(iditem[dish_id], statuson) # 限量库存校验 if dish.stock 0 and dish.stock item[quantity]: raise OrderError(菜品库存不足) subtotal dish.price * item[quantity] OrderItem.objects.create( orderorder, dishdish, dish_namedish.name, dish_pricedish.price, quantityitem[quantity], subtotalsubtotal, ) if dish.stock 0: dish.stock - item[quantity] dish.save(update_fields[stock]) total subtotal delivery_fee calculate_delivery_fee(store, takeaway_data[address]) order.total_amount total delivery_fee order.delivery_fee delivery_fee order.save(update_fields[total_amount, delivery_fee]) TakeawayInfo.objects.create(orderorder, **validate_takeaway_info(takeaway_data)) DeliveryOrder.objects.create(orderorder, statuspending) return Response({code: 0, data: {order_no: order.order_no}})注意这里用select_for_update()对菜品行加锁配合事务保证并发下单时不会超卖。我见过很多人用filter(stock__gtequantity)然后 update好像很简单但 select_for_update 在事务内更稳妥能避免先查询后更新之间的竞态窗口。支付环节是异步的。创建订单时状态先设 pending_payment用户调起微信/支付宝支付后支付平台会回调你的服务器。回调处理是整个订单链路里最需要谨慎的地方下面单独说。3.3 支付回调幂等是命根子支付回调的接口必须做到幂等。支付平台可能因为网络超时、重试机制同一笔支付结果回调两三次如果后端每次都把订单状态改成 paid用户还没察觉财务对账可就乱套了。做法是用订单号做唯一键回调处理前先查一次订单状态已经 paid 就直接返回成功如果状态是 cancelled 则需要特殊处理因为用户可能先发起了退款或者支付结果和取消操作存在竞态。核心代码class PaymentCallbackView(APIView): def post(self, request): data request.data order_no data.get(out_trade_no) trade_status data.get(trade_status) with transaction.atomic(): order Order.objects.select_for_update().get(order_noorder_no) if order.status paid: return Response({code: SUCCESS}) if order.status ! pending_payment: logger.error(f订单状态异常: {order.status}) return Response({code: FAIL}) if trade_status TRADE_SUCCESS: order.status paid order.save(update_fields[status, updated_at]) # 通知商家端有新订单WebSocket 推送 push_new_order_to_store(order) return Response({code: SUCCESS})支付回调还要注意验签。用支付宝或微信支付 SDK 自带的验签方法不要自己写签名算法能少踩很多坑。关于状态机的应用支付回调之后是 paid之后商家端接单会把状态改成 accepted后厨出餐改成 preparing出餐后进入配送环节。每一步变更都要落到同一个 transition 校验里避免某个后端接口私自改状态。4. WebSocket 实时推送让下单—接单—配送全链路动起来系统里实时性要求最高的三个场景商家端屏幕弹新订单、用户端看订单状态变化、配送页面看骑手位置。如果不做 WebSocket只能让前端每两三秒轮询一次体验差服务器压力也大。用 Django Channels 可以实现后端主动推送。4.1 Channels 基础环境搭建依赖安装pip install channels channels-redis daphne把 daphne 放在 INSTALLED_APPS 第一位再配置 ASGI# settings.py INSTALLED_APPS [ daphne, django.contrib.admin, # ... channels, ] ASGI_APPLICATION config.asgi.application CHANNEL_LAYERS { default: { BACKEND: channels_redis.core.RedisChannelLayer, CONFIG: { hosts: [(127.0.0.1, 6379)], }, }, }连接 Redis 后Channel Layer 能在不同 worker 进程之间传递消息。这一步是很多人卡住的地方本地开发没问题上生产用多进程跑必须把 Channel Layer 切到 Redis否则消息只能在单个进程内传播。ASGI 文件相应修改# asgi.py import os from django.core.asgi import get_asgi_application from channels.routing import ProtocolTypeRouter, URLRouter from channels.auth import AuthMiddlewareStack from channels.security.websocket import AllowedHostsOriginValidator import orders.routing os.environ.setdefault(DJANGO_SETTINGS_MODULE, config.settings) application ProtocolTypeRouter({ http: get_asgi_application(), websocket: AllowedHostsOriginValidator( AuthMiddlewareStack( URLRouter(orders.routing.websocket_urlpatterns) ) ), })AuthMiddlewareStack会从 WebSocket 握手阶段的 session 里解析出登录用户这样 Consumer 里就能直接使用self.scope[user]判断角色和权限。4.2 Consumer 设计与消息推送WebSocket 连接之后需要让不同角色的客户端加入不同的 group。我用一个非常简单的机制订单创建后会生成一个唯一的 channel 标识用户端和商家端都订阅这个标识。# orders/consumers.py import json from channels.generic.websocket import AsyncWebsocketConsumer class OrderConsumer(AsyncWebsocketConsumer): async def connect(self): self.user self.scope[user] self.order_no self.scope[url_route][kwargs][order_no] self.group_name forder_{self.order_no} if not self.user.is_authenticated: await self.close() return await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() async def disconnect(self, close_code): await self.channel_layer.group_discard(self.group_name, self.channel_name) async def order_status_push(self, event): await self.send(text_datajson.dumps({ event: event[event], order_no: event[order_no], status: event[status], }))在业务代码里推送状态消息时只要调用async_to_sync(channel_layer.group_send)( forder_{order_no}, { type: order_status_push, event: order_status_changed, order_no: order_no, status: order.status, }, )注意channel_layer.group_send的type参数对应到 Consumer 的方法名所以我在 Consumer 里写了order_status_push方法。这个命名是 Channels 的约定不要写错。很多人第一次用 Channels 就是这里连不上消息发了但客户端收不到。商家端监听的是整个门店的新订单所以会给每个门店单独建一个 group用户端监听的是自己这笔订单的 group。这样分配能把推送范围缩小避免所有用户挤在一个全局频道里收到别人订单的推送。4.3 骑手定位与配送进度的实时推送骑手端是另一个典型的 WebSocket 场景。骑手的手机每隔几秒要上报一次经纬度用户端要实时看到骑手位置。我的实现方案是class RiderLocationConsumer(AsyncWebsocketConsumer): async def connect(self): self.rider self.scope[user] self.group_name frider_{self.scope[url_route][kwargs][rider_id]} await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() async def receive(self, text_data): data json.loads(text_data) lat data.get(lat) lng data.get(lng) await self.channel_layer.group_send( fdelivery_{data[delivery_id]}, { type: rider_location, lat: lat, lng: lng, rider_id: self.rider.id, }, ) async def rider_location(self, event): await self.send(text_datajson.dumps({ event: rider_location, lat: event[lat], lng: event[lng], }))骑手端连接自己rider_{id}的 group 并上报位置配送中的订单 group 会把位置转发给正在等待的用户端。这样定位数据不落库也能完成实时轨迹展示如果需要历史轨迹回放再单独写一张位置日志表每 10 秒批量写一次即可。这里有个细节WebSocket 里收到位置消息后是否要立刻同步到 Redis 和数据库实时场景我一般先不落库等订单完成后把累计的位置日志一次性归档能大幅降低数据库压力。保存到数据库的字段包括delivery_id、lng、lat、create_time。定时任务去清理超龄数据即可。4.4 不搞全局群发按门店、按订单、按骑手三级分组很多人设计 WebSocket 时喜欢简单粗暴把消息发给所有连接。但点餐系统里门店多、用户多、订单多全局广播会造成大量无效消息。我实际按三类 group 隔离store_{store_id}门店的新订单提醒、订单状态变化商家端和门店大屏订阅。order_{order_no}具体某笔订单的用户端、门店端订阅订单每个状态都能推送到相关人。rider_{rider_id}骑手端自身连接配送位置和调度指令走这个通道。消息要发给谁、不发谁在业务层就决定好别在 Consumer 里做二次过滤。Consumer 主要负责转发业务层负责定向。5. 骑手配送的任务分配与状态闭环5.1 抢单还是派单系统里怎么选外卖配送任务分配有两种常见模式骑手抢单和系统派单。单店场景建议先做抢单订单出餐完毕配送单状态变为待接单后系统向在线骑手广播可抢单事件骑手点击抢之后配送单绑定到该骑手。class AssignDeliveryView(APIView): def post(self, request, delivery_id): delivery DeliveryOrder.objects.select_for_update().get(iddelivery_id) if delivery.status ! pending: return Response({code: 400, msg: 任务已被抢}, status400) rider Rider.objects.select_for_update().get(userrequest.user) if rider.status ! online: return Response({code: 400, msg: 当前不在线}, status400) delivery.rider rider delivery.status accepted delivery.assigned_at timezone.now() delivery.save() rider.status busy rider.save(update_fields[status]) # 推送订单物流信息给用户端 channel_layer get_channel_layer() async_to_sync(channel_layer.group_send)( forder_{delivery.order.order_no}, { type: order_status_push, event: delivery_assigned, order_no: delivery.order.order_no, status: delivering, }, ) return Response({code: 0, msg: 抢单成功})抢单的坑在并发多个骑手同时点抢单要依靠select_for_update()锁住配送单第二个请求进来时状态已经是 accepted直接返回失败。如果不用行锁两个请求都查到 pending 状态就会造成一单被两名骑手接走的脏数据。多门店或配送范围较大的时候可以升级成派单模式系统根据骑手当前位置、在线状态、空闲单量计算得分把单派给得分最高的骑手。但派单涉及骑手拒绝、超时重新分配等一整套策略复杂度指数级上升第一版不建议碰。5.2 配送状态流转从取餐到送达的完整闭环骑手端的状态流转顺序是 accepted - picked - on_the_way - completed。每一步都由骑手端发起请求class UpdateDeliveryStatusView(APIView): def post(self, request, delivery_id): delivery DeliveryOrder.objects.select_for_update().get(iddelivery_id) new_status request.data.get(status) allowed { accepted: {picked}, picked: {on_the_way}, on_the_way: {completed}, } if new_status not in allowed.get(delivery.status, set()): return Response({code: 400, msg: 非法状态流转}, status400) delivery.status new_status if new_status picked: delivery.picked_at timezone.now() elif new_status completed: delivery.completed_at timezone.now() delivery.order.status completed delivery.order.save(update_fields[status, updated_at]) delivery.rider.status online delivery.rider.save(update_fields[status]) delivery.save() # 推送状态给用户端 notify_order_status(delivery.order, new_status) return Response({code: 0, msg: ok})这里有一个关键点骑手确认送达时要同时更新订单状态、配送单状态、骑手状态。三个更新放在同一个事务里遇到失败必须整体回滚。我见过有人只在配送单上更新状态订单侧还是 delivering用户端一直显示不出已完成结果就是客服被问爆。配送超时的处理可以在配送单上记录超时时间由定时任务扫描超时未完成的配送单自动标记 timeout同时通知用户端配送可能超时。这个规则要和骑手绩效、延迟赔付挂钩的时候再单独设计状态。5.3 骑手在线状态与下班逻辑骑手端 App 顶部有一个上班/下班开关下班的时候不能接单。这个开关我在 Rider 表上用 status 字段管理但状态切换必须同步推送骑手上线时把骑手加入门店的在线骑手组骑手下线时从该组移除。这样抢单广播只需要发给在线骑手 group不用遍历所有骑手做判断。class RiderOnlineStatusView(APIView): def post(self, request): rider Rider.objects.select_for_update().get(userrequest.user) action request.data.get(action) # online / offline if action online: rider.status online elif action offline: if rider.status busy: return Response({code: 400, msg: 有配送任务在身不能下线}, status400) rider.status offline rider.save(update_fields[status]) return Response({code: 0})有任务在身的骑手不允许强行下线否则配送单会悬浮在半路。这个校验虽然简单但能避免一大批脏数据。6. 前端接入与 Django 静态文件新手最容易卡住的 404 问题很多人用 Django 写前端页面时都会遇到图片、CSS、JS 显示不出来的问题。社区里也经常看到有人问vscode 里写了 img 标签Django 的 static 文件显示不了这类问题这几乎是 Django 新手必踩的坑。这里单独拿出一节说明。6.1 静态文件配置的正确姿势开发环境下Django 通过django.contrib.staticfiles应用配合STATICFILES_DIRS来管理静态资源# settings.py STATIC_URL /static/ STATICFILES_DIRS [ BASE_DIR / static, ]模板里引入静态资源不要手写/static/images/logo.png而是用模板标签{% load static %} img src{% static images/logo.png %} altlogo script src{% static js/app.js %}/script{% static %}标签会根据 STATIC_URL 自动拼路径以后你改配置也不用改模板这是官方推荐的做法。那么为什么vscode 写了 img 标签Django 显示不了最常见的原因有三个{% load static %}漏写了。模板顶部没有加载 static 标签库{% static %}语法直接报错。文件放在 static 目录下但INSTALLED_APPS里没有django.contrib.staticfiles开发服务器根本不提供静态文件服务。文件路径大小写不对。Linux 部署环境路径是大小写敏感的本地 Windows 可能没问题一部署到服务器就 404。我自己的排查习惯是三步走先看页面源码里 img 标签最终生成的 URL 是什么再确认STATIC_URL配置和文件实际路径是否吻合最后确认服务器上静态目录权限是否正确。6.2 开发和生产环境的静态文件处理差异开发模式下python manage.py runserver会自动提供静态服务但这套机制只适合开发。生产环境用python manage.py collectstatic把所有静态文件收集到STATIC_ROOT再交给 Nginx 或 Whitenoise 处理。如果项目用 Django 自带服务器直接跑在生产环境性能和安全性都不过关一定要加一层反向代理。我通常用 Whitenoise 做生产静态文件服务因为它配置简单、能压缩、能设置缓存头适合中小型项目pip install whitenoise# settings.py MIDDLEWARE [ django.middleware.security.SecurityMiddleware, whitenoise.middleware.WhiteNoiseMiddleware, # ... ] STATIC_ROOT BASE_DIR / staticfiles STORAGES { staticfiles: { BACKEND: whitenoise.storage.CompressedManifestStaticFilesStorage, }, }然后执行python manage.py collectstatic --noinput静态文件 404 的另一个隐蔽原因是STATICFILES_DIRS配了但collectstatic没有执行或者服务器上的STATIC_ROOT目录不存在。所以生产环境排查思路要固化下来不要每次都从模板开始看。6.3 图片上传与展示的路径问题菜品图片用的是ImageField所以还需要配置MEDIA_URL和MEDIA_ROOT# settings.py MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media开发环境需要在 urls.py 里手动追加from django.conf import settings from django.conf.urls.static import static urlpatterns [ # ... ] if settings.DEBUG: urlpatterns static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)生产环境同样交给 Nginx 或对象存储。如果图片一直加载不出来先确认是 static 还是 media 的路径再分别排查不要混为一谈。我现在最建议的做法是菜品图片等所有可上传文件在生产环境直接用对象存储数据库只存路径能省掉大量运维成本。7. 并发扣减、权限控制与幂等上线前必须补的三个坑前面梳理完主流程这里集中讲讲那些跑通 demo 之后才见到的工程问题也是最容易在线上出事故的地方。7.1 限量菜品的并发扣库存外卖场景下如果菜品设置了限量库存多个用户同时抢购时必须先扣库存再创建订单否则会出现超卖。前面外卖下单接口里我写了select_for_update()这是最简单有效的行锁方案但在使用上有几个注意点必须在事务内使用with transaction.atomic()包住整个扣减和下单流程。尽量把高并发热点菜品的行锁持锁时间压缩到最短不要在持锁期间做网络请求、支付回调等耗时操作。如果单菜品瞬间并发极高行锁会碰到数据库连接池瓶颈需要改用 Redis 的 Lua 脚本做原子扣减把库存放在 Redis 里DB 只作最终一致性落库。对于餐厅点餐这种量级行锁足够。先把正确性做好性能等到量真正上来再优化也不迟。7.2 角色权限控制Django 自带的 User 模型有is_staff但不区分骑手、商家、顾客。我的做法是扩展一个 UserProfile或者用独立的 Rider / MerchantProfile 表关联 User再通过 DRF 的权限类做接口控制from rest_framework.permissions import BasePermission class IsRider(BasePermission): def has_permission(self, request, view): return ( request.user.is_authenticated and hasattr(request.user, rider_profile) ) class IsStoreStaff(BasePermission): def has_permission(self, request, view): return request.user.groups.filter(namestore_staff).exists()然后在视图上直接配置class AssignDeliveryView(APIView): permission_classes [IsRider]这一层一定要做否则任何人只要知道配送单 ID 就能抢单、改状态。权限校验对被调接口尤其重要。7.3 订单幂等与重复请求最后一个必须处理的坑是用户在前端点了两次提交或者网络重试导致同一笔订单被创建两次。处理方案有两个层面前端提交按钮点击后立即 disabled并在请求未返回前禁止重复提交。后端order_no唯一前端在创建订单时生成一个client_tokenUUID传给后端后端用这个 token 做唯一索引。同一 token 的第二次请求直接返回第一次创建的订单数据。class TakeawayOrderView(APIView): transaction.atomic def post(self, request): client_token request.data.get(client_token) if not client_token: return Response({code: 400, msg: 缺少client_token}, status400) existing Order.objects.filter(client_tokenclient_token).first() if existing: return Response({code: 0, data: {order_no: existing.order_no}}) # 正常创建订单逻辑 ...支付回调幂等在前面已经讲过配送状态更新的接口也要用同样的 select_for_update 锁行加状态校验。整个系统里凡是涉及写状态的接口都要过一遍幂等性和竞态检查这是餐饮系统稳定运行的基本功。最后再分享一个我的个人经验这类系统上线前最好安排专门的状态机走查把所有可能的用户操作路径都过一遍比如用户支付后立刻退款骑手配送中商家取消订单堂食下单后换桌等等极端路径逐个在代码里查一遍状态流转是否兜得住。绝大多数线上事故都不是主流程出问题而是这些边缘路径被打穿。项目做完到现在虽然功能还在不断迭代但我始终保留着最初那张状态流转表每次改逻辑都先对表心里有底。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询