简介:这套本科毕业设计以微信小程序为前端交互载体,后端基于Python-Django构建,实现了一整套在线点餐系统,适合作为计算机相关专业毕设参考或中小餐饮商家数字化改造的起步方案。系统整体采用前后端分离架构,用户端覆盖菜品浏览、购物车、在线支付与订单追踪,商家端包含菜品管理、订单处理和经营数据统计,同时集成微信支付与Redis缓存来提升交互响应与数据一致性。压缩包共2000个文件,约18.39MB,其中包含1474个Python后端源文件、201个HTML页面、127个JavaScript脚本、30个CSS样式表,以及微信小程序所需的wxss、wxml和json配置文件,目录结构清晰,便于按模块研读与二次开发。目前已有98人学习下载,适合具备一定Python和前端基础、希望快速理解前后端协同开发流程或直接复用项目框架的读者。
1. 毕设在线点餐系统的真实形态:微信小程序 + python + django 这套组合解决了什么
一份名为在线点餐.zip的毕设工程,核心是微信小程序前端加 python + django 后端。小程序负责展示菜单、加购物车、提交订单,django 提供菜品查询、下单、订单查询等 JSON 接口,两者通过 HTTP 协议沟通。它解决的正是毕设评审和现场演示最看重的两件事:系统能独立跑通完整下单流程,且前后端职责清晰,代码可以按模块一层层讲出来。
适合的人群很明确:需要快速搭出一套可演示、可答辩的点餐系统,又不想从小程序布局、接口协议一路啃文档的从业者。这篇文章按“拆技术栈 → 搭工程 → 写业务 → 排雷 → 验证”的顺序,把我在类似项目上反复验证过的做法讲清楚。涉及小程序宿主限制的部分会多说几句,因为那些坑几乎不写在官方示例里,却最容易让真机演示出岔子。
2. 先拆技术栈再动手:小程序的宿主限制与 Django 的接口边界
2.1 微信小程序端为什么适合做“薄前端”:页面结构、导航栏高度与登录态
微信小程序运行在微信提供的宿主环境里,逻辑层和渲染层分离,网络请求只能走wx.request。它不是不能写复杂业务逻辑,而是把复杂逻辑塞进前端会让调试成本急剧上升,真机上偶发的白屏和请求失败也难定位。常见做法是把小程序端做成“薄前端”:页面只负责展示、收集用户操作,把数据校验、金额计算、状态流转全部交给后端。点餐这类业务尤其适合,因为菜品价格、订单金额、库存这些数据如果前端说了算,后端不校验,改一下请求参数就能让订单金额变成任意值,这在答辩演示时是非常被动的翻车现场。
动手写页面之前要先把两个宿主限制搞清楚。第一个是顶部导航栏高度,小程序默认导航栏在不同机型上高度不一致,如果你做自定义导航栏,需要同时读取状态栏高度和胶囊按钮的位置来动态计算:
const res = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() const menuBtn = wx.getMenuButtonBoundingClientRect() const navBarHeight = (menuBtn.top - res.statusBarHeight) * 2 + menuBtn.height这段代码在较新基础库上优先用wx.getWindowInfo拿状态栏高度,拿不到再回退wx.getSystemInfoSync。胶囊按钮的位置由微信自己渲染,getMenuButtonBoundingClientRect能取到它的 top 和 height,导航栏高度通常按“状态栏到胶囊顶部的距离乘 2 再加胶囊高度”估算。页面里所有需要铅直定位的元素,比如搜索框、购物车悬浮条,都要留出这个偏移量,否则真机预览时组件会被状态栏挡住。
第二个限制是登录态。微信小程序登录获取手机号的线上流程是用户点button open-type="getPhoneNumber"触发授权,前端拿到 code 后交给后端换手机号,但这一步要求小程序主体通过企业认证,个人主体下无法走通。毕设环境经常卡在这一步,我的做法是:用wx.login拿到 code 传给后端做一次模拟登录,后端根据 code 生成一个用户记录并返回自造的 token,前端把 token 存进 Storage。这样既保住了“登录链路”这个答辩要点,又不在认证资格上耗时间。
2.2 Django 端把数据模型变成 JSON 接口:模型设计、DRF 序列化与查询优化
后端选 Django 而不是 Flask,原因很实在:点餐系统需要用户表、菜品表、订单表、订单项表,还要有后台管理画面,Django 自带 ORM 和 Admin,毕设演示管理端几乎零成本。接口层常见做法是装 Django REST Framework,把 QuerySet 序列化成 JSON,DRF 自带的认证、权限、分页能省掉大量手写代码。
一个容易被答辩追问的点是查询优化。比如订单列表页,每条订单要带出所属用户和菜品明细,如果直接在序列化器里嵌套查询,每个订单都会额外打若干次数据库,数据量一大页面明显变慢。这里用select_related一次性把外键关联的表在 SQL 层 JOIN 出来:
# 订单查询:一次性带上用户信息,避免每单多查一次用户表 orders = Order.objects.select_related('user').filter(status=1)select_related适用于一对一和外键关系,JOIN 发生在数据库内部;多对多关系需要用prefetch_related。点餐系统里订单和订单项是典型的一对多,订单项对菜品是外键,所以查询订单项时对菜品做select_related是同样的套路。答辩时能说出“这里为什么用 select_related 而不是 prefetch_related”,比背十个框架名管用。
2.3 小程序与 Django 的会话约定:Token 携带方式与接口返回格式
前后端联调最容易出问题的地方不是接口写错,而是双方对“请求怎么带身份、响应怎么解析”没有统一约定。我一般固定两套规则。第一,登录成功后后端返回一个 token 字符串,小程序端存进wx.setStorageSync,后续所有请求在 header 里带Authorization: Token <token>,后端用 DRF 的TokenAuthentication校验身份。第二,响应体统一包一层结构:
{ "code": 0, "message": "ok", "data": {} }code为 0 表示成功,非 0 表示业务错误,错误码不依赖 HTTP 状态码。这样做的好处是:小程序端在wx.request的 success 回调里先判断res.statusCode,再判断res.data.code,网络层错误和业务层错误分开处理,前端代码会干净很多。习惯上我把这个判断封装成一个公共 request 方法放在 app.js 里,所有页面共用,而不是每个页面复制一份wx.request。联调初期最容易踩的坑是字段名对不上,接口文档里写dish_name,小程序里绑定name,数据永远渲染不出来——统一约定后这类问题能少一大半。
3. 从 Django 工程创建到第一笔订单落库:可照抄的最小实现路径
3.1 创建工程与配置:虚拟环境、依赖安装和应用注册
新建一个干净的目录开始。建议先用python -m venv建虚拟环境,避免把系统 Python 环境搞乱。Python 3.10 以上配 Django 4.x 或 5.x 都能用,依赖版本以 DRF 3.15 左右的常见版本为例,版本号不需要完全一致:
python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install django djangorestframework django-cors-headers django-admin startproject takeout cd takeout python manage.py startapp order依赖里加django-cors-headers的原因稍后说明。startapp生成的应用叫order,菜品、订单、订单项都放这个应用里。接下来把三个模块注册进settings.py:
# settings.py INSTALLED_APPS = [ # ... 默认应用 'rest_framework', 'corsheaders', 'order', ] MIDDLEWARE = [ # ... 默认中间件 'corsheaders.middleware.CorsMiddleware', ] CORS_ALLOW_ALL_ORIGINS = True # 调试期先放开,上线前收紧小程序wx.request本身不受同源策略限制,但配套的网页管理端和 DRF 的可浏览 API 在浏览器里会被 CORS 拦住,所以中间件照配。调试期CORS_ALLOW_ALL_ORIGINS全放开是最省事的做法,答辩前记得改成白名单模式,这也是答辩时能主动讲出的一个安全细节。
注册完成后先迁移出默认数据表,再创建超级用户:
python manage.py makemigrations order python manage.py migrate python manage.py createsuperuser到这一步 Django 侧就能跑起来,用python manage.py runserver 0.0.0.0:8000监听局域网,小程序端在开发者工具里用电脑的局域网 IP 访问后端,真机预览才能打通。只监听 127.0.0.1 的话,手机永远连不上。
3.2 定义菜品、订单、订单项模型与序列化器
模型是点餐系统的地基。菜品要有名称、图片、价格、状态;订单要有用户、总价、状态、创建时间;订单项要有订单、菜品、数量、单价快照。价格用DecimalField而不是FloatField,这是做交易类系统的常识,浮点精度在金额计算上会直接翻车:
# order/models.py from django.db import models from django.contrib.auth.models import User class Dish(models.Model): name = models.CharField(max_length=100) image = models.URLField(blank=True) price = models.DecimalField(max_digits=8, decimal_places=2) is_available = models.BooleanField(default=True) class Order(models.Model): STATUS_CHOICES = ( (0, '待付款'), (1, '已付款'), (2, '制作中'), (3, '已完成'), (4, '已取消'), ) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders') total_price = models.DecimalField(max_digits=10, decimal_places=2) status = models.IntegerField(choices=STATUS_CHOICES, default=0) 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) quantity = models.IntegerField(default=1) price = models.DecimalField(max_digits=8, decimal_places=2)这里有一个故意设计的点:OrderItem.dish外键用了PROTECT,菜品被任何订单引用过以后禁止物理删除,防止历史订单变成“查不到点了什么”。Order.user用CASCADE,用户删了订单跟着删,符合用户自主管理数据的预期;菜品是交易记录的一部分,不能因为下架就抹掉历史。答辩被问“为什么这里用 CASCADE 那里用 PROTECT”,这就是标准答案。
序列化器用 DRF 写成嵌套结构,让前端一次请求同时拿到订单和明细:
# order/serializers.py from rest_framework import serializers from .models import Dish, Order, OrderItem class DishSerializer(serializers.ModelSerializer): class Meta: model = Dish fields = ['id', 'name', 'image', 'price', 'is_available'] class OrderItemSerializer(serializers.ModelSerializer): dish_name = serializers.CharField(source='dish.name', read_only=True) class Meta: model = OrderItem fields = ['id', 'dish', 'dish_name', 'quantity', 'price'] class OrderSerializer(serializers.ModelSerializer): items = OrderItemSerializer(many=True, read_only=True) class Meta: model = Order fields = ['id', 'total_price', 'status', 'created_at', 'items']dish_name用source参数直接从关联对象取,前端拿订单详情时不用再发一次请求查菜品名。嵌套序列化器在订单列表这种一对多场景里很常用,但要注意:列表接口里也嵌套,会产生 N+1 查询,所以订单列表视图要配合prefetch_related('items__dish')使用。
接下来是下单视图。下单接口要在一次请求里完成三件事:创建订单、创建订单项、计算总价,用事务包住:
# order/views.py from django.db import transaction from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from rest_framework.exceptions import ValidationError from .models import Dish, Order, OrderItem from .serializers import OrderSerializer class CreateOrderView(APIView): permission_classes = [IsAuthenticated] def post(self, request): items = request.data.get('items', []) if not items: return Response({'code': 1, 'message': '购物车为空'}, status=400) total = 0 with transaction.atomic(): order = Order.objects.create(user=request.user, total_price=0) for it in items: dish = Dish.objects.select_for_update().get(id=it['dish_id']) if not dish.is_available: raise ValidationError({'code': 1, 'message': '菜品已下架'}) OrderItem.objects.create( order=order, dish=dish, quantity=it['quantity'], price=dish.price ) total += dish.price * it['quantity'] order.total_price = total order.save() return Response({'code': 0, 'message': 'ok', 'data': OrderSerializer(order).data})参数说明:前端只传dish_id和quantity,价格一律从数据库读取,不信任请求体里的任何金额字段。select_for_update对菜品行加锁,配合transaction.atomic()防止两个用户同时下单导致超卖;ValidationError在 atomic 块内抛出会触发整个事务回滚,DRF 自动把它转成 400 响应。数量和 dish_id 的格式校验在这里没写全,实际项目可以用 DRF 的 Serializer 做入参校验,毕设阶段先在视图里用 try 包一下int()转换也能演示。
注意:
select_for_update依赖数据库事务和行锁,SQLite 下会静默失效。毕设如果用的是默认 SQLite,这一行代码不会报错,但并发场景下谈不上真锁,答辩前把库切到 MySQL 再验证一次。
最后注册路由:
# takeout/urls.py from django.urls import path, include from rest_framework.authtoken.views import obtain_auth_token from order.views import CreateOrderView urlpatterns = [ path('admin/', admin.site.urls), path('api/login/', obtain_auth_token), path('api/order/create/', CreateOrderView.as_view()), ]obtain_auth_token是 DRF 自带的登录换 token 接口,用户名密码换 token,省了手写认证逻辑。到这里后端最短路径已经通了,先用浏览器访问http://127.0.0.1:8000/api/order/create/就能看到 DRF 的可浏览 API 页面,确认登录后才能提交数据。
3.3 小程序端对接:从页面注册到 wx.request 的请求时序
小程序端先注册页面,点餐首页、菜品详情、购物车、订单列表,四个页面足够覆盖演示流程。以提交订单为例,重点看请求时序:用户点“去结算”后,先校验本地购物车非空,再调用 create 接口,拿到订单 id 后跳转订单详情页或支付页,不要一进页面就直接弹 Toast:
// pages/cart/index.js const app = getApp() Page({ onSubmitOrder() { if (this.data.cartList.length === 0) { wx.showToast({ title: '购物车是空的', icon: 'none' }) return } const items = this.data.cartList.map(item => ({ dish_id: item.dishId, quantity: item.quantity })) wx.request({ url: `${app.globalData.baseUrl}/api/order/create/`, method: 'POST', header: { 'Content-Type': 'application/json', 'Authorization': `Token ${wx.getStorageSync('token')}` }, data: { items }, success(res) { if (res.statusCode === 200 && res.data.code === 0) { wx.redirectTo({ url: `/pages/order/detail?id=${res.data.data.id}` }) } else { wx.showToast({ title: res.data.message || '下单失败', icon: 'none' }) } }, fail() { wx.showToast({ title: '网络请求失败', icon: 'none' }) } }) } })逻辑说明:header 里Authorization带的是登录后存进 Storage 的 token,后端TokenAuthentication靠它识别用户;success 回调里先看statusCode再判断业务code,两套错误分开处理;redirectTo而不是navigateTo,避免订单详情页被重复压栈,用户返回来时购物车已经是清空状态。
baseUrl我放在globalData里统一维护,开发时指向http://127.0.0.1:8000或电脑局域网 IP。真机调试时,电脑和手机要在同一 Wi-Fi,且baseUrl改成电脑的局域网 IP,例如http://192.168.1.5:8000。如果后端监听0.0.0.0但手机连不上,多半是系统防火墙拦了 8000 端口,在安全设置里放行即可。
4. 把菜单、购物车、订单状态做成能答辩的商用形态:三个模块的细节决策
4.1 菜单列表与菜品规格选择:radio-group 组件背后的数据结构设计
小程序原生组件里,菜单规格选择最简单可靠的方式是radio-group。一个菜品如果有“份量/辣度”规格,前端把这些规格渲染成单选,后端把规格选项存在Dish模型的JSONField里,前端只负责展示,不维护规格规则:
class Dish(models.Model): # ... 其他字段 specs = models.JSONField(default=list) # [{'name':'份量','options':['小份','大份']}]为什么用JSONField而不是单独建一张规格表:毕设的规格是展示型数据,不参与库存和价格计算,建表只会增加答辩时解释数据关系的负担。等规格要影响价格或库存时,再拆成DishSpec表不迟,这是典型的“按需建模”。
前端渲染时,radio-group的 change 事件里用dataset传递菜品 id,选中值存进页面 data:
<radio-group bindchange="onSpecChange">{ "dish_id": 3, "spec": "大份", "quantity": 2, "unit_price": "28.00" }unit_price是后端返回菜品列表时带的展示价,前端只在页面上展示用;提交订单时只传dish_id、spec、quantity三个字段,后端在事务里重新查菜品价格再累加总价。DecimalField的精度配置上,max_digits是总位数,decimal_places是小数位,菜品价格用max_digits=8, decimal_places=2足够覆盖绝大多数场景,total_price用max_digits=10防止多菜叠加溢出。
校验规则归纳起来就三条:数量必须是大于 0 的整数,菜品必须存在且上架,单价以数据库为准。任何一条不满足,后端返回code=1并把 message 弹给用户。配套还有一个防重复提交的问题:用户手快连点两下“提交订单”,会生成两个一模一样的订单。前端在请求 pending 期间把按钮置成 loading 并禁用,后端再加一道查重——同用户、同菜品组合、状态为待付款的订单存在时直接拒绝,两边一起堵才是完整方案。
4.3 订单状态流:状态机设计和超时未付款的兜底处理
订单状态用整型字段加choices,前面 models.py 里已经定义了五态:待付款、已付款、制作中、已完成、已取消。状态流转的规则在 Django 侧集中写死,接口不接受前端随意传status字段改状态,这是整个订单模块里我最坚持的一个设计:
ALLOWED_TRANSITIONS = { 0: [1, 4], # 待付款 -> 已付款 / 已取消 1: [2], # 已付款 -> 制作中 2: [3], # 制作中 -> 已完成 3: [], 4: [], } def change_status(order, to_status): if to_status not in ALLOWED_TRANSITIONS[order.status]: raise ValueError('非法状态流转') order.status = to_status order.save()接口层只暴露“付款”“接单”“完成”“取消”四个动作,每个动作内部调用change_status。前端订单列表下拉刷新时按状态分 tab 展示,已取消的订单置灰并标注原因。这样整条状态流在答辩 PPT 上一张图就能讲清楚:箭头只会从待付款指向已付款或已取消,不存在“已完成又回到制作中”这种乱象。
超时未付款的兜底,毕设阶段不建议上 Celery 之类的异步任务队列。常见做法是在订单列表接口里做延迟判断:查询时若订单处于待付款且创建时间超过 15 分钟,先把状态改成已取消再返回数据。演示时 15 分钟等不起,可以把阈值定义成模块级常量ORDER_TIMEOUT_SECONDS = 900,答辩前临时改成 30 秒,现场演示一遍自动取消,这是成本最低又能展示完整业务闭环的做法。
5. 小程序联调避坑手册:从白屏到重复下单的 5 类高频故障
5.1 真机上图片加载不出来
现象:开发者工具里菜品图加载正常,一换真机预览全是空白,控制台报url not in domain list。
原因:小程序真机环境的网络请求有严格域名白名单,后台配置的downloadFile合法域名里没有你的图片域名;开发者工具默认不校验,掩盖了这个问题。
解决:如果图片是网络图,要在小程序管理后台把图片域名加进合法域名;如果后端在本地,答辩直接用本地图片文件放进 static 目录,绕开域名校验。调试阶段可以勾选开发者工具里的“不校验合法域名”,但真机上这个选项不生效,别指望靠它撑过答辩。判断域名问题的标准方法:真机调试时打开 vConsole 看报错信息,只要出现 domain 字样,基本都是白名单问题。
5.2 请求返回 200 但页面数据渲染为空
现象:wx.request的 success 回调里statusCode是 200,Toast 也不报错,但页面列表空着,下拉刷新也没反应。
原因:一半是后端返回的数据结构和小程序解析的路径对不上,比如数据在res.data.data.menu,页面写成了res.data.menu;另一半是序列化器字段名与前端绑定名不一致,后端返回dish_name,前端绑定name。
解决:联调初期在后端保持 DRF 可浏览 API 模式,前端在onLoad里先用console.log(res.data)把原始返回打出来,对比一遍再写绑定逻辑。字段名应该在接口文档里定死,而不是前端现猜。打开开发者工具的 Network 面板能同时看到请求参数、响应体和耗时,遇到 200 但渲染空的情况,第一件事看响应体,数据结构对不对一眼就能确认。
5.3 自定义导航栏高度在不同机型错位
现象:iPhone 上标题位置正常,换到安卓机上标题整体偏上,部分机型状态栏更高,甚至按钮和胶囊重叠。
原因:自定义导航栏用了固定 px 高度,没有动态适配状态栏;或者读取statusBarHeight用了旧 API,在部分安卓机上返回的值不稳定。
解决:回到第 2.1 节的动态高度算法,wx.getWindowInfo优先,拿不到再回退wx.getSystemInfoSync,并用胶囊按钮位置校准。设置完以后不要只在开发者工具里看,真机预览里把 iPhone、华为、小米各点一遍,这个坑没有一劳永逸的公式,只有换机型验证。导航栏组件我习惯单独封装成一个custom-nav组件,所有页面统一引用,避免每个页面各算一遍高度导致肉眼可见的偏差。
5.4 下单按钮连点生成重复订单
现象:演示时连点三下“提交订单”,后台多了三笔一模一样的单,金额和菜品都一样。
原因:前端没有防抖,后端也没有幂等校验,每个请求都真的创建了一单。
解决:前端在请求 pending 期间把按钮置成 loading,disabled之后用户连点不会有反应;后端对同用户、同菜品组合且状态为待付款的订单做查重,命中就拒绝。更稳妥的方案是在购物车页面生成一个cart_token,下单时随请求提交,订单表对它建唯一索引,重复请求直接命中唯一约束报错,这是最不容易被绕过的一层。答辩演示时故意连点两下按钮,然后打开后台展示只有一单,这个细节很加分。
5.5 删除菜品时被外键保护拦下
现象:后台管理里删除一个销量不错的菜品,Django 直接抛ProtectedError,页面 500,前端弹“服务器错误”。
原因:前面 models.py 里OrderItem.dish用了on_delete=models.PROTECT,被任何订单引用过的菜品不允许物理删除,这是保护交易数据的策略,不是配置错误。
解决:删除操作改成两段式——先把is_available设成 False,小程序菜单接口默认只查上架菜品,实现“软下架”;要清理数据时,先删订单项再删菜品。这样历史订单里的dish_name快照还在,只是菜品从菜单中消失了。答辩时主动讲这个保护逻辑和两段式删除,比被问到后支支吾吾要主动得多。同样的思路适用于任何有交易历史的业务表,原则是“交易记录永远不物理删除”。
6. 用自动化测试证明接口可用:一条断言链覆盖下单主流程
6.1 用 Django TestCase 验证下单接口
不少人答辩前才手动点一遍页面,遇到订单接口改过代码就心里没底。常见做法是用 Django TestCase 把主流程写成断言,下单成功、金额计算、非法操作各一条:
from django.test import TestCase from django.contrib.auth.models import User from rest_framework.test import APIClient from .models import Dish, Order class OrderApiTests(TestCase): def setUp(self): self.client = APIClient() self.user = User.objects.create_user('demo', password='123456') self.client.force_authenticate(self.user) self.dish = Dish.objects.create(name='牛肉饭', price='28.00') def test_create_order_ok(self): resp = self.client.post('/api/order/create/', { 'items': [{'dish_id': self.dish.id, 'quantity': 2}] }, format='json') self.assertEqual(resp.status_code, 200) self.assertEqual(resp.data['data']['total_price'], '56.00') self.assertEqual(Order.objects.count(), 1)force_authenticate绕过真实登录直接给测试请求带上用户身份,适合测业务逻辑;total_price断言的是字符串'56.00',因为DecimalField序列化出来就是字符串,不要拿浮点数去比较。想验证非法状态流转或菜品下架后的下单拦截,按同样的模式补 case 即可。跑python manage.py test order后,下单主流程在你的电脑上是可证明的,答辩时这句话比“我点过了没有问题”有说服力得多。
6.2 答辩前值得做的两个改造方向
最有性价比的升级是接入支付。个人主体做不了微信支付回调,但可以在代码里预留PayService接口,演示时用“模拟支付”按钮把订单从待付款推进到已付款,切换路径正好呼应第 4 章的状态机。第二个方向是实时取餐通知,毕设阶段不想引入第三方推送的话,用前端 5 秒轮询订单状态接口也能演示出“新订单红点提醒”的效果。
改造前先衡量依赖成本:支付需要商户主体资质,小程序 WebSocket 要求 wss 域名,这两项都会把你拖进服务器配置和审核流程。我的习惯是答辩演示把“能稳定跑通”放在“功能多”前面,菜单、下单、支付模拟、订单状态、后台管理这条主链路每次演示都走一遍,比堆十个半成品功能有用。这套项目走到今天,我最受益的不是哪个库用得巧妙,而是每改一个字段都跑一遍那条测试用例,回归成本几乎为零,主流程永远敢说没问题。希望帮到你。
本文还有配套的精品资源,点击获取