☰
基于Django的Web点餐系统:从数据模型到部署的完整实践
2026/10/22 2:41:41 网站建设 项目流程

如果一家小餐馆还在靠服务员手写点菜单,高峰期收银台排队、厨房喊菜、漏单错单几乎是家常便饭。我身边一位开快餐店的朋友就为这个头疼了很久,最后我们决定自己动手做一个基于 Python 和 Django 的 Web 点餐系统——顾客打开网页看菜单、加购物车、在线下单,商家后台接单、制作、出餐,全程不再依赖纸质单和口头传菜。这套系统从技术选型到真正跑起来,前后花了三周时间,中间踩了不少坑,也总结出很多文档里不会写的经验。

这篇文章我会把整个设计和实现过程完整拆开:为什么选 Django 而不是其他框架、需求边界怎么划、数据模型怎么设计、下单时的并发和事务怎么处理、商家后台怎么做才能真正好用,以及最后部署上线时容易忽略的细节。如果你是正在做课程设计、毕业设计,或者第一次想独立完成一个从数据库到前端页面都跑通的 Web 项目,这篇内容应该能帮你省下大量试错时间。

1. 为什么选Django做点餐系统:从门店现场算起的技术选型账

1.1 先看清点餐系统本质上解决的是什么问题

点餐系统表面上是一堆页面和按钮,本质上是一个信息流系统:顾客把想吃的菜品信息传给商家,商家把接单和出餐状态传回给顾客。整个系统里流转的核心数据只有三类——菜品、订单、订单状态,外加用户和商家这两个操作角色。

搞清楚了这一点,技术选型就有了判断标准。这个项目不需要多高的并发,不需要多复杂的算法,也不需要处理海量数据,它最看重的是三点:开发效率要高、数据一致性要可靠、后台管理要省事。说白了就是希望用最少的时间把一套能真正用来营业的系统做出来,而不是为了炫技引入一堆用不上的架构。

1.2 常见框架放在点餐场景里怎么对比

我当时在 Python 生态里主要比较了 Django 和 Flask,也顺手看了一眼 SpringBoot。对比下来心里就比较有数了,这里用一个表格还原当时的选择过程:

框架优势放在点餐系统里的短板
Django自带 Admin 后台、ORM、用户认证、CSRF 防护、Session 机制框架有点“重”,但点餐系统其实用得上这些能力
Flask轻量、灵活,路由和视图写法自由用户认证、后台管理、表单处理都要自己搭,工期拉长
SpringBoot企业级能力强,性能好,生态成熟上手门槛高,环境配置重,做小项目属于杀鸡用牛刀
前后端分离(Vue/React + DRF)页面交互体验可以做得更顺滑多一层接口联调和跨域处理,单人开发时成本偏高

单纯从语言习惯出发,Python 写业务逻辑确实快,但真正让我下定决心用 Django 的,是它自带的那些“现成零件”。后面 1.3 节细说。

1.3 Django 的内置能力正好长在点餐需求的形状上

Django 有几个内置能力,放在点餐系统里几乎就是一一对应关系。

第一个是 Django Admin。商家需要维护菜品分类、菜品价格、上下架状态,这些本质上都是最典型的后台增删改查。Django Admin 在把模型定义好之后,后台管理页面几乎是免费送的,不用写一行前端代码。

第二个是 ORM。点餐系统里有大量的关联查询,比如一个订单下面挂了哪些菜品、一个分类下面有哪些菜品,用 Django ORM 的 ForeignKey 和 related_name 就能把关系理得很清楚,迁移工具还能在改表结构时少掉很多头发。

第三个是 Session 和用户认证。顾客逛菜单、加购物车时不强制登录,下单时才需要身份信息,Session 机制很自然地承接这个场景。Django 自带的 User 模型和 login_required 装饰器,也能让商家后台的权限控制直接落地。

第四个是安全防护。下单和支付相关的页面最怕 CSRF 攻击和参数篡改,Django 在表单里默认带 CSRF token,模板渲染自带转义,能主动挡掉一大批常见的 Web 攻击。做项目如果让我自己从零去写这些安全逻辑,说实话没那个精力也没那个水平。

