ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python+Vue在线茶叶销售系统全栈开发实战:从数据建模到前后端联调

Python+Vue在线茶叶销售系统全栈开发实战:从数据建模到前后端联调

做电商类的全栈练手项目,我劝大家别一上来就啃秒杀、双十一这种超大流量场景,先从"小而完整"的商城入手。在这类项目里,Python基于Vue的在线茶叶销售系统是我最推荐复刻的一个:它比图书商城多了一层商品属性的复杂度,又没有真正电商平台那么庞杂,功能边界刚好卡在毕业设计、课程实训和个人作品集最舒服的位置上。后端用Django或Flask都行,前端Vue配合,PyCharm作为开发环境,整套链路从数据库建模到前后端联调全部覆盖,做完之后你对Web全栈的认知会立刻连成一条线。这篇文章我就把整个项目从选型、建模、接口开发到前端落地、联调排错、种子数据准备的完整过程拆开讲,期间穿插我实际带项目时踩过和帮别人解决过的坑。

1. 为什么拿茶叶商城练手:项目背后的完整业务闭环

1.1 电商项目的经典骨架,一个都不少

很多人选课程设计题目时有个误区:觉得"在线茶叶销售系统"听起来太普通,不如做个什么智能推荐、人脸识别来得高级。真上手做起来才会发现,一个电商系统几乎涵盖了Web开发的所有基本功——用户注册登录、商品展示、购物车、订单、支付模拟、后台管理、数据统计。这些模块单独拎出来都不难,但组合在一起,需要你同时处理数据关系、接口设计、状态流转、权限控制,恰恰是真实业务里最值钱的技能组合。

茶叶这个品类本身也很有意思。它不像书只有"一本"和"价格"两个维度,茶叶有分类(绿茶、红茶、乌龙茶、白茶、黑茶、花茶)、产地、采摘季节(明前、雨前、秋茶)、等级(特级、一级、二级)、净含量(50g、100g、250g、500g)等等。这意味着商品表不能只是简单的"名称加价格",你得考虑规格、库存、上下架、销量排序这些真实商城才有的细节。把这些设计清楚,你对"商品中心"这个概念的认知就不再停留在demo层面了。

1.2 技术栈怎么选:Django还是Flask,别纠结太久

标题里同时出现了django和flask,说明这是一道"二选一"的题。很多初学者在选型上能纠结一个礼拜,其实没必要,我给你一个可以直接参考的判断标准。

对比维度Django + DRFFlask + Flask-SQLAlchemy
上手速度稍慢,需要理解框架约定快,路由视图写起来直接
自带功能认证、Admin后台、ORM应有尽有大部分要手动集成
接口开发DRF序列化器+ViewSet效率极高需要自己组织蓝图和序列化
适合场景后台管理需求重的完整系统轻量接口或微服务
踩坑程度坑少,文档全坑分散在多个第三方库

我的个人建议是:如果你打算把这个项目写进简历或者作为毕业设计,优先选Django。理由不是Flask不好,而是Django自带的Admin后台可以直接作为商城的运营管理端,省掉你单独再写一套后台管理的功夫。你只需要把Admin后台配置一下字段、列表页、搜索项,一个能给导师或面试官演示的"后台管理模块"就有了。而Flask适合你已经有其他Web经验、想快速验证一个点子的时候用。

本文后面的代码示例以Django + Django REST Framework为主,Flask版本的差异点我会在对应位置单独提示。开发环境统一用PyCharm,一个IDE搞定Python后端、JS前端、数据库终端和调试器,这在项目联调阶段省下的时间非常可观。

2. 数据模型先行:茶叶商品表的字段是怎么设计的

2.1 商品分类与规格:一款茶叶如何拆成可售卖的商品

我见过不少同学一上来就建一张超级大表,把所有东西塞进去,最后查个列表要关联七八次。这个项目的数据模型最好从"电商标准分类法"出发,拆成四个核心实体:用户、商品、购物车、订单,另外再配一个分类表和一个订单明细表。

