1. 校园外卖的需求画像:三方角色与核心业务流程
做校园外卖平台之前,我首先花了一周时间观察校园外卖的实际场景,而不是直接打开 IDE 写代码。校园外卖和美团、饿了么这类社会外卖最大的区别在于配送半径短、高峰期高度集中、用户群体相对封闭,而且很多商家本身就在校园内部或者校门旁边,这就决定了平台的功能设计不能照搬大厂模式。
我当时梳理出三个核心角色:学生用户、商家、配送员。可能很多人觉得还要加一个平台管理员,实际上校园外卖规模没那么大,管理端功能可以合并到系统后台,用 Django 自带的 Admin 就能覆盖。用户端要解决的核心痛点是:不知道今天吃什么、下单后不知道什么时候能送到、支付流程要尽量短。商家端的痛点是:订单消息容易漏看、菜品售罄了不想反复改、出餐后要快速通知配送员。配送端的痛点最实际:一次接多单的路线规划、送达后的状态反馈。
围绕这些痛点,我把核心业务流程画成了这样一条链路:用户浏览商家列表 -> 查看商家菜单 -> 加入购物车 -> 提交订单并支付 -> 商家接单 -> 商家出餐 -> 配送员取餐 -> 配送完成 -> 双方互相评价。这个流程看起来简单,但每一个环节都对应着明确的数据库表设计和 API 接口。举个例子,订单拆单问题就值得提前想清楚:用户在一个商家下单就是一张订单,如果用户想把 A 家的奶茶和 B 家的炒饭一起结算,那就涉及多商家拆单。我第一版选择不支持跨商家合并结算,因为校园外卖的客单价偏低,用户通常一次只在一家下单,强行做拆单会让订单状态管理复杂度翻倍。
功能清单我分为四个端来规划:
- 用户端小程序/Web:微信登录或账号密码登录、商家列表(支持按距离、销量、品类排序)、菜单浏览、购物车、下单支付、订单列表与详情、订单状态跟踪、评价。
- 商家端:菜品管理(上下架、价格库存)、营业状态开关、订单列表(待接单/制作中/已出餐)、接单与出餐操作、基础营业额统计。
- 配送端:待取餐订单池、抢单/派单、取餐与送达状态更新、我的配送记录。
- 管理后台:用户管理、商家审核与入驻、分类管理、订单概览、举报处理。
我实际开发时砍掉了配送端抢单逻辑的复杂度,改成管理员在后台手动指派配送员,或者配送员在配送端自己领单。校园配送员大多是兼职学生,抢单机制听着美好,实际会有人挂机抢单不配送,反而增加管理成本。第一版直接做成“订单完成后由系统广播给在线配送员,先到先得、超时自动释放”。
还有一个容易忽略的需求是“营业时间”。校园商家的营业时间非常不规律,有的只做午高峰、周末不营业,有的要提前一天预订早餐。如果不在商家端做营业时间配置,订单模块会不断收到用户的投诉电话。我在商家表里加了open_time、close_time、weekday_open几个字段,系统下单时先校验商家是否在营业中,这个逻辑很简单却非常实用。
2. 框架选型的真实考量:Django 和 Flask 之间,我为什么选 Django
标题里同时出现了 django 和 flask,这其实是很多人在项目起步时最纠结的问题。我最初也纠结过要不要用 Flask 保持轻量,但做完技术验证之后果断选了 Django,这里说说我的思考过程。
先看一组直接的对比:
| 对比维度 | Django | Flask |
|---|---|---|
| 内置功能 | Admin 后台、ORM、认证、表单、中间件齐全 | 只有路由和模板核心,其余全凭扩展 |
| 学习曲线 | 概念多,上手慢 | 简单,改改就能跑 |
| 数据库操作 | ORM 自带 migrations 管理 | 要自己接 SQLAlchemy,配置不少 |
| 适合场景 | 业务逻辑复杂、实体关系多 | 小型 API、微服务、原型验证 |
| 二次开发 | 模型改完直接生成迁移文件 | 要自己维护模型与表结构同步 |
校园外卖平台至少要包含用户、商家、菜品、订单、订单项、评价、配送记录这七张会互相外键关联的表,订单和订单项、商家和菜品、用户和评价之间全是嵌套关系。用 Flask 不是做不了,而是这些关系查询、联表筛选、后台 CRUD 都要自己造轮子。Django 的 ORM 在模型继承、关系查询、预取优化上非常顺手,比如查询订单时顺带取出用户、商家、订单项,用select_related和prefetch_related三行代码就搞定,同样的需求在 Flask 上要写大量 SQL。
Django 的 Admin 后台对这类管理型平台是巨大的生产力工具。商家审核、订单人工介入、菜品分类调整这些需求,直接用 Admin 就能实现,我几乎没有写任何后台页面前端代码。在项目第一版阶段,Admin 甚至可以直接充当商家管理端,后面才单独写 Vue 页面做商家自助操作。如果你用 Flask,这些后台页面全部得自己画,工作量直接翻倍。
我并不是完全否定 Flask。如果你的项目是纯 API 后端、客户端是独立的 App、没有后台管理需求,Flask 配合蓝图和扩展确实能写出结构优雅的代码。但校园外卖平台核心是数据密集型业务系统,用户认证、Session 管理、CSRF 防护、Admin 这些需求都是刚需,Django 全部内置并且久经考验,我不需要在安全配置上二次操心。选型从来不是比谁技术更酷,而是比谁能在同样时间内交付稳定可维护的东西。
3. 数据模型设计:订单状态机是整条业务链的心脏
我一直认为,外卖平台这类项目的数据库设计决定业务逻辑能走多远。表结构如果设计得不好,后面写 API 时会到处打补丁。我分享一下最终落地的核心模型设计,以及每个表背后的设计理由。
3.1 用户体系和角色划分
我没有单独建管理员表、商家表、配送员表,而是用 Django 的AbstractUser扩展出一个统一的用户模型,再通过role字段区分身份,同时维护一个独立 Profile 存扩展信息。这样做的原因是:一个用户在学校食堂窗口当商家,同时又可能兼职配送员,手机号登录同一账号就能切换身份。如果按角色拆成三张表,用户换身份时数据完全割裂,平台后续要做聚合统计就麻烦了。
扩展后的模型大概是这样的:
from django.db import models from django.contrib.auth.models import AbstractUser class User(AbstractUser): ROLE_CHOICES = ( ('user', '普通用户'), ('merchant', '商家'), ('delivery', '配送员'), ('admin', '管理员'), ) phone = models.CharField(max_length=11, unique=True) role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user') avatar = models.URLField(blank=True) campus_address = models.CharField(max_length=200, blank=True) class Meta: db_table = 'auth_user_ext'phone是登录凭证之一,验证码登录功能开发起来不难,但为了控制第一版复杂度,我先做了账号密码登录 + JWT,手机号作为唯一身份标识。这里有个关键点:Django 自带的 User 模型默认用username登录,你需要自定义认证后端把登录字段改成phone或者允许两者同时登录。
3.2 商家、菜品、分类的表关系
商家模型最容易被忽略的是地理位置字段。校园外卖虽然范围小,但配送距离判断、商家排序和配送费计算都需要经纬度。我在Merchant表里存了latitude和longitude字段,再用一个简单的 Haversine 公式在 Python 里计算距离,没有引入 PostGIS 和 GeoDjango,因为对校园这种几平方公里量级来说,直接用浮点计算完全够用了,省一套基础设施少掉一半部署问题。
菜品表的设计有一个老生常谈但实际很影响开发效率的点:分类和菜品的关系是单独建表,还是菜品表上挂分类字段?我建议在菜品表上直接挂category外键,但分类表隶属于商家,不同商家的分类互不相通。别搞全局分类,因为中餐、西餐、奶茶的分类体系完全不同,全局分类表会让前端菜单渲染非常难受。菜品表的status字段用来表示上架/下架状态,同时stock字段控制可售数量,售罄时前端直接展示“已售罄”而不是让用户下单后再被拒单。
3.3 订单表和订单状态机
订单表是整张表的索引命脉。我把订单核心字段设计为:
class Order(models.Model): STATUS_CHOICES = ( (0, '已取消'), (1, '待支付'), (2, '待接单'), (3, '制作中'), (4, '待配送'), (5, '配送中'), (6, '已完成'), ) order_no = models.CharField(max_length=32, unique=True) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders') merchant = models.ForeignKey(Merchant, on_delete=models.CASCADE, related_name='orders') delivery_user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name='delivery_orders') total_amount = models.DecimalField(max_digits=8, decimal_places=2) status = models.IntegerField(choices=STATUS_CHOICES, default=1) address = models.CharField(max_length=255) remark = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) paid_at = models.DateTimeField(null=True, blank=True) completed_at = models.DateTimeField(null=True, blank=True)为什么要用IntegerField存状态值而不是直接用 CharField 字符串?因为代码里判断状态时用数字最简洁,if order.status == 3一眼就看明白,而且后续和前端联调时数字枚举映射关系清晰。Django 的choices参数只在表单和 Admin 展示层生效,不会影响数据库实际类型,这个设计很成熟。
整条链路最关键的是状态流转校验。我特意写了一个独立的服务函数集中处理状态变更,而不是在每个视图函数里直接order.status = 3然后save()。原因很简单:状态机一旦到处散落,后面加一个“退款中”状态时,你会找出几十处位置逐一修改,漏掉一处就产生脏数据。
class OrderMachine: TRANSITIONS = { 1: [2], # 待支付 -> 待接单 2: [3, 0], # 待接单 -> 制作中 / 取消 3: [4, 0], # 制作中 -> 待配送 / 取消 4: [5, 0], # 待配送 -> 配送中 / 取消 5: [6], # 配送中 -> 已完成 } def transition(self, order, target_status): if target_status not in self.TRANSITIONS.get(order.status, []): raise ValueError(f'订单状态不允许从 {order.status} 变更为 {target_status}') order.status = target_status order.save()3.4 购物车与订单项的取舍
很多教程会把购物车做成数据库表,但我觉得第一版完全没必要。购物车是典型的临时性数据,用户关了浏览器就消失,做成 localStorage 存前端反而更简洁。你在前端维护一个购物车数组,提交订单时把商品 ID 和数量一起传给后端,后端在事务里创建订单和订单项。这样做省掉了购物车表的 CRUD API,而且前端交互更灵敏,增删商品不用等网络请求。
不过有一个细节必须处理干净:用户下单后到支付完成之间,菜品价格可能被商家调整。所以订单项里一定要保存“下单时的快照价格”,而不是下单时再去查菜品表的最新价格。
class OrderItem(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items') dish = models.ForeignKey(Dish, on_delete=models.SET_NULL, null=True) dish_name = models.CharField(max_length=100) # 快照字段 dish_price = models.DecimalField(max_digits=6, decimal_places=2) # 快照价格 quantity = models.PositiveIntegerField(default=1)为什么要冗余dish_name和dish_price快照字段?因为商家删掉菜品或者改价之后,你的历史订单列表依然能正确展示,否则就需要连表查一个已经不存在或者正在变化的菜品记录。这个设计在电商系统里叫“快照模式”,外卖平台必须用。
4. 后端接口实现:从登录鉴权到下单结算的完整链路
数据模型敲定后,后端就是沿着业务流把接口一个个写出来。我按模块分阶段实现,这里挑几个重点环节讲清楚实现思路和关键细节。
4.1 JWT 登录鉴权与自定义认证后端
Django 自带的 Session 认证在小程序端、前后端分离的场景下不是不能用,但每次请求都要带 Cookie,跨域设置麻烦,CSRF 处理也很啰嗦。我选了 Token 认证,使用rest_framework_simplejwt,它能自动处理 access token 和 refresh token 的签发与续期。
配置核心点是把登录标识从 username 改成手机号。我自定义了一个认证后端:
from django.contrib.auth.backends import ModelBackend from django.db.models import Q class PhoneBackend(ModelBackend): def authenticate(self, request, username=None, password=None, **kwargs): try: user = User.objects.get(Q(phone=username) | Q(username=username)) except User.DoesNotExist: return None if user.check_password(password) and self.user_can_authenticate(user): return user return None设置文件里把AUTHENTICATION_BACKENDS指向这个类,同时在 simplejwt 的配置里指定USER_ID_FIELD和USER_ID_CLAIM。前端拿到 token 之后,在请求拦截器里放到Authorization: Bearer <token>头里,后端就能通过request.user拿到当前用户信息了。有一个坑值得提醒:simplejwt 的 refresh token 过期后用户要重新登录,开发阶段可以把SLIDING_TOKEN_REFRESH_EXPIRATION设长一点,减少调试时反复登录的烦躁感。
4.2 商家列表与菜品查询的索引和排序策略
商家列表接口的核心不是逻辑,而是性能和排序规则。校园外卖场景下用户会频繁按距离排序,如果每次请求都实时计算所有商家的经纬度距离再排序,数据量小还好,到了几千家商家时会卡。我的做法是写一个 Python 函数计算两点距离,然后对查询结果做内存排序,因为校园范围小、商家数量有限,内存排序完全够用。如果真要支撑城市级数据量,应该用 MySQL 的空间函数或者 PostGIS,但那样工程复杂度会上升。
菜品查询接口用了 Django ORM 的预取优化。家常写法是商家列表接口里嵌套循环查询菜品,产生 N+1 次数据库查询。我直接在序列化时用Prefetch把菜品一次性取出来:
merchants = Merchant.objects.filter(status=1).prefetch_related( Prefetch('dishes', queryset=Dish.objects.filter(status=1), to_attr='active_dishes') )序列化器里只遍历merchant.active_dishes,一个商家一个列表。这个优化不复杂但效果立竿见影,接口响应时间从几百毫秒降到几十毫秒。
4.3 下单事务与库存扣减的原子性
下单接口是整个后端最容易出 bug 的地方。用户点一次“提交订单”,后端要做的事情包括:校验商家营业状态、校验菜品上下架和库存、计算订单总金额、创建订单记录、创建订单项、扣减菜品库存。任何一个步骤失败都不能让数据库出现半成品订单,所以必须用事务包住整段逻辑。
Django 的transaction.atomic()上下文管理器可以嵌套使用。我建议把创建订单的核心逻辑写成一个独立函数并用@transaction.atomic装饰器包裹,内部任何异常都会触发整体回滚。这里要注意一个和并发相关的坑:扣减库存时不能用“先查库存再判断够不够”的方式,因为高并发下两条请求同时查到库存为 1,然后都扣减成功,库存变成 -1。正确做法是用数据库的行级锁或者原子更新:
from django.db import transaction with transaction.atomic(): dish = Dish.objects.select_for_update().get(pk=dish_id) if dish.stock < quantity: raise ValueError('库存不足') dish.stock -= quantity dish.save()select_for_update()会锁定这条菜品记录直到事务结束,第二个请求必须等待前一个事务提交后才继续执行,这样就不会出现超卖。
还有一个容易被忽略的问题:支付成功的回调。第一版我做了支付模拟接口,用户调用pay_order接口传入订单号,后端在校验订单状态为“待支付”后直接置为“待接单”,不接真实第三方支付。但支付回调一定是异步的,如果你后续接入微信支付或支付宝,需要在回调接口里做签名校验、金额校验、订单号校验,而且回调处理必须幂等,不能因为重试导致重复更新状态。这些我在第一版代码里就预留了接口位置。
4.4 状态变更接口与操作权限
订单状态变化涉及用户、商家、配送员三方不同的操作权限。用户能催单但不能把已支付的订单改成已完成,商家能接单但不能替用户确认收货,这些规则我在视图函数里通过request.user.role严格判断。
以商家接单为例:
@api_view(['POST']) def merchant_accept_order(request, order_id): order = get_object_or_404(Order, pk=order_id) if request.user.role != 'merchant': return Response({'detail': '无权操作'}, status=403) if order.merchant.user != request.user: return Response({'detail': '只能操作本店订单'}, status=403) machine = OrderMachine() try: machine.transition(order, 3) # 待接单 -> 制作中 except ValueError as e: return Response({'detail': str(e)}, status=400) # 这里触发通知逻辑 notify_user(order, '商家已接单') return Response(OrderSerializer(order).data)平台后续如果要接入消息推送,这些notify_*函数就是扩展点,我把它们从视图逻辑里剥离出来,避免视图过度膨胀。
5. Vue 前端搭建:从页面拆分到接口联调
后端接口有了,前端我选了 Vue 3 + Vite + Pinia + Element Plus。重点讲一下页面结构怎么组织、Axios 怎么封装、以及前后端联调过程中最容易踩的坑。
5.1 前端页面结构与路由设计
我按角色维度拆分前端项目,而不是按页面维度堆在一起。项目目录大概是这样的:
src/ views/ user/ ShopList.vue ShopDetail.vue CartPage.vue OrderList.vue OrderDetail.vue merchant/ Dashboard.vue DishManage.vue OrderManage.vue delivery/ TaskList.vue login/ LoginPage.vue RegisterPage.vue router/index.js stores/user.js api/request.js路由对象里用路由守卫控制权限,通过 Pinia 里存的用户角色判断当前路由是否允许访问。这个方案简单直接,但要注意一点:路由守卫只能做前端控制,真正的权限校验必须以后端接口返回的 403 为准,前端守卫只是用户体验层面的提前拦截。
5.2 Axios 封装和 Token 处理
Axios 封装是我每次项目都会做但很多教程不讲的部分。完整封装包含三块:请求拦截器统一加 token;响应拦截器统一处理业务错误码和 401 跳转登录;统一携带一些通用的请求头。
import axios from 'axios' const request = axios.create({ baseURL: '/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) { if (error.response.status === 401) { localStorage.removeItem('access_token') router.push('/login') } else if (error.response.status === 400) { return Promise.reject(new Error(error.response.data.detail || '请求参数错误')) } } return Promise.reject(error) } )这里有个细节我踩过坑:baseURL设置为/api后,开发环境怎么解决跨域?我推荐用 Vite 的代理而不是引入django-cors-headers。在vite.config.js里这样配置:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true, }, }, }, })这样前端页面里的请求都走http://localhost:5173/api,Vite 开发服务器把它代理转发到 Django 的 8000 端口。浏览器里看不到跨域,因为请求是同源的。生产环境则直接用 Nginx 反向代理同一套路径规则。相比之下,用django-cors-headers还需要维护允许的来源列表,开发时还经常因为ALLOWED_HOSTS配置漏掉地址而白屏,代理方案更省心。
5.3 购物车和页面状态管理
购物车我选择放在 Pinia 里存内存,同时镜像一份到 localStorage,刷新页面不丢失。用 Pinia 的好处是商家详情页加购之后,底部导航栏的购物车角标能响应式地更新。
export const useCartStore = defineStore('cart', { state: () => ({ items: JSON.parse(localStorage.getItem('cart_items') || '[]'), }), actions: { addToCart(dish, quantity = 1) { const existing = this.items.find((item) => item.dish.id === dish.id) if (existing) { existing.quantity += quantity } else { this.items.push({ dish, quantity }) } localStorage.setItem('cart_items', JSON.stringify(this.items)) }, clear() { this.items = [] localStorage.removeItem('cart_items') }, }, })有个地方必须提醒:localStorage 里存的商品数据里包含价格,用户可能通过浏览器开发者工具手动修改 localStorage 里的价格再提交订单。所以前端购物车里的价格只能做展示,后端创建订单时必须以数据库里的菜品价格为准重新计算总金额。开发阶段我就发现过这个问题,提前做了防护。
6. PyCharm 下的双进程开发:环境配置与调试技巧
很多人把 PyCharm 当一个编辑器用,实际上它在 Django + Vue 这种双进程项目里能发挥很大价值。我在整个开发过程中总结了一套配置方案,能显著减少切换窗口和 Debug 的痛苦。
6.1 同时管理 Django 后端和 Vue 前端
PyCharm 专业版支持同时配置多个运行配置(Run Configuration)。我会建立两个配置:一个是 Django Server,启动命令是python manage.py runserver 8000;另一个是 npm 脚本配置,执行npm run dev启动 Vite 开发服务器。两个配置可以同时运行,PyCharm 右上角会并排显示两个服务。这样我改完后端代码在 PyCharm 里按 Ctrl+Shift+F10 重启,改完前端代码 Vite 会自动热更新,不用在终端窗口之间来回切换。
专业版对 Vue 单文件组件和高亮支持非常完整,而且在调试接口时可以直接用 PyCharm 内置的 HTTP Client(也就是.http文件)发送测试请求,不需要开 Postman:
### 用户登录 POST http://localhost:8000/api/token/ Content-Type: application/json { "username": "test_user", "password": "test_password" } ### 获取商家列表 GET http://localhost:8000/api/merchants/ Authorization: Bearer <token>这种文件式接口调试的好处是请求历史、环境变量、断言逻辑都保留在项目里,换电脑也能直接接着用。
6.2 虚拟环境和依赖管理的坑
我建议用 PyCharm 创建项目时直接选择虚拟环境,并把 Django 和 DRF(Django REST Framework)装进去。这里有一个非常容易踩的坑:默认的python解释器可能是系统全局环境,装了一堆乱七八糟的包,结果某个依赖版本冲突导致 Django 启动崩溃。用虚拟环境能隔离项目依赖,专案专用。
依赖清单里我认为必须装的是这些:
- Django(我用的是 4.2 LTS 版本)
- djangorestframework
- djangorestframework-simplejwt
- django-cors-headers(虽然 Vite 代理规避了跨域,但生产环境还是可能需要)
- mysqlclient 或 pymysql(看数据库选型)
- pillow(图片上传处理,商家上传菜品图必需)
有一个必须注意的坑:Django 3.2 以上对auto_now_add和auto_now字段的处理没有变化,但是 MySQL 8 默认字符集如果是 utf8mb4,迁移时可能会因为索引长度问题报错。我遇到过一次Specified key was too long的错误,解决方案是在数据库连接配置里显式指定字符集为 utf8mb4,或者在 Django 的settings.py里加上OPTIONS = {'charset': 'utf8mb4'}。
6.3 断点调试的实战心得
Django 项目在 PyCharm 里打断点调试非常方便,但很多人不知道调试代码时能不能看到 SQL 语句。我通常会在断点处切到 Console 窗口,然后手动执行:
from django.db import connection print(connection.queries)这样就能看到当前请求执行的全部 SQL,排查 N+1 查询问题直接一目了然。配合django-debug-toolbar可以在网页底部看到直观的 SQL 统计,但我个人觉得在开发量不大的时候,connection.queries一梭子就能解决问题,装上调试工具栏反而拖慢页面响应。
另一个实用技巧是调试异步请求。Vue 前端发起的 Axios 请求进入 Django 视图函数后,PyCharm 的调试器可能停在断点处,但 Vite 那边还在等待响应,这是正常的。你在 PyCharm 里按 F8 单步执行,前端页面会在请求返回后自动更新,体验基本接近全栈调试器。
7. 部署与优化:上线前必须处理的几个关键问题
项目开发完不是终点,能稳定跑起来交付给同学用才叫完成。这里讲讲部署方案和上线前我做的几项关键优化。
7.1 轻量部署方案:Nginx + Gunicorn + Django
校园项目不需要上 Docker 全家桶,一个轻量云服务器加 Nginx 就能扛住日常请求。我的部署方案是:Gunicorn 充当 Python WSGI 服务器运行 Django 应用,监听本机 8000 端口;Nginx 监听 80 端口,把/api路径的请求转发到 Gunicorn,同时托管前端构建后的静态文件。
前端构建后产物在dist/目录里,把里面的文件放到 Nginx 的 web 根目录或者单独指定目录就行。Nginx 核心配置大概长这样:
server { listen 80; server_name your-domain.com; location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { root /var/www/html/campus-takeout/dist; try_files $uri $uri/ /index.html; } }有一个部署的细节必须牢记:Django 的DEBUG模式在线上一定要设成 False,否则一个未捕获异常会直接把栈信息完整吐给用户,安全风险巨大。同时要配置ALLOWED_HOSTS为你的服务器域名或 IP。收集静态文件用python manage.py collectstatic,再把收集到的静态文件交给 Nginx 托管,否则 CSS 和图片会全部 404。
7.2 数据库层面的索引与查询优化
上线前我检查了每个高频接口的数据库查询,发现订单列表接口存在明显的性能问题:按用户查询订单时,时间排序走的是created_at字段,但这个字段没有索引,数据量上去后会全表扫描。我增加了数据库索引:
class Order(models.Model): ... created_at = models.DateTimeField(auto_now_add=True, db_index=True)同时给订单的status字段加索引,因为运营后台要高频按状态筛选订单。另外一个常见的优化是分页,Django REST Framework 内置的PageNumberPagination直接可用,我配置每页 20 条,前端滚动加载时按页请求。校园学生用户量大,订单表几百条数据就开始卡的话,体验会很差。
还有一类优化是不起眼但极其实用的:菜品图片不要直接存在本地磁盘里然后让 Django 去读文件。开发时可以先服务本地媒体文件,但上线后建议把图片放到对象存储里,数据库只存 URL 字段。对象存储的成本非常低,但能显著降低 Web 服务器负载,并且图片加载速度快很多。
7.3 上线后的几个真实教训
项目上线一周后,我遇到的真实问题和我预期的技术难点完全不一样。
最大的教训是密码安全策略不要图省事。我在 User 模型里给密码设了简单的正则校验,结果有同学注册了 8 位纯数字密码,被撞库攻击后在别家平台被盗号,回头投诉到我这。后来我统一把密码校验策略调高,至少包含字母和数字,并且把登录接口加了简单的限流,每个手机号一分钟最多尝试五次。
第二件事是订单并发下的状态混乱。开发时我注意力全放在库存扣减的原子性上,忘了订单状态本身在高并发下也可能被重复更新。用户在支付页秒点两次“提交订单”,后端可能创建出两条订单。我加了前端防重复提交,同时在订单号生成上使用 UUID + 时间戳保证唯一,并且在后端对相同用户在短时间内的订单创建做了幂等控制。
第三件事是商家端权限要管到菜单链接级别。我最初只在前端路由做了角色限制,但后来发现有人通过修改前端 JavaScript 或者直接访问接口路径,能拿到商家端的 API 请求。真正的安全防线永远在服务端,前端路由守卫只是体验层的优化。我把商家端的每个接口都加上了角色和所有权校验,宁可多写几行判断也不能让数据裸奔。
写在最后的一点心得
校园外卖项目我前后花了大约四周时间,从需求梳理到部署上线,最大的感受是:这类全栈项目真正的难点不是某个单独技术,而是把各个模块的逻辑边界梳理清楚。Django 负责把数据模型和业务规则稳住,Vue 负责让用户交互更流畅,PyCharm 负责把两种语言的开发体验整合到一条工作流里,三者各司其职,项目就不会失控。
如果再给我一次机会重新开始,我会在第一天就把订单状态机的测试写完整。当时我嫌单元测试麻烦,觉得函数逻辑简单没必要测,结果后来改动模型字段时,一个不起眼的状态变更遗漏导致线上订单卡在“制作中”整整两小时。这类业务系统里,自动化测试从第一天开始写并不亏,后面每次重构都有安全网兜底。如果你想做类似项目,建议把这条经验也记下来。