所以我的结论是:选 Django 不是因为它是“流行框架”,而是因为它的内置能力和点餐系统的需求匹配度最高,能把精力集中在业务逻辑上,而不是去造轮子。

2. 需求边界与功能模块拆解:先分清“必须做”和“坚决不做”

2.1 三类角色的权限与核心动作

动手写代码之前,我先把系统里的角色画清楚。点餐系统里一共三类人:顾客、商家操作员、系统管理员。

角色核心动作数据权限
顾客浏览菜单、加购物车、提交订单、查看订单状态只能看自己创建的订单
商家操作员查看新订单、接单、更新制作状态、标记完成能看所有订单,不能改菜品价格
系统管理员管理菜品分类、菜品上下架、价格调整全部权限

当时做的版本把商家操作员和系统管理员拆开了,但实际使用中,小店往往就是老板一个人同时干这两件事。于是我在 Django 的 User 模型上加了两个布尔字段区分角色,一个 is_merchant 用于标记商家身份,另一套用 Django 自带的 is_superuser 管管理员权限。权限控制上,商家后台的所有视图都加了 login_required 和自定义的 merchant_required 装饰器,防止普通顾客直接访问后台链接。

2.2 功能清单:P0、P1、P2 分级

需求一定要分级,不然很容易陷入“什么都想做”的泥潭。我当时把功能分成三个优先级:

  • P0(必须做):菜品分类展示、菜品列表、加入购物车、提交订单、生成订单号、商家接单、更新订单状态、后台菜品维护。
  • P1(应当做):用户注册登录、菜品关键词搜索、下单备注(比如“不要辣”)、按分类筛选、商家端待接单列表。
  • P2(有条件再做):接入真实在线支付、桌台二维码绑定、顾客评价、配送地址管理。

这里面最需要解释的是为什么真实支付被放在 P2。在线支付涉及第三方商户号申请、回调验签、订单状态和支付状态的对账,一套流程下来复杂度至少翻倍,而且个人开发者的资质申请本身就有门槛。门店试运营阶段完全可以线下收款,系统里只保留订单金额和支付状态字段,等跑顺了再对接支付接口。这种“砍需求”不是偷懒,而是保证项目能按期交付的关键。

2.3 一条订单从创建到出餐的完整链路

需求理清楚之后,我把整条链路画了一遍,确认每个环节都有对应的页面和接口,避免写着写着发现中间断了一截。

完整链路是这样的:顾客打开点餐首页/menu,看到分类和菜品;把菜品加入购物车,购物车数据临时存在 Session 里;进入购物车页面/cart确认数量和金额,填写备注;点击提交订单,后端生成订单号和订单记录;商家登录后台/merchant/orders/,看到置顶的待接单订单;商家点击接单,状态从“待接单”变成“已接单”;后厨制作完成后,商家把状态更新为“已完成”;顾客刷新订单详情页,看到自己这单的状态变化。

URL 结构我按业务模块来组织,不是把所有接口都塞在同一个 app 里。项目里拆了两个 app:mall负责顾客端菜单和下单,merchant负责商家后台。这样目录清晰,后面加功能不会互相干扰。

3. 数据模型设计:订单字段和状态流转比想象中更重要

3.1 核心表关系与字段选型

数据模型是整个系统的地基,我在这一块花的时间比写视图还多。点餐系统最核心的表是这几张:菜品分类、菜品、订单、订单项。用 Django 模型表达出来是这样:

from django.db import models from django.contrib.auth.models import User class Category(models.Model): name = models.CharField(max_length=32, unique=True) sort = models.IntegerField(default=0) is_active = models.BooleanField(default=True) class Meta: ordering = ['sort', 'id'] def __str__(self): return self.name class Dish(models.Model): category = models.ForeignKey( Category, on_delete=models.CASCADE, related_name='dishes' ) name = models.CharField(max_length=64) price = models.DecimalField(max_digits=7, decimal_places=2) image = models.ImageField(upload_to='dishes/', blank=True) description = models.TextField(blank=True) stock = models.IntegerField(default=0) sales = models.IntegerField(default=0) is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return self.name class Order(models.Model): ORDER_STATUS_CHOICES = ( ('pending', '待接单'), ('accepted', '已接单'), ('cooking', '制作中'), ('delivering', '待上桌'), ('completed', '已完成'), ('canceled', '已取消'), ) order_no = models.CharField(max_length=32, unique=True) user = models.ForeignKey( User, on_delete=models.PROTECT, related_name='orders' ) status = models.CharField( max_length=16, choices=ORDER_STATUS_CHOICES, default='pending' ) total_amount = models.DecimalField(max_digits=8, decimal_places=2) remark = models.CharField(max_length=255, blank=True) created_at = models.DateTimeField(auto_now_add=True) class OrderItem(models.Model): order = models.ForeignKey( Order, on_delete=models.CASCADE, related_name='items' ) dish = models.ForeignKey(Dish, on_delete=models.PROTECT) dish_name = models.CharField(max_length=64) price = models.DecimalField(max_digits=7, decimal_places=2) quantity = models.PositiveIntegerField(default=1)

这里有几个设计决策值得展开说一下。OrderItem里为什么要冗余dish_name和price?因为菜品价格和名称是会变的,比如老板把宫保鸡丁从 28 改成 32,历史订单里的金额不能跟着变。把下单时的名称和价格快照存进订单项,既方便对账,也避免以后出现“订单里显示两个价格”这种尴尬。Order里存total_amount同理,属于典型的读多写少冗余设计。

Dish.price我用的是DecimalField而不是FloatField。float 在二进制里没法精确表示 0.1,计算金额时会出现 0.1 + 0.2 = 0.30000000000000004 这种问题,放到金额计算里就是事故了。DecimalField 是定点数,配合Decimal()做运算才能保证分毫不差。

3.2 订单号生成策略

订单号是订单在现实世界的身份证,顾客报号、商家查单全靠它。我用的是“日期 + 随机短码”的格式,大概长这样:20250607153028A3F2,前 14 位是年月日时分秒,后面 4 位是随机字符串。

为什么不直接用自增 id?因为 id 会被直接抓取到,别人能通过订单号猜到你这单生意多不多,而且位数不稳定。日期前缀的好处是顾客一看就知道是哪天下单的,商家按订单号搜索时也可以用日期收窄范围。生成时加了唯一约束,如果插入时撞了唯一索引就重新生成一个,碰撞概率极低,但防御逻辑还是得写上。

3.3 订单状态:单一状态字段加合法流转表

订单状态我没有用一堆布尔字段(比如is_paid、is_cooking、is_done)来表示,而是只用一个status字段,配合白名单式的状态流转控制。原因是:多个布尔字段的组合会产生无数种非法状态,比如is_paid=True同时is_canceled=True,这种脏数据一旦出现,后期统计和展示都会出问题。

合法状态流转我维护成一张表:

当前状态可以流转到
待接单 pending已接单 accepted、已取消 canceled
已接单 accepted制作中 cooking、已取消 canceled
制作中 cooking待上桌 delivering、已完成 completed
待上桌 delivering已完成 completed
已完成 completed无
已取消 canceled无

代码层面,我在Order模型里加了一个简单的状态流转校验方法:

STATUS_TRANSITIONS = { 'pending': {'accepted', 'canceled'}, 'accepted': {'cooking', 'canceled'}, 'cooking': {'delivering', 'completed'}, 'delivering': {'completed'}, 'completed': set(), 'canceled': set(), } def transition_to(self, new_status): if new_status not in STATUS_TRANSITIONS.get(self.status, set()): raise ValueError( f'非法状态流转:{self.get_status_display()} -> ' f'{dict(self.ORDER_STATUS_CHOICES).get(new_status)}' ) self.status = new_status self.save()

之所以要把状态流转控制放在模型层而不是视图层,是因为模型是最后一道防线。视图层写错了可能漏校验,但只要模型层校验住了,任何途径进来的非法流转都会被拦截。

4. 下单链路核心实现:购物车、事务与并发扣库存

