基于Django的Web点餐系统:从数据模型到部署的完整实践

📅 发布时间:2026/10/22 2:41:53
基于Django的Web点餐系统:从数据模型到部署的完整实践
如果一家小餐馆还在靠服务员手写点菜单高峰期收银台排队、厨房喊菜、漏单错单几乎是家常便饭。我身边一位开快餐店的朋友就为这个头疼了很久最后我们决定自己动手做一个基于 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 里。项目里拆了两个 appmall负责顾客端菜单和下单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_length32, uniqueTrue) sort models.IntegerField(default0) is_active models.BooleanField(defaultTrue) class Meta: ordering [sort, id] def __str__(self): return self.name class Dish(models.Model): category models.ForeignKey( Category, on_deletemodels.CASCADE, related_namedishes ) name models.CharField(max_length64) price models.DecimalField(max_digits7, decimal_places2) image models.ImageField(upload_todishes/, blankTrue) description models.TextField(blankTrue) stock models.IntegerField(default0) sales models.IntegerField(default0) is_active models.BooleanField(defaultTrue) created_at models.DateTimeField(auto_now_addTrue) def __str__(self): return self.name class Order(models.Model): ORDER_STATUS_CHOICES ( (pending, 待接单), (accepted, 已接单), (cooking, 制作中), (delivering, 待上桌), (completed, 已完成), (canceled, 已取消), ) order_no models.CharField(max_length32, uniqueTrue) user models.ForeignKey( User, on_deletemodels.PROTECT, related_nameorders ) status models.CharField( max_length16, choicesORDER_STATUS_CHOICES, defaultpending ) total_amount models.DecimalField(max_digits8, decimal_places2) remark models.CharField(max_length255, blankTrue) created_at models.DateTimeField(auto_now_addTrue) class OrderItem(models.Model): order models.ForeignKey( Order, on_deletemodels.CASCADE, related_nameitems ) dish models.ForeignKey(Dish, on_deletemodels.PROTECT) dish_name models.CharField(max_length64) price models.DecimalField(max_digits7, decimal_places2) quantity models.PositiveIntegerField(default1)这里有几个设计决策值得展开说一下。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_paidTrue同时is_canceledTrue这种脏数据一旦出现后期统计和展示都会出问题。合法状态流转我维护成一张表当前状态可以流转到待接单 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, quantity1): 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__indish_ids, is_activeTrue ) 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( useruser, order_nogenerate_order_no(), total_amounttotal, remarkremark, ) order_items [] for did, qty in cart_items.items(): dish dish_map[did] order_items.append(OrderItem( orderorder, dishdish, dish_namedish.name, pricedish.price, quantityqty, )) Dish.objects.filter(iddish.id).update( stockF(stock) - qty, salesF(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_activeTrue的菜品给出“已售罄但未下架”的提示。这让后厨在高峰期能够提前准备避免冷冰冰的“已售罄”突然出现在顾客面前。6. 部署上线与实测复盘本机能跑只是第一步6.1 部署组合与配置要点开发环境里用的是 Django 自带的runserver和 SQLite一旦要正式营业这套组合肯定撑不住真实的访问和并发。我最后采用的部署组合是 Nginx uWSGI PostgreSQLDjango 应用由 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_TZTrue订单时间以 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 给了我们把想法快速落地的能力但真正把系统从“能跑”变成“能用”靠的是对真实场景的一次次打磨。