先看商品侧。一款茶叶能不能直接作为一条商品记录?可以,但不够专业。比如"明前龙井"至少有50g罐装和250g礼盒装两种规格,价格差好几倍。如果你把规格塞进同一个商品字段,那下单时选规格就会变得很痛苦。正确做法是商品表只放通用属性,规格用SKU表去承接。茶叶商城里最常见的做法是这样:

class TeaCategory(models.Model): name = models.CharField(max_length=50, verbose_name="分类名称", unique=True) parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE, verbose_name="父级分类") sort_order = models.IntegerField(default=0, verbose_name="排序") create_time = models.DateTimeField(auto_now_add=True) class Meta: verbose_name = "茶叶分类" verbose_name_plural = verbose_name def __str__(self): return self.name class TeaProduct(models.Model): name = models.CharField(max_length=200, verbose_name="商品名称") category = models.ForeignKey(TeaCategory, on_delete=models.PROTECT, verbose_name="所属分类") origin = models.CharField(max_length=100, blank=True, verbose_name="产地") harvest_season = models.CharField(max_length=50, blank=True, verbose_name="采摘季节") grade = models.CharField(max_length=50, blank=True, verbose_name="等级") description = models.TextField(blank=True, verbose_name="商品详情") main_image = models.ImageField(upload_to="tea/", blank=True, verbose_name="主图") is_on_sale = models.BooleanField(default=True, verbose_name="是否上架") sales_count = models.IntegerField(default=0, verbose_name="销量") create_time = models.DateTimeField(auto_now_add=True) class Meta: verbose_name = "茶叶商品" verbose_name_plural = verbose_name class TeaSku(models.Model): product = models.ForeignKey(TeaProduct, on_delete=models.CASCADE, related_name="skus", verbose_name="所属商品") spec_name = models.CharField(max_length=50, verbose_name="规格名称", help_text="例如:50g罐装 / 250g礼盒") price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="售价") original_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="原价") stock = models.IntegerField(default=0, verbose_name="库存") sku_code = models.CharField(max_length=50, unique=True, verbose_name="SKU编码")

这里有一个容易被忽略的点:外键约束。分类表用on_delete=models.PROTECT,意思是如果还有商品挂在某个分类下,这个分类不允许被直接删除。真实运营场景里误删分类会牵连所有商品,这个保护非常关键。SKU表用on_delete=models.CASCADE加related_name="skus",商品删掉时规格一并清掉,反查时直接product.skus.all()就能拿到全部规格,序列化时也方便。

2.2 用户、购物车与订单:状态流转是电商的地基

用户表我建议直接继承Django自带的AbstractUser并扩展一个手机号字段,不要自己从零写用户认证。自定义用户模型一定要在第一次migrate之前就配好AUTH_USER_MODEL,这可以说是这个项目里最"改晚了就想哭"的配置,后面前后端联调阶段我会再强调一遍。

购物车有两种设计思路:一种是把购物车数据存到Redis,过期自动清理;另一种就是普通关联表。课程设计项目里我推荐后者,逻辑直观、可演示、还能存关联数据。购物车表的关键字段是user、sku和quantity,再加一个selected布尔字段,用来标记勾选状态,方便前端在结算页做"批量选中"。

订单状态是整个系统里最能体现业务理解的部分。茶叶商城的订单至少要有下面这条状态链:

状态含义触发动作
待付款订单已创建提交订单
待发货已付款,模拟支付成功用户支付/模拟支付回调
已发货商家操作发货后台填写物流单号
已完成用户确认收货用户确认或超时自动完成
已取消用户取消或超时未付款用户操作/定时任务
退款中/已退款售后场景用户申请、商家审核

订单主表存金额、订单号、收货信息、状态;订单明细表存每一件商品的快照——注意是"快照",不是引用SKU。为什么?因为SKU的价格和名称是可能变的,但用户下单那一刻买的是什么必须永远定格。这个设计是很多初级项目里没有的,但面试官一问"订单里存商品ID还是存快照"就能区分出你有没有真实业务的意识。