4.1 购物车:用 Session 还是数据表?

购物车的实现方式有两种主流选择:存 Session 或者建一张购物车表。我最终选了 Session 方案,理由是点餐场景和电商购物车有本质区别——顾客在餐厅里点餐,购物车生命周期很短,通常几分钟内就会下单,不存在“十天后再回来结算”的情况。Session 过期丢购物车,对餐厅场景几乎没影响。

Session 方案还有一个好处:顾客不登录也能用。我先在 Session 里存一个字典,键是菜品 id,值是数量。提交订单时再要求用户登录,界面友好很多,不会因为“必须注册才能点菜”这一步流失顾客。

购物车的存取逻辑我是这样封装的:

def get_cart(request): return request.session.get('cart', {}) def add_to_cart(request, dish_id, quantity=1): cart = get_cart(request) cart[str(dish_id)] = int(cart.get(str(dish_id), 0)) + quantity request.session['cart'] = cart request.session.modified = True

注意修改 Session 里的可变对象之后,必须手动把request.session.modified设为 True,否则 Django 可能认为 Session 没有变化而不保存。这个细节我在调试时浪费了半小时才搞清楚。

4.2 下单时的原子性保障

下单不是一个操作,而是一组操作:创建订单、创建订单项、扣减库存、累加销量。这组操作任何一个失败,整单都应该作废,否则就会出现“订单创建成功但库存没扣”或者反过来“库存扣了但订单没生成”的问题。

我用transaction.atomic()把整段逻辑包起来,同时用select_for_update()锁住要扣库存的菜品行。这里放了核心代码:

from django.db import transaction from django.db.models import F @transaction.atomic def create_order(user, cart_items, remark=''): dish_ids = [int(did) for did in cart_items.keys()] dishes = Dish.objects.select_for_update().filter( id__in=dish_ids, is_active=True ) dish_map = {dish.id: dish for dish in dishes} if len(dish_map) != len(dish_ids): raise ValueError('部分菜品不存在或已下架') total = sum( dish_map[did].price * qty for did, qty in cart_items.items() ) order = Order.objects.create( user=user, order_no=generate_order_no(), total_amount=total, remark=remark, ) order_items = [] for did, qty in cart_items.items(): dish = dish_map[did] order_items.append(OrderItem( order=order, dish=dish, dish_name=dish.name, price=dish.price, quantity=qty, )) Dish.objects.filter(id=dish.id).update( stock=F('stock') - qty, sales=F('sales') + qty, ) OrderItem.objects.bulk_create(order_items) return order

这里有几个关键点。第一,select_for_update()会把命中的菜品行锁住,直到事务提交,这样两个人同时抢最后一份菜品时,第二个请求会等第一个事务结束再读取,看到的库存就是扣减后的,不会超卖。第二,扣库存用的是F('stock') - qty,让数据库去执行原子减法,而不是先在 Python 里读取一个旧值再写回,避免并发下互相覆盖。第三,前端传来的总金额绝对不能信任,我这里是完全根据数据库里的菜品价格和数量重新计算的,杜绝了用户篡改价格的隐患。

4.3 商家接单与状态流转:轮询刷新够不够

顾客端下单之后,商家端需要及时看到新单。这个问题在最初版本里我用了最简单的方式:商家订单页面用 JavaScript 每 5 秒拉一次接口,然后把订单列表重新渲染一遍。接口返回的是 JSON 数据,前端用setInterval定时请求,新订单来的时候页面会自动刷新。

有人会问,为什么不用 WebSocket 做实时推送?我的考虑是,小店场景下订单频率很低,可能几分钟才来一单,5 秒一次的轮询完全够用,而 WebSocket 要引入额外服务、维护长连接、处理断线重连,复杂度明显高一个量级。Django Channels 确实能做,但为了一个低频场景去上这套架构,纯属给自己找事。只有当订单频率高到每秒几十单、轮询造成明显延迟和服务器压力时,再考虑升级到 WebSocket 才合理。

状态更新操作的视图层,我只是在模型层校验的基础上,做了一层用户身份和订单归属的检查,防止顾客端瞎传参数把订单状态改乱。

