2026/10/11 13:14:45

Django+Vue外卖点餐系统毕设指南:从建表到联调全流程

Django+Vue外卖点餐系统毕设指南:从建表到联调全流程 简介一套基于Python Django与Vue.js开发的外卖点餐系统毕业设计项目采用B/S架构适合计算机相关专业学生作为毕业设计或课程设计参考。前端覆盖首页、菜品详情、订单中心、用户中心等核心用户场景后台提供总览、订单管理、菜品管理、分类管理、标签管理、评论管理、用户管理、运营管理、日志管理、系统信息等完整运营模块帮助理解从点餐下单到后台处理的业务闭环。压缩包内共422个文件以py后端代码、vue前端页面、js交互逻辑、jpg/png图片素材为主整体23.81MB目录划分为server与web两大区块并附带依赖清单与运行说明便于对照学习、二次开发或快速演示。目前已有609人学习下载结构清晰、模块划分明确是熟悉DjangoVue全栈开发流程的实用案例。1. 毕业设计做外卖点餐系统这套 Django Vue 方案解决什么问题每年到毕业设计开题季总会有人来问外卖点餐系统怎么做。这个题目好在业务链路完整用户点餐、商家出餐、订单流转、购物车结算每一环都能对应到课程里学的知识又不会像电商平台那样复杂到失控。基于 Python Django Vue 开发的外卖点餐网站本质上是把「前端展示 后端接口 数据库设计」三件事串成一个能跑通、能演示、能写进论文的闭环项目。我见过太多同学卡在同一个地方前后端各自都能跑一联调就四处冒错跨域、登录态、图片路径每一个都能耗掉一下午。这篇笔记就按我做这类项目的习惯把从建表到联调再到答辩演示的路径完整走一遍顺带把那些最容易让人翻车的细节提前说破。适合正在选题、中期卡壳、或者准备把课设升级成毕设的人跟着步骤走能复现抄完代码也知道每行在干什么。2. 技术选型与数据模型前后端怎么分工业务表如何落地2.1 为什么是 Django Vue而不是 Django 模板直接渲染很多课程里教的是 Django 自带模板系统views 里 render 一个 html 就完事。但外卖点餐系统的交互密度远超普通展示页购物车加减、下单确认、商家接单、进度轮询这些如果用服务端模板硬写代码会迅速变得难以维护。另一个现实原因是现在主流岗位和后端协作方式基本都默认前后端分离毕业设计选 Vue Django 的组合在答辩时也能说清楚「职责分离」这个设计点。Django 负责的是数据模型、业务逻辑、权限校验和接口输出Vue 负责的是页面交互、状态管理和接口调用。两者通过 REST 风格的 JSON 接口通信。我一般会把项目拆成两个目录backend 和 frontend后端项目里用 DRFDjango REST Framework来写接口前端用 Vue 全家桶请求库选 axios。这套组合的成熟度很高网上的资料和踩坑记录都多遇到问题不容易卡死。2.2 三种角色与前置设计决策开工之前先定角色边界。常见的外卖点餐系统至少有三类用户普通用户点餐、商家出餐、管理员运营管理。角色影响的是数据可见性和操作权限直接决定接口怎么写。我建议不要一开始就做完整的 RBAC 权限系统毕业设计的体量用「用户表字段 装饰器」来控制就够。另一个容易被忽略的决策是购物车存哪。很多人习惯把购物车放在前端 localStorage刷新不丢简单省事。但外卖系统的购物车有一个特性结算时价格必须以后端数据库为准前端价格只是展示。如果购物车只存在于前端下单接口就得把整个购物车数据重新传一次服务端还要逐条验价接口体积大且容易出错。我一般会把购物车也放在服务端用一张购物车表来承载前端只存 cartId。这样下单逻辑会清爽很多答辩追问「购物车数据一致性」时也有话可说。2.3 五张核心业务表Django 模型设计外卖点餐系统的核心表通常是这几张用户表复用 Django 自带 User 再扩展、商家表、菜品表、购物车表、订单表和订单明细表。菜品和订单之间是多对多关系但订单明细必须单独成表因为下单那一刻的菜品名称和价格要快照下来不能等菜品表改了价格之后历史订单也跟着变。# backend/order/models.py —— 核心模型设计示意 from django.db import models from django.contrib.auth.models import User class Shop(models.Model): name models.CharField(店铺名称, max_length64) owner models.OneToOneField(User, on_deletemodels.CASCADE, related_nameshop) address models.CharField(店铺地址, max_length128) phone models.CharField(联系电话, max_length20) class Dish(models.Model): shop models.ForeignKey(Shop, on_deletemodels.CASCADE, related_namedishes) name models.CharField(菜品名称, max_length64) price models.DecimalField(价格, max_digits6, decimal_places2) image models.ImageField(图片, upload_todishes/) is_available models.BooleanField(是否在售, defaultTrue) class Cart(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, related_namecart) shop models.ForeignKey(Shop, on_deletemodels.CASCADE) created_at models.DateTimeField(auto_now_addTrue) class CartItem(models.Model): cart models.ForeignKey(Cart, on_deletemodels.CASCADE, related_nameitems) dish models.ForeignKey(Dish, on_deletemodels.CASCADE) quantity models.PositiveIntegerField(数量, default1) class Order(models.Model): STATUS_CHOICES [ (pending, 待接单), (accepted, 已接单), (delivering, 配送中), (finished, 已完成), (canceled, 已取消), ] user models.ForeignKey(User, on_deletemodels.PROTECT, related_nameorders) shop models.ForeignKey(Shop, on_deletemodels.PROTECT) total_price models.DecimalField(总价, max_digits8, decimal_places2) status models.CharField(状态, max_length16, choicesSTATUS_CHOICES, defaultpending) address models.CharField(配送地址, max_length128) created_at models.DateTimeField(auto_now_addTrue) class OrderItem(models.Model): order models.ForeignKey(Order, on_deletemodels.CASCADE, related_nameitems) dish_name models.CharField(菜品名称, max_length64) dish_price models.DecimalField(单价, max_digits6, decimal_places2) quantity models.PositiveIntegerField(数量, default1)这段模型有几个设计点是答辩常问的。第一订单表里的 user 和 shop 用 PROTECT 级联而不是 CASCADE意思是只要有历史订单用户和商家就不能被直接删除这符合业务真实逻辑。第二OrderItem 里存的是 dish_name 和 dish_price 快照而不是外键指向 Dish这是外卖系统里必须形成习惯的做法。第三购物车按用户 店铺区分因为同一用户可能会在不同店铺下单购物车如果全局只有一条换店点餐时就会出现「把 A 店的菜加到 B 店订单里」的 bug。2.4 用户扩展与 JWT 认证选型Django 自带的 User 表字段有限直接在上面加字段不推荐。常见做法是新建一个 Profile 模型用 OneToOne 关联 User 存手机号、头像、默认地址商家信息则在 Shop 模型里通过 owner 字段关联 User。这样既能复用 Django 的认证体系又不动原表结构。接口认证我建议用 JWT而不是 Django 默认的 Session。原因是 Vue 前端调用接口时Session 机制依赖 Cookie需要处理 CSRF前后端分离时这个组合很容易出现「登录了但接口报 403」的诡异问题。JWT 的逻辑是登录后后端返回一个 token前端存起来每次请求放到 Authorization 头里后端验签后就能拿到用户身份无状态、好调试、也符合作业中「RESTful API」的表述。推荐用 djangorestframework-simplejwt配置量小默认实现已经够用。# backend 目录下安装核心依赖版本以你本地环境为准 pip install django djangorestframework djangorestframework-simplejwt django-cors-headers pillowpillow 是 ImageField 处理图片上传必需的库漏装会在使用 .save() 保存图片时直接报错。django-cors-headers 解决的是 Vue 开发服务器访问 Django 接口时的跨域问题这两个都是这个项目里“没有会卡很久装上才发现原来如此”的典型依赖。requirements.txt 就按这个列表锁版本即可。数据库这边MySQL 更接近生产环境但如果你本地没装 MySQL直接用 SQLite 也能跑Django 在模型层做的 ORM 屏蔽了差异切换数据库只需要改 settings 里的配置和数据库驱动。3. Django 后端从零搭建用户、菜品、订单三块核心接口的实现3.1 项目初始化的标准流程后端代码的组织方式我习惯按业务模块拆 app而不是把所有模型塞进一个 app。外卖点餐系统可以拆成 user、shop、order 三个业务 app公共的放在 core 里。这样写的时候看着清爽写完论文里画架构图也方便。初始化命令如下# 创建 Django 项目和两个业务模块 django-admin startproject backend . python manage.py startapp user python manage.py startapp shop python manage.py startapp order注意这里第一行命令最后的点是关键意思是把项目文件生成在当前目录而不是再包一层 backend 文件夹。如果不加这个点后面所有路径都会多嵌套一层运行 manage.py 时很容易出现路径找不到的问题。项目生成后先进入 settings.py 把新 app 加入 INSTALLED_APPS然后配置数据库和认证方式。# backend/settings.py 关键配置 INSTALLED_APPS [ # 默认的 app 省略 rest_framework, rest_framework_simplejwt, corsheaders, user, shop, order, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, # 默认的 middleware 保持不动 ] # Django REST Framework 的全局认证策略 REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: [ rest_framework_simplejwt.authentication.JWTAuthentication, ], DEFAULT_PERMISSION_CLASSES: [ rest_framework.permissions.IsAuthenticated, ], }这里有个坑要提前说明corsheaders 的中间件必须放在 MIDDLEWARE 列表里尽量靠前的位置官方要求放在 CommonMiddleware 之前。很多人跨域配置写了却不起作用检查这一行顺序就能解决大半。REST_FRAMEWORK 里如果把默认权限设成 IsAuthenticated那所有接口默认都需要登录然后对不需要登录的接口逐个用 AllowAny 放开这样写比较安全不容易出现「忘记加权限、任何人都能下单」的问题。也可以反过来全局 AllowAny再给敏感接口逐个加权限两种风格熟手看代码就知道答辩时能说出理由即可。3.2 用 DRF 写菜品列表与店铺列表接口菜品列表是个入门接口正好用来说明 DRF 的 Serializer ViewSet 组合拳。Serializer 负责把模型数据转成 JSON也负责校验前端传进来的参数ViewSet 负责把常见的 list、create、retrieve 这类操作自动映射到 URL 上。# backend/shop/views.py —— 菜品与店铺接口 from rest_framework import viewsets, permissions from rest_framework.decorators import action from rest_framework.response import Response from .models import Shop, Dish from .serializers import ShopSerializer, DishSerializer class ShopViewSet(viewsets.ReadOnlyModelViewSet): queryset Shop.objects.all() serializer_class ShopSerializer permission_classes [permissions.AllowAny] class DishViewSet(viewsets.ReadOnlyModelViewSet): queryset Dish.objects.filter(is_availableTrue) serializer_class DishSerializer permission_classes [permissions.AllowAny] action(detailFalse, methods[get]) def by_shop(self, request): shop_id request.query_params.get(shop_id) dishes self.get_queryset().filter(shop_idshop_id) serializer self.get_serializer(dishes, manyTrue) return Response(serializer.data)# backend/shop/serializers.py from rest_framework import serializers from .models import Shop, Dish class ShopSerializer(serializers.ModelSerializer): class Meta: model Shop fields [id, name, address, phone] class DishSerializer(serializers.ModelSerializer): class Meta: model Dish fields [id, shop, name, price, image, is_available]逻辑说明ReadOnlyModelViewSet 只生成查询接口不生成写接口适合菜品和店铺这种由后端管理数据的场景。by_shop 这个自定义 action 解决了「进入一家店铺后只看这家店的菜」这个需求URL 会自动映射成 dishes/by_shop/?shop_id1。因为 ViewSet 默认的 list 接口会返回所有店铺的菜前端还得在内存里再过滤一次多一步不如写一个带参数的接口。queryset 里直接 filter(is_availableTrue)让下架菜根本不进接口前端也少一个判断条件。这段代码背后有个值得记住的参数query_params.get()。如果前端没传 shop_id这里取到的是 Nonefilter(shop_idNone) 会返回空列表而不是报错。这是 DRF 里一个比较友好的行为但你自己写 Django 原生视图时就不会这么宽容filter 条件传 None 会直接查不到数据排错时先检查参数名对不对。3.3 下单接口事务、价格校验、购物车清空下单是整个系统里最核心的接口要同时做几件事读取购物车、校验菜品是否在售、计算总价、创建订单和订单明细、清空购物车。这四步任何一个失败都不能让订单残留一半数据所以必须用事务包裹。# backend/order/views.py —— 下单接口 from django.db import transaction from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.response import Response from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from .models import Order, OrderItem from shop.models import Cart, CartItem, Dish api_view([POST]) permission_classes([IsAuthenticated]) transaction.atomic def create_order(request): user request.user cart Cart.objects.filter(useruser, is_checked_outFalse).first() if not cart or not cart.items.exists(): return Response({detail: 购物车为空}, statusstatus.HTTP_400_BAD_REQUEST) items cart.items.select_related(dish) total_price 0 order_items [] for item in items: dish item.dish if not dish.is_available: return Response({detail: f{dish.name} 已下架}, statusstatus.HTTP_400_BAD_REQUEST) total_price dish.price * item.quantity order_items.append(OrderItem( order_idNone, dish_namedish.name, dish_pricedish.price, quantityitem.quantity, )) order Order.objects.create( useruser, shopcart.shop, total_pricetotal_price, addressrequest.data.get(address, ), ) for item in order_items: item.order order OrderItem.objects.bulk_create(order_items) cart.is_checked_out True cart.save(update_fields[is_checked_out]) return Response({order_id: order.id, total_price: str(total_price)}, statusstatus.HTTP_201_CREATED)这段代码有两个细节值得展开。第一个是为什么用 is_checked_out 而不是直接 delete 购物车外卖系统里用户可能会反复下单保留购物车记录可以为以后的「再来一单」功能留数据即使不做这个功能保留也比删除更容易排查问题删除是不可逆操作撤不回。第二个是 total_price 用 Decimal 类型计算Django 的 DecimalField 传回来的值可以直接做加减乘除算完后通过 str() 转成字符串返回给前端避免 JSON 序列化时丢失精度。还有个容易搞错的点OrderItem.objects.bulk_create() 里 items 列表的 order 属性要先在循环里赋值。因为 bulk_create 是批量插入不会像普通 .create() 那样自动回填外键必须先把 order 指向当前创建的 Order 实例。如果你在循环里直接 OrderItem.objects.create(orderorder, ...) 也没有问题只是批量插入在数据量大时性能更稳毕设里几十条数据差异不大但写成批量插入的习惯在接口响应时间上会有差别。3.4 商家接单与状态流转接口订单状态是一个状态机不能允许用户把「已完成」改成「配送中」。后端要做的是在状态变更接口里加上合法流转校验。商家角色怎么识别通过 request.user.shop 这个反向关联如果当前登录用户没有关联店铺说明他不是商家。# backend/order/views.py —— 商家更新订单状态 api_view([POST]) permission_classes([IsAuthenticated]) def update_order_status(request, order_id): user request.user if not hasattr(user, shop): return Response({detail: 无权限}, statusstatus.HTTP_403_FORBIDDEN) order get_object_or_404(Order, idorder_id, shopuser.shop) new_status request.data.get(status) valid_transitions { pending: [accepted, canceled], accepted: [delivering, canceled], delivering: [finished], } if new_status not in valid_transitions.get(order.status, []): return Response({detail: 非法状态流转}, statusstatus.HTTP_400_BAD_REQUEST) order.status new_status order.save(update_fields[status]) return Response({status: order.status})get_object_or_404 里同时带了 order_id 和 shopuser.shop这行代码同时完成了两件事第一确认订单存在第二确认这个订单属于当前商家。如果直接先取订单再判断归属就得手动写 if 逻辑现在这样更简洁也少一个出错点。valid_transitions 这个字典就是状态机的核心体现答辩时用「状态流转合法性校验」来形容比「就是一个 if 判断」要有说服力得多。外卖订单有取消这个状态但取消动作通常发生在 pending 阶段商家接单后就只能走到 finished这样设计避免了用户一边取消、商家一边出餐的矛盾局面。如果要放开用户侧取消也是一样的思路定义一个 client_cancel 的合法流转范围。4. Vue 前端对接后端登录态、购物车、下单流程的联调闭环4.1 前端项目结构与路由设计前端服务用 Vue CLI 创建项目比较省事src 目录下按 views、components、api、store、router 五个文件夹组织。views 放页面级组件components 放被复用的局部组件api 文件夹里统一放 axios 请求方法。注意 order 里的路由要有动态段订单详情页用 detail/:id商家端和用户端的页面最好拆成两个父路由分别挂自己的子页面这样权限守卫写起来也清爽。vue create frontend cd frontend npm install axios vuex vue-router4 element-plus装 Element Plus 是为了快速获得表格、表单、弹窗这些组件毕设项目的时间不应该花在写原生表格样式上。如果你不习惯组件库也可以只装 axios 和 vue-router用原生 HTML 写列表也完全可以这个看个人熟悉程度。vue-router4 对应 Vue 3如果你本地是 Vue 2 项目则要装 vue-router3这个版本差异是很多同学照着网上教程装包后路由不生效的原因。前端路由的核心配置是导航守卫它的作用是拦截未登录用户。外卖点餐系统的页面类型可以分成三类所有人可访问的首页和店铺页、需要登录的购物车和订单页、仅商家可访问的管理页。在路由的 meta 字段里标记 requiresAuth 和 requiresShop然后统一在 beforeEach 里判断// frontend/src/router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(access_token) const isShop localStorage.getItem(is_shop) true if (to.meta.requiresAuth !token) { next(/login) return } if (to.meta.requiresShop !isShop) { next(/) return } next() })这段守卫逻辑里 is_shop 是登录时后端返回的用户类型标识。如果后端没返回这个字段前端就得再调一次用户信息接口才能知道当前用户是不是商家。我建议登录接口直接返回 is_shop 字段省掉一次请求也避免路由守卫变成异步逻辑处理起来麻烦。路由守卫里不要做接口请求一旦在守卫里等待接口返回页面跳转会出现可感知的卡顿而且错误处理在守卫里写起来很别扭。4.2 axios 封装token 注入与统一错误处理axios 封装的必要性在于如果每个页面单独写请求token 注入逻辑会重复十几次而且响应状态码处理401 跳登录、500 弹提示分散在每处代码里出一个问题要改很多地方。我会把请求实例单独抽到一个文件里统一做拦截器。// frontend/src/api/request.js import axios from axios import router from ../router import { ElMessage } from element-plus const request axios.create({ baseURL: http://127.0.0.1:8000/api, timeout: 10000, }) request.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(access_token) router.push(/login) } else if (error.response?.status 500) { ElMessage.error(服务器开小差了稍后再试) } return Promise.reject(error) } ) export default requestbaseURL 指向 Django 后端的地址注意这里的 /api 前缀要和后端的 URL 配置对应。前端代理和后端 CORS 是两回事如果在 vite.config.js 里配了 proxy那 baseURL 可以直接写成 /api由开发服务器转发如果不配 proxy就必须写全 URL然后靠 django-cors-headers 放行。两种方式都能用但建议开发时用 proxy这样浏览器的请求都指向同源地址跨域问题会少很多。timeout 设 10 秒是给网速波动留的余量太短会导致图片加载稍慢就整个请求失败太长会影响用户操作反馈。4.3 购物车与下单页的完整流程用户加购的流程是点击加购按钮前端把 dishId 和数量发给后端后端把它追加到当前用户的购物车。前端不维护购物车列表每次进入购物车页从接口拉取。这样做的好处是用户换设备登录时购物车还在商家角度看到的也是实时数据。// frontend/src/api/order.js import request from ./request export const getCart () request.get(/cart/) export const addToCart (dishId, quantity) request.post(/cart/add/, { dish_id: dishId, quantity }) export const createOrder (address) request.post(/orders/create/, { address }) export const getOrderList () request.get(/orders/) export const updateOrderStatus (orderId, status) request.post(/orders/${orderId}/status/, { status })// frontend/src/views/Checkout.vue关键逻辑片段 async function submitOrder() { const address form.address.trim() if (!address) { ElMessage.warning(请填写配送地址) return } const res await createOrder(address) ElMessage.success(下单成功订单号 ${res.order_id}) cart.value [] router.push(/orders) }下单成功后前端把本地购物车置空然后跳到订单列表页。这里有个界面交互的细节后端清空购物车后前端不能重刷页面而要把购物车在内存里的状态也清掉否则用户回到购物车页还会看到旧数据。置空操作的时机是在 createOrder 返回成功之后而不是点击之后这个顺序不能反。如果用户在确认弹窗里取消了操作购物车数据不能丢如果下单接口断了购物车也得保留方便用户重试。这类「数据何时该清、何时该留」的判断就是前后端协作里最常见的逻辑坑。4.4 订单列表与商家接单轮询用户端订单列表展示的是状态和金额商家端需要展示订单明细并处理接单操作。外卖订单在真实场景里有新订单提醒的问题写毕设时不建议引入 WebSocket一个简单的轮询就能满足演示需求。每 10 秒请求一次新订单数量有变化就弹一条提示。// frontend/src/views/shop/ShopOrders.vue轮询逻辑 let timer null function startPolling() { timer setInterval(async () { const res await fetchNewOrders() if (res.count 0) { ElNotification({ title: 新订单, message: 您有 ${res.count} 个新订单待处理, type: info }) orderList.value.unshift(...res.orders) } }, 10000) } onMounted(startPolling) onBeforeUnmount(() clearInterval(timer))轮询的合理间隔是 8 到 15 秒太短会给后端造成无意义的压力太长用户感知不到新订单。clearInterval 在组件销毁时一定要调用否则用户离开商家页面后轮询还在后台发请求浏览器的 network 面板里能看到一堆挂着的请求这在演示时是很减分的。如果想做得更好一点可以在页面隐藏时暂停轮询、重新可见时恢复用 document.visibilitychange 事件控制。这个细节答辩时提一句「按页面可见性调整轮询策略」说明你想过资源消耗的问题。状态轮询的思路是一样的用户在订单详情页可以 5 秒拉一次订单状态比刷新整个页面优雅得多。5. 踩坑排查跨域、图片、时区、并发这四处最容易翻车5.1 跨域配置写了却不生效现象Vue 页面发起请求浏览器 Console 报 CORS error后端日志里完全看不到这条请求。原因分两类。第一类是 django-cors-headers 的中间件位置不对CorsMiddleware 必须写在 MIDDLEWARE 列表的上方放在 session 或 csrf 后面就失效了。第二类是请求被浏览器拦截时走后端 OPTIONS 预检但 Django 默认的 CSRF 中间件也会观察 OPTIONS 请求如果 CORS 中间件没有正确响应浏览器就直接拦截。解决先把 settings.py 里的配置检查一遍确认中间件顺序然后# settings.py 追加 CORS_ALLOWED_ORIGINS [ http://localhost:8080, http://127.0.0.1:8080, ] CORS_ALLOW_ALL_ORIGINS False同时简单的方法是用 Vue 的 devServer proxy让浏览器以为请求是同源的。开发阶段我用 proxy 居多因为少一层域名判断部署后再把 CORS 打开允许前端的正式域名。这里的 CORS_ALLOWED_ORIGINS 里写的是端口号很多项目把 8080 写漏Vue 换个端口启动就又开始报错排查时要先看前端控制台的 URL 端口是不是这个列表里的值。5.2 菜品图片上传成功前端显示裂图现象在 Django admin 里能看到图片文件存在数据库里有路径但页面上 img 标签的 src 打不开响应 404。原因MEDIA_URL 和 MEDIA_ROOT 配置缺失或者主 urls.py 里没有挂载 media 目录。Django 开发服务器默认不服务上传的媒体文件这不算 bug而是设计如此。解决在 settings.py 里配好两个变量再把路由挂上。# settings.py MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media# backend/urls.py from django.conf import settings from django.conf.urls.static import static urlpatterns static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)这个问题在新手项目里出现率极高关键是 urls.py 里的那行 static() 必须放在所有路由定义之后。在 Vue 里显示图片时注意图片 URL 要拼成 http://127.0.0.1:8000 dish.image 的完整形式如果直接拿相对路径 /media/dishes/xx.jpg 放在 baseURL 为 /api 的请求里就会变成 /api/media/dishes/xx.jpg自然找不到文件。我自己会把图片路径在 Serializer 里用 request.build_absolute_uri 转成绝对 URL前端拿到的就是可直接访问的地址省一个拼接逻辑。5.3 订单创建时间差 8 小时现象订单在下单后查看列表created_at 显示的时间比本地时间晚 8 小时或者反过来。原因Django 的 TIME_ZONE 默认是 UTCUSE_TZ 为 True。显示时间时 Django 会按 UTC 输出本地是东八区看起来就差 8 小时。这是很多项目都会触发的经典坑。解决settings.py 里设置 TIME_ZONE Asia/ShanghaiUSE_TZ False。如果 USE_TZ 保持 True那数据库存储的时间依然是 UTC只是模板显示时做了时区转换。做毕设项目直接关掉 USE_TZ 最简单数据存什么、接口返回什么都是本地时间前端不用做任何换算。但要说明这个配置只适合纯国内项目如果系统有海外用户还是要保留时区机制存储 UTC、按用户时区展示。5.4 并发下单导致订单金额错乱现象用两个账号同时在下单偶发出现订单明细里的菜品数量和购物车不一致。原因代码先读取购物车计算金额然后创建订单、清空购物车这中间如果另一个请求也进来读同一条购物车记录两边都算了一遍价格后提交的覆盖了先提交的数据就冲突了。单机调试时很难触发但用脚本并发压测或者答辩现场用两个设备同时操作就能看到问题。解决在事务里给购物车记录加行级锁保证同一时间只有一个请求能读到这条购物车。from django.db import transaction with transaction.atomic(): cart Cart.objects.select_for_update().filter( userrequest.user, is_checked_outFalse ).first() # 后边的计算逻辑都在这个事务块内select_for_update 是 Django 对数据库行锁的封装只有事务内才有效。使用后并发请求会排队等待锁释放而不是同时读到同一份数据。注意 select_for_update 不能配合 SQLite 在某些配置下生效如果发现加了锁依然有问题优先检查当前使用的数据库是否支持行锁MySQL 的 InnoDB 是支持的。这段代码我建议直接写到项目里就算毕设答辩不会遇到并发场景代码评审时这也是一个亮点。5.5 前端路由刷新后 404现象用户停留在 /orders 页面时按 F5 刷新直接出现 404 页面但刷新首页没这个问题。原因vue-router 默认用 history 模式URL 里没有 # 号刷新时浏览器把 /orders 这个路径发给了后端但 Django 后端没有定义这个路由就返回 404。解决两种方案要么把 vue-router 改成 hash 模式URL 变成 /#/orders刷新不会请求后端要么在后端加一个兜底视图把所有非 API 路径都指向 index.html。毕设项目用 hash 模式省事但看起来不如 history 模式专业要保留 history 模式就加一个 catch-all 视图。这里提示一下catch-all 视图一定要放在 urls.py 最底部并且尽量用正则排除 /api 和 /admin 前缀否则你可能把后端接口也兜底到 index.html接口直接返回 HTML前端请求全部解析失败。# backend/urls.py 底部仅 history 模式需要 from django.views.generic import TemplateView urlpatterns [ re_path(r^(?!api/|admin/).*, TemplateView.as_view(template_nameindex.html)), ]6. 从毕设到演示状态机补强、自测清单与一个实用习惯6.1 订单状态机补强把「超时未支付」纳入设计到目前为止的状态流转还缺一个环节用户下单后一直不付款或一直不确认订单永远停在 pending。真实的点餐业务里商家端的合理诉求是「超时未处理的订单自动取消释放运力」。做毕设时可以把定时任务交给 Django 的 admin command 来实现而不是引入 Celery后台每 5 分钟扫一次 pending 超过 15 分钟的订单自动置为 canceled。这个实现比 Celery 轻得多也足够演示。6.2 演示前的一页纸自测清单答辩演示最怕现场翻车提前一天按清单完整走一遍比反复改代码更有效。我常用的清单是注册一个新用户点菜加购提交订单商家端接单改状态为配送中用户端看到状态变化整个链路没有报 4xx 或 5xx图片、数字、金额在界面上正常显示刷新订单页、退出登录再登录数据仍然是连续的。任何一个环节有问题按第 5 章的排查思路逐个定位而不是直接回滚代码。6.3 一个让我少走很多弯路的使用习惯我自己的习惯是在项目根目录放一个 README第一段写启动命令第二段写测试账号第三段写踩过哪些坑。很多同学会在答辩前一天忘了测试账号密码然后在现场重置数据库那个画面我见过不止一次。开发过程中每解决一个 bug 就顺手把现象和原因记在备注文件里这个动作看似花时间但到了写论文的「系统调试」章节时几乎是现成的素材。外卖点餐系统这类课程设计真正拉开差距的不是功能多少而是细节是否闭环超时订单能不能自动取消库存不足时购物车会不会提示商家端看不到自己的订单这种事绝不允许出现。希望这些经验能帮到你把项目稳稳落地、顺顺演示。本文还有配套的精品资源点击获取