2.3 代码落地:Django模型定义

订单相关的模型我直接给出可运行的结构:

from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): mobile = models.CharField(max_length=11, unique=True, null=True, blank=True, verbose_name="手机号") avatar = models.ImageField(upload_to="avatar/", blank=True, verbose_name="头像") class Meta: verbose_name = "用户" verbose_name_plural = verbose_name class CartItem(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="cart_items", verbose_name="用户") sku = models.ForeignKey(TeaSku, on_delete=models.CASCADE, verbose_name="商品SKU") quantity = models.IntegerField(default=1, verbose_name="数量") selected = models.BooleanField(default=True, verbose_name="是否勾选") class Meta: unique_together = ("user", "sku") verbose_name = "购物车项" class Order(models.Model): ORDER_STATUS = ( ("pending", "待付款"), ("paid", "待发货"), ("shipped", "已发货"), ("completed", "已完成"), ("cancelled", "已取消"), ) order_no = models.CharField(max_length=32, unique=True, verbose_name="订单号") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="orders", verbose_name="下单用户") total_amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="订单总金额") status = models.CharField(max_length=20, choices=ORDER_STATUS, default="pending", verbose_name="订单状态") receiver_name = models.CharField(max_length=50, verbose_name="收货人") receiver_phone = models.CharField(max_length=11, verbose_name="联系电话") receiver_address = models.CharField(max_length=255, verbose_name="收货地址") remark = models.CharField(max_length=255, blank=True, verbose_name="用户备注") create_time = models.DateTimeField(auto_now_add=True, verbose_name="下单时间") pay_time = models.DateTimeField(null=True, blank=True, verbose_name="支付时间") class OrderItem(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items", verbose_name="所属订单") product_name = models.CharField(max_length=200, verbose_name="商品名称快照") spec_name = models.CharField(max_length=50, verbose_name="规格快照") price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="成交单价快照") quantity = models.IntegerField(default=1, verbose_name="购买数量") image = models.CharField(max_length=255, blank=True, verbose_name="主图快照")

订单号和购物车唯一约束这两处值得多说一句。订单号不要用自增ID直接暴露给用户,因为那样别人能通过ID号推算你的订单量。推荐格式是"日期时间+随机数",比如20250610143012345678,在save()方法里生成或者下单服务里生成都行。购物车表加unique_together之后,同一个人加同一件商品时会触发冲突,接口层要捕获这个情况并改为"数量+1",而不是报错给前端。

3. 后端接口开发:DRF把业务变成一套可用的API

3.1 先定接口文档,再写ViewSet

我在写任何接口前都会先用手写的方式把URL清单列出来,这一步看起来原始,但能让你避免"接口写着写着就加字段"的混乱。茶叶商城最小可用集大概是这些:

功能模块接口路径方法说明
用户认证/api/auth/register/POST注册
用户认证/api/auth/login/POST登录,返回JWT
商品模块/api/products/GET商品分页列表,支持分类、关键词筛选
商品模块/api/products/{id}/GET商品详情,含SKU列表
购物车/api/cart/GET/POST查看购物车 / 加入商品
购物车/api/cart/{id}/PUT/DELETE修改数量 / 删除
订单模块/api/orders/POST从购物车勾选项创建订单
订单模块/api/orders/GET当前用户订单列表
后台管理Django Admin-商品/订单/用户运营管理

在DRF里,商品列表这种典型接口用ModelViewSet几行就能搞定,但真正要花心思的是序列化器的设计。商品列表页和详情页需要的字段完全不同——列表只要名称、主图、最低价、销量;详情页要把描述、规格、产地都带出来。所以不要一个序列化器通吃,而是建两个序列化器,列表用轻量版,详情用完整版,然后通过get_serializer_class来切换。

from rest_framework import serializers from rest_framework.viewsets import ModelViewSet from rest_framework.permissions import IsAuthenticatedOrReadOnly class SkuSerializer(serializers.ModelSerializer): class Meta: model = TeaSku fields = ["id", "spec_name", "price", "original_price", "stock"] class ProductListSerializer(serializers.ModelSerializer): min_price = serializers.DecimalField(max_digits=10, decimal_places=2, read_only=True) category_name = serializers.CharField(source="category.name", read_only=True) class Meta: model = TeaProduct fields = ["id", "name", "main_image", "min_price", "sales_count", "category_name"] class ProductDetailSerializer(serializers.ModelSerializer): skus = SkuSerializer(many=True, read_only=True) category_name = serializers.CharField(source="category.name", read_only=True) class Meta: model = TeaProduct fields = ["id", "name", "origin", "harvest_season", "grade", "description", "main_image", "skus", "category_name"]

列表里的"最低价"怎么算?DRF里可以用annotate配合聚合函数,在视图集里给查询集加上一个Min("skus__price")字段。这一步很经典:

from django.db.models import F, Min from rest_framework import viewsets class ProductViewSet(viewsets.ModelViewSet): queryset = TeaProduct.objects.filter(is_on_sale=True).annotate( min_price=Min("skus__price") ).order_by("-create_time") permission_classes = [IsAuthenticatedOrReadOnly] def get_serializer_class(self): if self.action == "list": return ProductListSerializer return ProductDetailSerializer def get_queryset(self): qs = super().get_queryset() category_id = self.request.query_params.get("category") keyword = self.request.query_params.get("keyword") if category_id: qs = qs.filter(category_id=category_id) if keyword: qs = qs.filter(name__icontains=keyword) return qs

这里有个业务上的取舍:茶叶商城的销量排序用sales_count字段而不是实时从订单里聚合,因为每次列列表都去聚合订单明细,数据库压力会很大。真实系统都是"下单成功后销量加一"的冗余维护,展示性能好得多。

3.2 登录鉴权:JWT接入的细节

前端是Vue单页应用,登录态不能依赖Django的Session,直接用JWT最合适。Django这边用djangorestframework-simplejwt这个库,接入非常简单。但你需要注意三个地方:

第一个是自定义用户模型之后,simplejwt的TokenObtainPairView会用username字段登录,而你想让用户支持手机号或用户名都可以登录,那就不能直接用默认视图,要写一个继承TokenObtainPairSerializer的子类,把validate方法改一下。

第二个是前端拿到access和refresh两个token之后,access有效期默认只有5分钟,如果前端不做刷新处理,用户逛着逛着突然接口401,体验会非常糟糕。课程项目里我建议把SIMPLE_JWT配置里的ACCESS_TOKEN_LIFETIME调成60分钟,避免频繁刷新token引入额外复杂度。

第三个是跨域携带token的问题。前端用axios发请求头,名称默认是Authorization,格式是Bearer xxx,这个大小写和空格一个都不能错。我在联调阶段帮别人排查过好几次401,最后发现都是前端把Bearer拼成了bearer。

3.3 商品列表的分页、搜索与排序

DRF默认的分页类返回的是{count, next, previous, results}这个结构,Vue前端拿results渲染,拿count算总页数,已经够用。但如果你想做得更像真实商城,可以在视图集里加一个排序参数。茶叶商城的排序一般就三档:综合排序、按价格从低到高、按销量。实现起来就是在filter_queryset后面根据参数动态order_by:

sort = self.request.query_params.get("sort", "default") if sort == "price_asc": qs = qs.order_by("min_price") elif sort == "sales": qs = qs.order_by("-sales_count")

要注意的是,annotate生成的min_price可以直接用在order_by里,这一手DRF是支持的,不用再写子查询。前端页面只需要把当前选中的排序项存到data里,请求时带sort参数即可。

4. Vue前端页面:让茶叶商城真正"能看能用"

4.1 工程初始化与路由设计

前端我用Vue 3搭配Vite初始化,UI库用Element Plus,状态管理用Pinia。初始化命令很简单,但真正决定项目体验的是路由和目录规划。茶叶商城的前端路由建议这样分:

  • /首页,展示轮播图和热销茶叶
  • /products商品列表页,支持分类筛选
  • /products/:id商品详情页,含规格选择和加购按钮
  • /cart购物车页
  • /checkout结算页,填写收货地址并提交订单
  • /orders订单列表页

这里有一个前端新手很容易忽略的点:详情页用动态路由/products/:id进入之后,同一个组件会被复用,如果用户从A商品跳到B商品,Vue不会重新执行onMounted,数据不会自动刷新。解决办法是对路由参数做监听,参数变了就重新请求接口。这个坑几乎每个做商城的人都会遇到,属于"不写到第五个页面根本发现不了"的问题。

4.2 商品列表与详情:图片和规格的处理

商品列表页的结构不复杂:左边是分类侧边栏,右边是商品卡片网格。每个卡片显示主图、名称、最低价和销量。难点在两点:一是图片字段,Django的ImageField在开发模式下返回的是相对路径,前端拼接时要区分当前环境用的是本地静态文件还是线上URL,我建议在后端序列化器里用一个request.build_absolute_uri(main_image.url)把它转成完整地址。

二是价格显示。前面说过SKU才有价格,商品表没有价格字段,所以列表页只能显示"最低价"。前端拿到min_price后直接展示"起",点击进入详情页再展示所有规格价格。茶叶详情页的规格选择交互也很常见:把规格列表渲染成一组可点击的标签,选中后显示对应规格的价格和库存。这个交互在Element Plus里没有现成组件,自己写十几行就能搞定,但注意选中状态必须在切换时清掉,不然会出现"选了50g罐装,再选250g礼盒时价格还是50g的"这种低级bug。

加购按钮的处理逻辑是:先判断登录态,没登录就跳转登录页,登录了才调用POST /api/cart/传sku_id和quantity。很多同学的购物车系统"做成了摆设",就是因为没有做登录态拦截,加购接口一直401还找不到原因。商品列表卡片上我也建议直接显示"库存紧张"或"已售罄"的角标,数据来源就是SKU的stock字段,这比单纯显示一个灰色按钮要真实得多。

4.3 购物车与下单:状态管理用Pinia还是本地Storage

购物车数据天然适合放在Pinia里,因为多个页面要共享"购物车里有几件商品"这个小角标。但要注意:刷新页面后Pinia会清空。所以标准做法是Pinia存状态,同时把购物车接口的数据来源设为后端——页面加载时请求一次GET /api/cart/,把结果灌进Store。这样刷新之后角标还能恢复,而且不同设备登录也能看到一致的购物车,这才是真实商城的逻辑。

下单流程是我觉得整个前端最有含金量的部分,涉及三个页面之间的数据传递:购物车页勾选SKU → 结算页展示合计金额并填地址 → 提交订单成功后跳转订单列表。这里有个关键点:金额计算一律在后端订单接口里做,前端传上来的只是SKU ID列表。前端显示的总价只能作为展示,不能作为最终数据。下单接口收到SKU列表后,重新从数据库查价格、算合计、扣库存、生成订单号,这一套流程才是安全的。很多初级项目让前端把总价传过来,后端直接存进去,这在安全上完全不合格。

5. 前后端联调:跨域、字段与金额,最容易翻车的三个地方

5.1 跨域:一次性的配置

前后端分离项目,前端跑在5173端口,后端Django跑在8000端口,两个端口不同就产生了跨域。Django处理跨域最简单的方式是装django-cors-headers,在INSTALLED_APPS和MIDDLEWARE里注册,然后配置白名单:

CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", "http://127.0.0.1:5173", ] CORS_ALLOW_CREDENTIALS = True

注意:不要把CORS_ALLOW_ALL_ORIGINS设成True,虽然能快速解决联调报错,但上线后等于允许任何网站跨域请求你的接口,配合JWT越权就会出大问题。开发环境严格列出本地地址就好,前后端都在本机,加这一条配置几乎零成本。

5.2 字段格式:前端要驼峰,后端给下划线

Django模型的字段默认是下划线风格,比如create_time、main_image;而前端JavaScript的习惯是驼峰,比如createTime、mainImage。联调时前端拿到create_time不能直接用,还要写一堆映射。解决思路是前后端约定一个规范:接口层统一用小写下划线,前端不做驼峰转换,在axios封装里或者组件里直接使用后端原始字段名。真相是如果每篇文章都做驼峰转换,代码会多出几十行无意义的东西。当然如果你很喜欢驼峰,可以在DRF序列化器里用source字段映射,但这属于增加维护成本的选择。

5.3 金额与精度:一律用分存储

茶叶的价格从几十到几千都有,涉及小数点后两位。Django的DecimalField能存精确小数,但如果前后端传值时不注意,还是会出现0.1 + 0.2 !== 0.3这种经典问题。我的建议是:数据库和接口层统一用Decimal类型,前端显示时用toFixed(2)格式化;如果涉及多个SKU合计,前端只展示后端算好的total_amount,不要在JS里自己加一遍。更稳妥的方案是金额以"分"为单位存储为整数,展示时再除以100,这样能彻底避开浮点误差。课程项目用DecimalField已经够用,但如果以后做支付对接,你就会发现支付平台的金额参数通常就是整数分。

联调阶段最值得花时间的是一张"问题登记表"。我习惯在项目初期就建一个表格,记录接口路径、请求参数示例、返回示例、前端对应的调用位置。联调时凡是出现字段对不上,就在表里更新,这样两边改起来都有据可依,不会出现"后端改了前端不知道"的尴尬。

6. 种子数据与本地启动:让演示项目一键跑起来

6.1 造一批像样的茶叶数据

项目能不能打动面试官或评委,很大程度取决于演示数据够不够真实。我见过太多项目里商品名叫"商品1""商品2",这种一眼就是凑数的。茶叶商城的种子数据建议真实还原常见品类:绿茶放龙井、碧螺春、毛尖;红茶放正山小种、金骏眉、祁门红茶;乌龙茶放铁观音、大红袍、凤凰单丛;白茶放白毫银针、白牡丹、寿眉;黑茶放普洱熟茶、安化黑茶;花茶放茉莉花茶、桂花龙井。

每款茶叶配2-3个规格,价格要有梯度,比如50g罐装和250g礼盒装。销量、库存、产地、采摘季节这些字段也要填满。用Django的manage.py shell或者写一个seed.py脚本批量插入,每次重新建库后跑一遍就恢复演示状态。我在脚本里会先清库再写入,保证幂等性:

def run(): TeaProduct.objects.all().delete() # 按上面的清单逐条创建商品和SKU

图片是另一个问题。真实茶叶图片大家可能没有版权,我的建议是用占位图或者免费图库的图片,前期开发阶段甚至可以直接用一张本地生成的纯色图。图片挂在商品上,商品列表才能在页面上"看起来像个商城"。某图像处理Demo的做法是先统一放一张默认图,后面运营同事有素材了再替换,开发节奏完全不受影响。

6.2 PyCharm里的启动配置

PyCharm用起来最关键的两个配置就是解释器和运行环境。项目根目录下创建虚拟环境,然后安装requirements.txt。这里提醒一句:requirements.txt要有版本号,不要空写Django、djangorestframework,至少要写成Django==4.2.7这种形式。不同大版本的Django在模型和URL配置上有差异,没有固定版本会导致别人克隆你代码后根本跑不起来。

启动Django后端时,PyCharm的Run Configuration里选择Python,Script路径指向manage.py,参数填runserver 0.0.0.0:8000。前端Vue项目在PyCharm的Terminal里执行npm run dev即可。两个进程同时在IDE里跑,调试时前端报错、后端报错都能在同一个窗口看到,这就是我推荐PyCharm而非分开用两个编辑器的原因。

首次建库和创建超级管理员的流程,我一般按这个顺序执行:

  1. pip install -r requirements.txt
  2. python manage.py makemigrations
  3. python manage.py migrate
  4. python manage.py createsuperuser
  5. python manage.py runserver

进入Django Admin后,把分类、商品、SKU都建好,再登录前端页面测试。数据库我用MySQL,配置在settings.py里注意编码要设成utf8mb4,不然存茶叶详情里的生僻字或者特殊符号可能报错。为了让第一次跑的人减少依赖,这个项目也可以先用SQLite跑通,再把数据库切换成MySQL,这种渐进式的方式在课程设计里非常实用。

7. 复盘与扩展:做完基础版之后还能往哪加

7.1 做项目时积累的几个通用教训

走完这一整套"Python后端 + Vue前端"的茶叶商城开发,有几个教训是通用的,值得单独记下来:

第一,数据库模型决定了项目的天花板。我见过一个同学在购物车表里没有加唯一约束,结果用户反复点击加购按钮生成了多条重复购物车记录,前端列表越拉越长。这种问题在建模阶段就可以避免,所以每张表在创建前都要问自己:这表有没有唯一性约束?有没有快照字段?删除时是保护还是级联?

第二,任何涉及钱的逻辑后端都要重算。前端传过来的价格、数量、总价都是"不可信输入"。下单接口必须重新查库、核算金额、校验库存,做完之后还要用事务包裹。Django里用transaction.atomic(),在库存扣减和订单创建之间做原子性保护,防止"订单建好了库存没扣成"的脏数据。

第三,联调阶段是最容易失控的阶段。前端和后端各改各的,接口文档一旦滞后,两边就互相甩锅。我的习惯是无论用什么框架,先跑通一个最小闭环——从注册登录到查看一个商品——再继续做其他模块。最小闭环跑通的意义是确认整个握手链路没有问题,后面所有接口只是在这个链路上复制粘贴。

第四,演示项目也要有权限和异常的自觉。哪怕是个课程设计,前端路由也要做登录守卫,后端视图也要区分匿名用户和登录用户。茶叶商城里未登录可以看商品,但不能加购、不能下单,这个规则用DRF的权限类IsAuthenticatedOrReadOnly一行就能实现,却能让你的项目在答辩时经得起追问。

7.2 进阶方向

基础版做完后,如果你时间还充裕,有四个方向可以继续扩展,价值从高到低排列。

第一个方向是支付模拟与异步回调。很多商城项目做到"提交订单"就结束了,更像一个订单记录系统。你可以加一个模拟支付页,下单后跳转支付页点"确认支付",后端把钱和状态更新完成,同时用日志模拟支付回调,这会让整个闭环彻底完整。第二个方向是后台数据统计。Django Admin里加一个简单的自定义视图,展示每日销售额、销量Top10茶叶,用表格或者简单的柱状图呈现,瞬间让项目有了"运营后台"的味道。

第三个方向是评论系统。已完成的订单里可以对商品评分和写文字评论,评论出现在商品详情页。这个功能看似简单,但涉及订单状态校验、评分建模、评论列表分页,做完之后你的数据表又多了一种关联关系。第四个方向是搜索优化,用__icontains做关键词模糊搜索已经能用,但如果商品详情是富文本,再加一个全文搜索功能就会让列表接口响应更快,具体可以用MySQL内置的全文索引,也可以引入专门的搜索服务,但课程项目做到前两步就够了,不必上太重的组件。

我个人在做类似项目时还有一个体会:代码里尽量少写死数据,多用配置和常量。茶叶商城的订单状态、分类层级、SKU规格,这些在演示时很容易被问"如果业务调整怎么办"。你如果在代码里定义成枚举或常量表,能当场演示修改的效果,说服力比单纯口述强得多。开发这件事,很多时候拼的就是这些细节的累计。

做完这个茶叶商城,你会发现自己对"一个系统怎么从零长出来"有了具体的感觉:先建表、再写接口、再画页面、再做联调,每一步都像搭积木,每块积木都有它必须存在的原因。拿它去面试也好,做课程设计也好,至少你能在面试官面前把全链路讲清楚——这部分价值,才是这个项目真正值钱的地方。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进