5. 商家后台与运营细节:从“能演示”到“能开店”

5.1 用 Django Admin 快速搭后台,再决定要不要自建商家端

Django Admin 在管理菜品时非常好用,给Dish模型注册一个ModelAdmin后,增删改查和图片上传全部自动生成。我当时把分类、菜品、订单都注册进了 Admin,然后用is_superuser权限限制只有管理员能进入。

但真到门店试运行的时候发现,Admin 的交互是面向桌面浏览器的,在手机上点起来很别扭,而且商家操作员不应该看到系统管理员的全部能力。于是我又在merchant应用里自建了一套商家端页面,只保留三块核心功能:新订单列表、订单状态操作台、菜品库存预警。

后来我把这两套东西明确分工了:自建商家端给店里日常营业用,Django Admin 留给我自己做系统维护和批量修改数据用。分工明确之后,两边都不别扭。

5.2 待接单提醒与订单列表体验

商家端订单页面的第一版就是把所有订单按时间倒序排列,实际试运行后发现,店里忙的时候根本没有精力一条一条往下翻找新订单。后来改成了按状态分栏展示,并且“待接单”永远置顶,用醒目的橙色标签标出来。

用了 5 秒轮询之后,新订单能在十几秒内出现在最顶部,商家的响应速度已经比之前吼来吼去快很多。对于单量稍大的场景,我在页面里加了一个基于sessionStorage的本地计数,轮询发现待接单数量比上次多时,就弹一个系统提示音,生活在嘈杂的餐饮环境里,声音提醒比视觉提醒更实用。

还有一个小细节:顾客下单时填的备注(比如“少辣”“不要香菜”)一定要在订单卡片上醒目显示。商家的操作台上,备注字号比菜品名还大,因为漏看一条备注导致的客诉,远比漏看一条菜品严重。

5.3 菜品上下架与库存预警的实用处理

菜品上下架我用Dish.is_active字段实现。下架后的菜品在顾客端菜单里不再显示,但历史订单中的OrderItem仍然通过dish_name快照正常展示,不会受影响。

库存预警的处理是试运行阶段加上的。最开始库存只在下单时校验,后来发现老板需要“提前知道哪个菜快卖完了”才能决定要不要去备货。我在商家端菜品列表里加了一个规则:stock小于 5 的菜品,数量标红;等于 0 且is_active=True的菜品,给出“已售罄但未下架”的提示。这让后厨在高峰期能够提前准备,避免冷冰冰的“已售罄”突然出现在顾客面前。

6. 部署上线与实测复盘:本机能跑只是第一步

6.1 部署组合与配置要点

开发环境里用的是 Django 自带的runserver和 SQLite,一旦要正式营业,这套组合肯定撑不住真实的访问和并发。我最后采用的部署组合是 Nginx + uWSGI + PostgreSQL,Django 应用由 uWSGI 托管,Nginx 负责静态文件、媒体文件和反向代理。

选 PostgreSQL 而不继续用 SQLite,是因为 SQLite 在并发写入上锁粒度太粗,点餐高峰期多个订单同时写入时容易报出database is locked。PostgreSQL 的并发控制明显更稳,而且迁移成本很低,Django 的 ORM 切换数据库基本不需要改业务代码。

生产环境配置最容易踩的坑是静态文件。Django 模板里引用的 CSS、JS 和菜品图片,在开发环境由 Django 直接服务,生产环境必须交给 Nginx。我的做法是:

server { listen 80; server_name your-domain.com; location /static/ { alias /var/www/ordering/static/; } location /media/ { alias /var/www/ordering/media/; } location / { include uwsgi_params; uwsgi_pass unix:/var/www/ordering/ordering.sock; } }

对应地,Django 的settings.py里设置:

DEBUG = False ALLOWED_HOSTS = ['your-domain.com'] STATIC_ROOT = '/var/www/ordering/static/' MEDIA_ROOT = '/var/www/ordering/media/'

6.2 上线前要过的安全检查

项目跑起来是一回事,能安全地上线是另一回事。我整理了一张上线前检查清单,每项都过了一遍:

