做茶叶商城这个项目,其实是被朋友的一句话推着走的。他说想搞个线上卖茶的铺子,要能展示茶叶、能下单、能看订单,最好以后还能搞活动。我寻思这不就是个典型的电商系统吗,但真上手之后发现,茶叶这个品类比想象中复杂——同样是绿茶,产地不同、采摘时间不同、工艺不同,价格能差出好几倍。这种“属性多、分类细、SKU看似简单实则复杂”的业务特征,反而特别适合拿来做一个完整的前后端分离项目,从数据库表设计到接口开发再到前端页面渲染,每个环节都能踩到实实在在的坑。这篇文章就是从这个项目里整理出来的完整复盘,从技术选型到数据库设计,从后端接口到Vue前端实现,再到最后的部署收尾,每一步都记录了当时的思考和踩过的坑。适合正在学Python Web开发、准备做毕业设计,或者想自己动手搞一个电商类项目的人参考。
1. 项目定调:网上茶叶商城的核心需求与技术选型
1.1 茶叶商城的业务需求拆解
开始写代码之前,我习惯先把业务需求拆清楚。这个茶叶商城看起来只是个“卖货”的网站,但真往细了想,需要覆盖的模块一点儿也不少。
用户侧的核心流程是:注册登录 → 浏览商品 → 加入购物车 → 提交订单 → 模拟支付 → 查看订单状态。在此基础上还牵扯到商品分类筛选、茶叶详情展示(产地、年份、工艺、储存方式等细碎信息)、购物车数量修改、订单状态流转(待支付、已支付、已发货、已完成)这些环节。
管理侧的需求更直接:得能添加茶叶商品、维护库存、处理订单状态。如果从头写一套管理后台,工作量不小,但好在如果用Django,自带的Admin后台稍微改改就能用,这才是选型时的重要加分项。
拆完需求之后,整个项目的边界就清楚了。这是一个典型的“B2C零售系统”,看着简单,但涉及的东西很全:用户体系、商品体系、交易体系、后台管理,一套走完,你对Web全栈的理解会提升一大截。
1.2 Django与Flask的选型思考
项目标题里同时出现了Django和Flask,这其实是很多人在起步阶段的纠结。这两个框架我都有实际项目经验,直接说说结论。
选Django的理由在商城项目里非常明显:
- 自带ORM、Admin后台、认证系统、表单校验,商城这种业务密集型项目能省下大量重复劳动
- 自带的Admin后台改改配置就能给运营用,否则你要为了“添加商品”这个功能单独写一整套管理页面
- Django的ORM在处理关联查询的时候非常顺手,比如查某个用户的订单时连带查出订单项和商品信息,select_related和prefetch_related用好了性能也不差
Flask的优势在于灵活。它轻,想怎么组织代码都行,适合那种接口少、逻辑简单、需要高度定制化的项目。但商城这种模块多、关系复杂的项目,用Flask就得自己搭结构、接ORM、写认证,性价比不高。
有一说一,如果只是做一个非常轻量的展示型电商(比如就几个页面、不涉及复杂订单流程),Flask完全够用。但如果是想认真做一个能长期扩展的商城系统,我不纠结,直接推Django。
1.3 为什么前端选择Vue
后端定下来用Django,前端我选了Vue 3。前后端分离是现在的主流做法,前端的交互体验和页面渲染都更灵活,后端只需要专心提供JSON接口就行。
Vue的优势是渐进式:项目简单的时候,你可以只在一个页面里引入Vue做局部交互;项目复杂了,配合Vue Router做路由、Pinia或Vuex做状态管理,一样能撑起完整的单页应用。这个项目里我们需要处理商品列表、商品详情、购物车状态、用户登录状态,这些跨页面共享的数据用状态管理工具统一维护,逻辑会清爽很多。
选型这事儿,没有绝对的对错,关键是匹配项目规模和团队熟悉度。我见过有人用Flask加原生JavaScript也能把商城做完,也有人用Django加Vue把项目做得特别重。这个茶叶商城项目,我最终定下的技术栈是:Django + Django REST Framework + Vue 3 + Pinia + MySQL,开发IDE用PyCharm。
2. 开发环境搭建与项目初始化
2.1 PyCharm、Python与虚拟环境准备
工欲善其事,必先利其器。Python开发我用的是PyCharm,社区版其实就够用了,专业版支持Django模板提示和数据库工具,体验更好,但不用强求。Python版本这里提醒一句,直接用3.10或3.11就行,别用太老的3.7——Django新版本已经开始要求Python 3.10以上了。
环境这块我踩过一个教训:一定、一定、一定要用虚拟环境。刚开始学Python那会儿我图省事,全局pip install,结果项目一多,依赖冲突得让人崩溃。这个茶叶商城项目依赖包括Django、Django REST Framework、django-cors-headers等,各自有版本要求,不用虚拟环境纯属给自己添堵。PyCharm新建项目的时候会默认帮你创建venv虚拟环境,这个操作不要取消。
# 在PyCharm终端或者命令行中,确认虚拟环境已激活 python --version # 安装Django及项目依赖 pip install django djangorestframework django-cors-headers pillow # 检查安装版本 pip list | findstr Django # Windows pip list | grep Django # macOS/LinuxDjango版本我建议直接用最新的稳定版(目前是4.x或5.x),DRF则需要匹配对应版本。这里要注意一点:django-cors-headers这个库是前后端分离开发的刚需,不加它,你Vue项目访问后端接口的时候会被浏览器的同源策略直接拦下。
2.2 Django项目与Vue项目的目录规划
我的习惯是前端和后端放在同一个总目录下,但各自独立,互不干扰。整体结构是这样:
tea_shop/ # 项目总目录 ├── backend/ # Django后端 │ ├── manage.py │ ├── tea_shop/ # Django项目配置目录 │ └── apps/ │ ├── users/ # 用户模块 │ ├── goods/ # 商品模块 │ ├── cart/ # 购物车模块 │ └── orders/ # 订单模块 └── frontend/ # Vue前端 ├── src/ │ ├── views/ # 页面组件 │ ├── components/ # 通用组件 │ ├── stores/ # Pinia状态 │ ├── router/ # 路由配置 │ ├── api/ # 接口封装 │ └── utils/ # 工具函数 ├── package.json └── vite.config.js创建Django项目的命令是:
# 在backend目录下 django-admin startproject tea_shop . python manage.py startapp users python manage.py startapp goods python manage.py startapp cart python manage.py startapp orders注意startproject命令后面那个点,表示在当前目录生成项目文件,不会再多套一层目录。这个细节看起来无关紧要,但目录层级一旦多套一层,后面写代码的时候导入路径就会很别扭。
创建Vue项目我用的是Vite而不是Vue CLI,Vite启动速度快,配置也简单:
# 在frontend目录下 npm create vite@latest . -- --template vue npm install npm install vue-router pinia axios这里直接在当前目录初始化Vite项目,同样是为了避免目录嵌套过深。
2.3 数据库选型与Django配置
数据库我用的是MySQL,因为MySQL是最通用的选择,以后真要上线部署,找运维资料也方便。开发环境我用的MySQL 8.0,生产环境部署时同样用MySQL,保持环境一致。
在Django的settings.py里配置数据库连接:
# backend/tea_shop/settings.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'tea_shop', 'USER': 'root', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', } }用MySQL还得安装一个连接驱动,比如mysqlclient或者pymysql。在Windows上安装mysqlclient可能会遇到编译问题,推荐直接用pymysql,兼容性好,安装也简单:
pip install pymysql然后在__init__.py里写入:
# backend/tea_shop/__init__.py import pymysql pymysql.install_as_MySQLdb()这一步是让Django以为你在用MySQLdb,实际上是走了pymysql的兼容层。如果不做这一步,Django连接MySQL的时候会直接报错“No module named MySQLdb”。
还有一件事:在settings.py的INSTALLED_APPS里把rest_framework和corsheaders加进去,再把django-cors-headers的中间件注册到MIDDLEWARE里。这些都是后面开发接口的前置条件。
INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', 'corsheaders', 'users', 'goods', 'cart', 'orders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', ... ] CORS_ALLOW_ALL_ORIGINS = True开发阶段CORS直接全放开就行,上线之前再收紧,不然前后端联调时跨域问题会浪费你大量时间。
3. 数据库表设计与核心模块划分
3.1 用户模块与Token认证设计
网上商城系统里,用户表是最基础也最关键的表。Django自带了一个User表,但字段不一定够用,我的做法是创建一个Profile模型,和Django自带的User做一对一关联,这样既能复用Django的认证逻辑,又能扩展自己的字段。
# backend/apps/users/models.py from django.contrib.auth.models import User from django.db import models class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') phone = models.CharField(max_length=11, blank=True, null=True) address = models.CharField(max_length=255, blank=True, null=True) avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True)注册登录的认证方案,我选的是JWT,先用的是djangorestframework-simplejwt这个库。用JWT的好处是前后端分离架构下不需要维护Session状态,后端接口无状态化,扩展和部署都方便。用户登录后拿到一个access_token,后续每次请求在Authorization头里带上这个token,后端通过认证中间件解析用户身份。
# 安装 simplejwt pip install djangorestframework-simplejwt在settings.py里配置JWT认证:
from datetime import timedelta from rest_framework.settings import api_settings SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(hours=2), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), } REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication.JWTAuthentication', ), }JWT的过期时间我设的是2小时,Refresh Token设7天。这个时间可以自己调,但核心逻辑是:access_token短一点安全,refresh_token长一点方便用户自动续期。
3.2 茶叶商品模型:分类、属性与库存
茶叶商品的设计是整个项目里最需要仔细琢磨的部分。很多新手做电商项目,就直接建一个商品表,字段写名称、价格、图片,完事。但茶叶这个品类特殊,光是一个商品名称,就可能包含品牌、产地、原料、等级、年份、净含量、许可证号等一大堆信息。
我设计的商品表核心字段大概这个样子:
# backend/apps/goods/models.py class Category(models.Model): name = models.CharField(max_length=50) # 绿茶、红茶、乌龙茶、白茶、黑茶 parent = models.ForeignKey('self', null=True, blank=True, related_name='children', on_delete=models.CASCADE) class Tea(models.Model): category = models.ForeignKey(Category, related_name='teas', on_delete=models.CASCADE) name = models.CharField(max_length=100) origin = models.CharField(max_length=50, blank=True) # 产地 year = models.IntegerField(blank=True, null=True) # 年份 craft = models.CharField(max_length=50, blank=True) # 工艺 price = models.DecimalField(max_digits=8, decimal_places=2) # 单价 stock = models.IntegerField(default=0) # 库存 sales = models.IntegerField(default=0) # 销量 description = models.TextField(blank=True) # 详情描述 image = models.ImageField(upload_to='teas/', blank=True) # 主图 is_active = models.BooleanField(default=True) # 是否上架 created_at = models.DateTimeField(auto_now_add=True)这里的Category用了自关联外键,支持二级分类。虽然茶叶商城用一级分类可能就够,但自关联的设计意味着以后如果要加“绿茶下的龙井、毛峰、碧螺春”这种子分类,直接插数据就行,不需要改表结构。
产地、年份、工艺这几个字段虽然看起来不是必填项,但实际是茶叶商品的核心卖点。建议在商品列表页就把产地和年份展示出来,茶叶用户非常吃这一套。这属于业务细节,不熟悉茶叶品类的人很容易忽略。
库存字段必须用IntegerField,而且每次下单更新库存的时候要注意并发问题,这个后面在订单模块里细说。
3.3 购物车、订单与订单项的表结构
购物车表结构相对简单,核心就是把“哪个用户买了哪个商品、买了几件”记录下来:
# backend/apps/cart/models.py class CartItem(models.Model): user = models.ForeignKey(User, related_name='cart_items', on_delete=models.CASCADE) tea = models.ForeignKey('goods.Tea', related_name='cart_items', on_delete=models.CASCADE) quantity = models.PositiveIntegerField(default=1) created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ('user', 'tea')unique_together的含义是:同一个用户和同一种茶叶在购物车表里面只能有一条记录。这样设计的好处是,用户重复点击“加入购物车”时,我们只需要更新商品数量,而不是新增记录。这个约束在数据库层面就保证了数据不会重复,比在代码里判断靠谱得多。
订单表要稍微拆开看。一个订单包含订单主表和订单项表,主表记录订单总额、状态、收货信息等,订单项表记录每个商品的下单数量、单价。这是电商系统的基础设计模式,一定要拆开,不然你无法处理“一个订单里有三种茶叶”的情况。
# backend/apps/orders/models.py class Order(models.Model): STATUS_CHOICES = [ ('pending', '待支付'), ('paid', '已支付'), ('shipped', '已发货'), ('completed', '已完成'), ('canceled', '已取消'), ] user = models.ForeignKey(User, related_name='orders', on_delete=models.CASCADE) order_no = models.CharField(max_length=64, unique=True) # 订单号 total_amount = models.DecimalField(max_digits=10, decimal_places=2) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') address = models.CharField(max_length=255) # 收货地址 receiver = models.CharField(max_length=30) # 收货人 phone = models.CharField(max_length=11) # 联系电话 created_at = models.DateTimeField(auto_now_add=True) class OrderItem(models.Model): order = models.ForeignKey(Order, related_name='items', on_delete=models.CASCADE) tea = models.ForeignKey('goods.Tea', related_name='order_items', on_delete=models.PROTECT) tea_name = models.CharField(max_length=100) # 冗余商品名称,防止商品修改后历史订单混乱 price = models.DecimalField(max_digits=8, decimal_places=2) # 下单时价格快照 quantity = models.PositiveIntegerField(default=1) class Meta: unique_together = ('order', 'tea')OrderItem里我特地加了tea_name和price字段,这两个不是冗余设计,而是“快照”。为什么要做快照?因为你卖的是茶叶,商品价格和名称可能会调整,但历史订单不能跟着变。用户一个月前买的龙井,不能因为你今天把商品改名了,他的历史订单就显示新名字。这笔账在开发时就要算清楚。
4. 后端接口开发:从Model到API的完整链路
4.1 Django REST Framework的序列化器设计
后端接口是前后端分离架构的桥梁。我用的Django REST Framework(简称DRF)提供了一套高度封装的API开发组件,序列化器就是其中最关键的一环。序列化器做的事情,就是把Django的Model对象转换成JSON返回给前端,同时把前端传来的JSON校验后转换成Model存进数据库。
商品列表的序列化器写法:
# backend/apps/goods/serializers.py from rest_framework import serializers from .models import Category, Tea class CategorySerializer(serializers.ModelSerializer): class Meta: model = Category fields = ['id', 'name', 'parent'] class TeaSerializer(serializers.ModelSerializer): category_name = serializers.CharField(source='category.name', read_only=True) class Meta: model = Tea fields = ['id', 'name', 'category', 'category_name', 'origin', 'year', 'craft', 'price', 'stock', 'sales', 'image', 'description']这里加了一个只读字段category_name,因为前端商品列表一般只需要展示“这是什么分类”,而不需要去联动查询分类表。这种在序列化器里直接取关联字段的方式,比前端自己维护分类映射要省事得多。
订单接口的序列化器复杂一些,因为要支持嵌套:
class OrderItemSerializer(serializers.ModelSerializer): tea_name = serializers.CharField(read_only=True) class Meta: model = OrderItem fields = ['id', 'tea', 'tea_name', 'price', 'quantity'] class OrderSerializer(serializers.ModelSerializer): items = OrderItemSerializer(many=True, read_only=True) status_display = serializers.CharField(source='get_status_display', read_only=True) class Meta: model = Order fields = ['id', 'order_no', 'total_amount', 'status', 'status_display', 'receiver', 'phone', 'address', 'items', 'created_at']4.2 视图函数与API路由设置
DRF提供了三种视图写法:函数视图@api_view、类视图APIView、视图集ViewSet。这个项目我混合使用了ViewSet和APIView。商品和分类这种以查询为主的模块,用ViewSet很舒服,因为它自动帮你实现了list、retrieve、create、update这些标准方法。
# backend/apps/goods/views.py from rest_framework import viewsets from .models import Category, Tea from .serializers import CategorySerializer, TeaSerializer class CategoryViewSet(viewsets.ReadOnlyModelViewSet): queryset = Category.objects.all() serializer_class = CategorySerializer class TeaViewSet(viewsets.ReadOnlyModelViewSet): queryset = Tea.objects.filter(is_active=True) serializer_class = TeaSerializer def get_queryset(self): queryset = super().get_queryset() category_id = self.request.query_params.get('category') keyword = self.request.query_params.get('keyword') if category_id: queryset = queryset.filter(category_id=category_id) if keyword: queryset = queryset.filter(name__icontains=keyword) return querysetReadOnlyModelViewSet是只读视图集,只有列表和详情两个接口,商品展示正好用这个,思路清晰也不容易出错。get_queryset方法里我做了分类筛选和关键词搜索,这些都是商城系统的刚需功能。
购物车和订单因为涉及写操作和用户绑定,我用的是APIView,代码更明确:
# backend/apps/cart/views.py from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated class CartListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): items = CartItem.objects.filter(user=request.user).select_related('tea') # 构造返回数据 ...路由配置在Django的urls.py里:
# backend/tea_shop/urls.py from django.urls import path, include from rest_framework.routers import DefaultRouter from apps.goods.views import CategoryViewSet, TeaViewSet router = DefaultRouter() router.register('categories', CategoryViewSet) router.register('teas', TeaViewSet) urlpatterns = [ path('api/', include(router.urls)), path('api/auth/', include('djoser.urls')), # 或者自己写注册登录接口 path('api/cart/', include('apps.cart.urls')), path('api/orders/', include('apps.orders.urls')), ]4.3 用户注册登录与JWT鉴权流程
用户注册登录这个环节,我没有用Django自带的登录接口,而是自己写了一套基于simplejwt的流程。注册接口的核心逻辑是:前端把用户名和密码传过来,后端创建User对象,然后返回access_token和refresh_token。这样用户注册完就已经是登录状态,不用再跳去登录页,体验好很多。
# backend/apps/users/views.py from django.contrib.auth.models import User from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken class RegisterView(APIView): def post(self, request): username = request.data.get('username') password = request.data.get('password') email = request.data.get('email', '') if not username or not password: return Response({'detail': '用户名和密码不能为空'}, status=status.HTTP_400_BAD_REQUEST) if User.objects.filter(username=username).exists(): return Response({'detail': '用户名已存在'}, status=status.HTTP_400_BAD_REQUEST) user = User.objects.create_user(username=username, password=password, email=email) refresh = RefreshToken.for_user(user) return Response({ 'access': str(refresh.access_token), 'refresh': str(refresh), 'user': {'id': user.id, 'username': user.username} }, status=status.HTTP_201_CREATED)对数据校验这里要特别说一下,后端必须做校验,不能光靠前端。前端校验只是为了用户体验,后端校验才是安全保障。用户名是否重复、密码是否为空、密码长度是否达标,这些必须后端判断一遍。恶意用户可以绕过前端直接调用你的接口,你不可能指望他老老实实走页面流程。
4.4 下单逻辑与库存并发处理
下单是整个系统最核心的写操作。流程是这样的:
- 用户提交收货信息和购物车ID列表
- 后端验证用户身份和收货信息
- 计算订单总金额
- 扣减库存
- 生成订单和订单项
- 清空购物车
第4步扣库存这块是最容易出问题的。假设只有一个用户下单,事情很简单,库存减一就行。但如果是秒杀场景,几百上千人同时下单,每个人都读到库存大于0,然后都执行减库存,最后库存就变成负数了。
Django的ORM在并发场景下读取和写入不是原子的。简单写法是:
tea = Tea.objects.get(id=tea_id) if tea.stock >= quantity: tea.stock -= quantity tea.save() # 这样写有并发风险更稳妥的做法是使用Django的F表达式做原子更新:
from django.db.models import F updated = Tea.objects.filter(id=tea_id, stock__gte=quantity).update(stock=F('stock') - quantity) if updated == 0: return Response({'detail': '库存不足'}, status=status.HTTP_400_BAD_REQUEST)这段查询的作用是:只有当当前库存大于等于购买数量时,才执行减库存的操作。数据库层面的条件更新保证了原子性,在并发场景下不会出现超卖。这个技巧是电商开发里教科书级别的坑,不管项目大小都应该按这个思路来写。
下单还需要生成订单号。我的方案是时间戳加用户ID再加随机数:
import time, random def generate_order_no(user_id): ts = time.strftime('%Y%m%d%H%M%S') rand = random.randint(1000, 9999) return f'{ts}{user_id}{rand}'订单号的格式没有统一标准,但核心要求是唯一、可读、不暴露太多业务信息。我见过有些项目直接用UUID,其实也可以,就是订单号太长,客户售后报订单号的时候报起来很痛苦。
5. Vue前端开发:商品展示与购物车交互
5.1 Vue项目结构与路由配置
前端的开发我用了Vue 3的Composition API(也就是script setup的写法),这种写法相比Vue 2的Options API更简洁,代码组织也更灵活。在Vite创建的Vue项目里,我先把路由配好:
// frontend/src/router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', name: 'home', component: () => import('../views/HomeView.vue') }, { path: '/goods/:id', name: 'goods-detail', component: () => import('../views/GoodsDetailView.vue') }, { path: '/cart', name: 'cart', component: () => import('../views/CartView.vue') }, { path: '/orders', name: 'orders', component: () => import('../views/OrderListView.vue') }, { path: '/login', name: 'login', component: () => import('../views/LoginView.vue') }, { path: '/register', name: 'register', component: () => import('../views/RegisterView.vue') }, ] const router = createRouter({ history: createWebHistory(), routes, }) // 全局路由守卫:需要登录的页面做跳转 router.beforeEach((to) => { const token = localStorage.getItem('access_token') if (to.name === 'cart' || to.name === 'orders') { if (!token) return { name: 'login' } } }) export default router我用了Vue Router的懒加载,通过import()函数动态引入组件,这样首屏加载不需要一次性把所有页面代码都下载下来,对性能有好处。路由守卫里判断了需要登录的页面,没有token就强制跳登录页,这个逻辑是商城系统的标配。
5.2 商品列表与详情页的实现
商品列表页是商城对外的门面,用户体验好不好,第一印象非常重要。我采用的是“左侧分类侧边栏 + 右侧商品卡片网格”的经典布局。左侧从后端接口拉取分类列表,点击不同的分类,会携带参数重新请求商品接口。
<!-- frontend/src/views/HomeView.vue --> <script setup> import { ref, onMounted, watch } from 'vue' import { getCategories, getTeas } from '../api/goods' import GoodsCard from '../components/GoodsCard.vue' import { useRoute } from 'vue-router' const route = useRoute() const categories = ref([]) const teas = ref([]) const loading = ref(false) async function loadGoods() { loading.value = true const params = {} if (route.query.category) params.category = route.query.category if (route.query.keyword) params.keyword = route.query.keyword teas.value = await getTeas(params) loading.value = false } onMounted(() => { getCategories().then(res => categories.value = res) loadGoods() }) watch(() => route.query, loadGoods) </script>商品卡片组件我用了一个比较讨巧的做法:把价格用醒目的颜色和大号字体展示出来,把“产地”和“年份”这两个关键字放在价格下面。这两个字段是茶叶用户在浏览时最关心的核心信息,比放一段又长又绕的商品描述有用得多。
详情页更直接,顶部是商品大图,下面跟着价格、库存、产地、年份、工艺等参数表,再往下是商品描述。我一般会在这个页面加一个“加入购物车”按钮和一个“立即购买”按钮,两个按钮对应同一个接口,只是在加入购物车成功后跳转到购物车页面,立即购买则直接跳提交订单页。
5.3 Pinia状态管理与购物车交互逻辑
购物车的数据属于跨页面共享状态。用户可能在下单页操作,也可能在商品详情页操作,购物车里装了什么商品在多个组件里都要用。这种情况用Pinia做全局状态管理再合适不过。
// frontend/src/stores/cart.js import { defineStore } from 'pinia' import { getCart, addCart, updateCartItem, removeCartItem } from '../api/cart' export const useCartStore = defineStore('cart', { state: () => ({ items: [], totalPrice: 0, count: 0, }), getters: { // 计算总价 calculatedTotal: (state) => { return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0) } }, actions: { async fetchCart() { this.items = await getCart() this.count = this.items.length }, async addItem(teaId, quantity) { await addCart(teaId, quantity) await this.fetchCart() }, async updateQuantity(id, quantity) { await updateCartItem(id, quantity) await this.fetchCart() }, async removeItem(id) { await removeCartItem(id) await this.fetchCart() } } })购物车页面的核心交互有两个:修改数量和删除。修改数量时,前端会调用接口更新后端数据,同时重新拉取购物车列表。这里我踩过一个性能上的坑:不要每次点加减按钮都全量刷新购物车数据。比如用户连续把某个商品数量从1加到5,每加一次就发一次请求,网络开销大,体验也不好。我的做法是:本地先改数量,点击加减后先更新UI,等用户停止操作或离开页面时再统一提交。当然这是优化项,项目初期直接每次都调用接口也问题不大。
5.4 Axios封装与前后端联调问题
前后端联调是开发中最容易卡住的环节,问题通常集中在两个地方:跨域和Token传递。
Axios封装的核心思路是拦截器。请求拦截器负责在每次请求的header里带上token,响应拦截器负责统一处理错误状态码,遇到401就跳登录页。
// frontend/src/utils/request.js import axios from 'axios' import router from '../router' 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 && error.response.status === 401) { localStorage.removeItem('access_token') router.push({ name: 'login' }) } return Promise.reject(error) } ) export default request这里有个开发阶段很常见的坑,就是图片路径问题。Django后端的商品图片是通过ImageField存到本地的,数据库里存的是一个相对路径,比如/media/teas/2024/longjing.jpg。前端Vue项目拿着这个路径直接放到img标签里,会拼成http://localhost:5173/media/...,肯定访问不到。解决方法是给前后端约定一个静态资源访问规则,Vite的简单配置:
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': 'http://127.0.0.1:8000', '/media': 'http://127.0.0.1:8000' } } })通过Vite的代理配置,前端请求/media/xxx时会把请求转发到后端的8000端口,这样图片就能正常显示了。同理,/api路径也通过代理转发,这样在开发阶段前端代码里就不需要写完整的后端地址,统一以/api开头就行。
6. 图片上传、安全加固与收尾部署
6.1 Django图片上传与Media文件配置
商城系统的图片上传是一个绕不开的功能。商品主图、用户头像,都得走上传逻辑。Django的ImageField默认把图片存到本地磁盘,这个机制对中小型项目完全够用。
在settings.py里配置:
# 图片上传目录 MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media')然后在项目的urls.py里加上开发阶段的媒体文件访问路由:
from django.conf import settings from django.conf.urls.static import static urlpatterns = [ # ... 其他路由 ] if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)这里只适用于DEBUG模式,上线后应该由Nginx来提供静态文件和媒体文件的访问,不能让Django处理这些静态资源,性能撑不住。
6.2 商城系统的安全注意事项
安全是电商系统开发中最不能糊弄的部分,但也是初学者最容易忽略的部分。我梳理一下咱这种项目里必须注意的几个点。
密码必须哈希存储。Django的User模型默认使用PBKDF2算法对密码做哈希,create_user方法本身就会处理,你千万不要手动把明文密码存进数据库。验证密码的时候用user.check_password(raw_password),不要自己对比。
JWT令牌的分发和管理。前后端分离架构下,token是用户身份的唯一凭证,泄露了就等于账号拱手让人。前端不要把token放在URL参数里,必须放在Authorization头里。开发阶段可以在localStorage存token,但上线后推荐改成HttpOnly Cookie存储,可以有效降低XSS攻击导致token泄露的风险。
商品接口的越权问题。比如订单接口,用户只能查看自己的订单,不能看到别人的订单。用DRF的get_queryset方法做权限控制是标准做法:
# backend/apps/orders/views.py class OrderListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): orders = Order.objects.filter(user=request.user).prefetch_related('items') serializer = OrderSerializer(orders, many=True) return Response(serializer.data)这个过滤条件至关重要。有些新手会直接写Order.objects.all()然后让前端自己筛选,这在商城系统里属于严重的安全漏洞。
6.3 部署方案的简化选择
部署这块我不想讲得太复杂,因为很多人的项目只是课程设计或练手项目。如果只是课程设计交作业,本地跑起来就算完成。但如果真要公网访问,我的经验是先别急着上Kubernetes、Docker那一套,先把最简单的方式跑通。
最简单的方案是:一台云服务器,Nginx + Gunicorn + Django + Vue打包静态文件。大概流程是:
- Django后端用Gunicorn跑起来,监听8000端口
- Vue项目执行
npm run build,生成dist目录 - Nginx把dist目录作为静态文件服务的根目录,访问
/时返回前端页面 - Nginx把
/api开头的请求反向代理到Gunicorn - 把
/media开头的请求代理到Django或直接指向media目录
Nginx配置的大致样子:
server { listen 80; server_name your_domain.com; # 前端静态文件 root /path/to/frontend/dist; index index.html; location /api { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /media { alias /path/to/backend/media; } # Vue history 模式刷新支持 location / { try_files $uri $uri/ /index.html; } }Nginx配置里最后那个try_files特别关键。Vue用了history模式的路由后,页面路径不是真实文件路径。用户刷新/goods/3这个地址时,Nginx发现没有这个文件,就会返回404。try_files的作用就是找不到文件时回退到index.html,由Vue Router接管路由。这一句不加,上线的商城一刷新就白屏,我当年在这个问题上栽过跟头。
6.4 Django自带Admin后台的使用
说了半天前后端分离,其实后台管理这个事儿有个更省力的方案:直接用Django的Admin后台。
Django Admin几乎是白送的报表后台。你只需要把Model注册进去,就能在线增删改查茶叶商品、管理订单状态、查看用户列表。对于商城运营来说,这个后台比自研管理页面好用太多了。
# backend/apps/goods/admin.py from django.contrib import admin from .models import Category, Tea @admin.register(Tea) class TeaAdmin(admin.ModelAdmin): list_display = ['name', 'category', 'price', 'stock', 'sales', 'is_active'] list_filter = ['category', 'is_active', 'year'] search_fields = ['name', 'origin'] list_editable = ['price', 'stock'] # 列表页直接改价格和库存Admin后台的list_display控制列表显示哪些字段,list_filter生成筛选器,search_fields支持搜索。list_editable让运营人员不用进入详情页就能直接改价格和库存,效率非常高。
我自己做商城项目,后台管理这块的策略是:Admin管数据,自研接口管用户端。这个组合既省力又清晰,后台给内部用,接口给前端用,互不干扰。
7. 常见问题与排查技巧实录
7.1 跨域问题的完整排查流程
前后端分离项目遇到的第一个拦路虎绝对是跨域。浏览器地址是http://localhost:5173,接口地址是http://127.0.0.1:8000,不同端口就算跨域了。
我的排查流程是这样的:
- 先看报错信息,浏览器开发者工具里的Console和Network面板会明确告诉你哪个请求被CORS拦截
- 确认后端是否安装了django-cors-headers,并且加到了INSTALLED_APPS和MIDDLEWARE里
- 确认中间件的位置,CorsMiddleware必须在CommonMiddleware之前
- 开发阶段直接设置CORS_ALLOW_ALL_ORIGINS = True,先把功能跑通
- 上线前把全放开改成白名单模式,只允许你的前端域名访问
中间件的顺序这个问题很隐蔽。Django中间件按列表从上到下执行,如果你把CorsMiddleware放在最后面,它的配置可能不会被先执行的其他中间件所感知。曾经我花了一整晚排查跨域问题,最后发现只是中间件顺序不对。
7.2 Django ORM查询中的高频坑点
ORM查询看起来简单,实际用起来有几个高频坑。
第一个坑是忘记写.all()或者.first()。很多人写Tea.objects.filter(is_active=True)拿到的是一个QuerySet,然后直接序列化传给前端,会报Object of type QuerySet is not JSON serializable。但更隐蔽的情况是,你拿这个QuerySet去模板渲染没事,但用DRF时就必须先经过序列化器处理。
第二个坑是查询今天创建的订单这类时间范围查询。用created_at__date做日期过滤是可行的,但大数据量下性能不好,因为__date会阻止数据库索引生效。正确写法是用range:
from django.utils import timezone today_start = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) today_orders = Order.objects.filter(created_at__gte=today_start)第三个坑是N+1查询问题。比如查询订单列表时,每条订单都要查它关联的订单项,如果不加prefetch_related,ORM会对每条订单分别发一条查询,50条订单就是51条SQL语句。加上prefetch_related('items')之后,查询次数会降到2次。商城列表页这种场景,性能差距非常明显。
7.3 Vue中的典型问题与解决办法
Vue开发遇到的问题更多是细节层面的。我挑了三个典型问题,都是实战中几乎必遇的。
路由跳转但页面不更新。商品列表页在按分类筛选时,虽然路由变化了,但组件实例没有重建,onMounted不会再次触发。解决办法就是用watch监听路由对象的变化,在回调里重新请求数据。
Vue响应式丢失。直接给ref的value重新赋值,数据变了但页面不刷新。这个问题的根源通常是你把响应式对象传给了一个非响应式的地方,然后在里面修改了它的值。比如把reactive对象传给一个普通函数去修改属性,修改操作不会触发响应。用ref和reactive时一定要弄清楚哪些数据是响应式的,新增属性时要用$set或直接替换整个对象。
开发环境的模块缓存。Vite开发模式有热更新,但有时候你改了代码页面没反应,这个问题大概率是因为模块依赖图缓存。重启开发服务器基本能解决。
7.4 排查问题的通用方法论
最后分享一个我调试项目时屡试不爽的方法论。很多新人遇到Bug慌得不行,我的建议是:别急着改代码,先确认问题到底出在哪一层。
前后端分离项目的故障链路是:前端页面 → 接口请求 → 后端逻辑 → 数据库。问题可能出在任意一环。我的排查顺序是:
- 打开浏览器开发者工具的Network面板,看接口到底返回了什么
- 如果接口返回500,直接去Django后端看日志,找到Traceback
- 如果是数据不对,用Django shell手动执行ORM查询,看看是不是数据本身有问题
- 如果前端页面渲染不对,在Vue组件里打印数据,看传给组件的数据结构是否和后端期望的一致
这个方法讲起来简单,但能帮你节省大量无效调试的时间。技术栈再复杂,核心思路永远是“先确认问题在哪个环节,再针对那个环节做深入排查”。
8. 写在最后的一些体会
茶叶商城这个项目从设计到实现,前前后后花了两周左右的时间,中间踩了不少坑,但也把前后端分离开发的全流程走了一遍。我个人做这类全栈项目的体会是:先规划后动手真的很重要,再把一个完整流程从头到尾走通,比浏览十篇教程都有用。
如果后面想继续扩展,有几个方向值得尝试:接入真实支付渠道和退款流程,这个对你的代码能力是个不小的考验;加上用户评论和评分功能,让茶叶的口碑能积累下来;做后台数据统计看板,把销量、热门商品这些数据可视化展示出来。这几个方向各自都很有内容,也能让这个茶叶商城项目在简历上更有含金量。最后提醒一句,做完项目一定要及时写总结记录,过几个月回看你会发现,那些踩坑经验才是你真正的收获。