检查项要求说明
DEBUG必须设为 False开着会把报错堆栈暴露给访问者,泄露源码路径甚至数据库信息
ALLOWED_HOSTS只填自己的域名防止 HTTP Host 头攻击
SECRET_KEY更换为环境变量注入不能再用仓库里的默认值
CSRF 防护所有 POST 表单带 tokenDjango 默认开启,确认没关掉
商家端权限普通用户不可访问用装饰器强制校验角色
数据库密码使用环境变量不写死在代码里

还有一个容易被忽略的点:Cloudflare或者反代层如果有 HTTPS,需要在 Django 里配置SECURE_PROXY_SSL_HEADER,否则request.is_secure()永远返回 False,后续做重定向或安全策略时会出问题。我在这上面踩过一次,排查了很久才发现是代理头没配。

6.3 试运行阶段踩过的典型问题

系统在朋友店里试运行了两周,除了一些小问题,整体稳定,这里挑几个最有代表性的问题讲:

第一个是菜品图片 404。开发环境图片正常,部署之后很多图片加载不出来。检查发现MEDIA_ROOT目录没有创建,Nginx 的/media/别名指向了一个不存在的路径。把目录建好、权限调整成www-data可写之后解决。这类问题属于“环境问题”,部署前把目录结构和权限提前规划好能省不少事。

第二个是时区导致统计日报出错。系统里USE_TZ=True,订单时间以 UTC 存储,模板层虽然会转本地时区,但我在写营业统计 SQL 时直接拿created_at的日期去分组,结果每天 0 点到 8 点的订单都被算到了前一天。后来统一使用Django的template filter或者datetime模块做时区转换,不在业务逻辑里裸用datetime.now()。

第三个是并发下库存偶尔超卖。试运行某天午市,两个顾客几乎同时点了最后一份招牌菜,系统都显示下单成功,后厨一看只剩一份菜,当场傻眼。这就是典型的并发问题。当时读库存和扣库存中间有间隙,我在代码里加了select_for_update()之后问题消失。前端即使展示库存,也只能作为参考,真正的库存防线必须在数据库事务里。

第四个是打印机排版问题。店里用浏览器直接打印订单小票,默认样式会把导航栏、按钮都打进去。后来给打印模板单独写了一套 CSS,用@media print隐藏无关元素,把小票宽度设为 80mm,再设置margin: 0,小票打印出来基本干净可用。

这几个问题有一个共同特点:都不是逻辑算法层面的难问题,而是真实场景下才会暴露出来的“缝”。只在本地runserver的话,可能永远撞不上。

6.4 如果让我重新做一遍,改动清单

回到最初那个点餐系统,如果现在让我重新做,有三处我一定会改。

我会先把商家端做完验完,再做顾客端。当初我先做顾客的菜单和下单,做到一半发现商家端才是真正每天被高频使用的部分,顾客端偶尔用一次,商家端却要从开门用到打烊。先把商家端的体验打磨好,系统才真的“能用”。

我会在订单模型旁边从一开始就加一张OrderLog表,记录每次状态变更的时间、操作人和旧状态。当时状态流转虽然用白名单控制住了,但出了问题没有审计日志,想查“这单是谁在什么时候改的状态”非常费劲。状态日志表就是几个字段的事,后期排查争议时价值巨大。

我会把支付状态和订单状态分开设计。当时因为没接真实支付,我没太在意这两个概念的区分,后来发现哪怕是线下收款,也需要知道“这单到底收没收到钱”。把is_paid、paid_time、payment_method这些字段一开始就放进订单表,后续接在线支付时就不用再改表结构。

做这套系统的过程中,我最大的体会是:点餐系统的难点从来不是某个技术点有多深,而是各种细节能不能串联成一个店里的员工愿意每天用的工具。判断一个系统做得好不好,不是看代码写得有多漂亮,而是看中午高峰期老板愿不愿意打开你做的页面去接单。Django 给了我们把想法快速落地的能力,但真正把系统从“能跑”变成“能用”,靠的是对真实场景的一次次打磨。

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

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

立即咨询