Django构建陶瓷商城:从SPU/SKU建模到订单闭环的实战设计

发布时间:2026/10/12 2:45:29
Django构建陶瓷商城:从SPU/SKU建模到订单闭环的实战设计 1. 先谈业务陶瓷商品的特殊性如何决定系统设计1.1 陶瓷品类对商城系统的三个特殊要求很多人看到陶瓷销售商城这个题目第一反应是这不就是套个现成的电商系统换换图、改改标题就能上线吗我第一次接这个需求的时候也这么想但真正做下去才发现陶瓷这个品类对系统的要求比大多数标准电商方案能承受的要大得多。第一个特殊要求是商品属性的离散性。工业标品的SKU可以精确到颜色、尺寸、型号同一批几百上千件没有任何区别。但陶瓷不一样尤其是有手工成分的产品釉色的深浅、口径的大小、容量的高低每件之间都可能存在差异。同一个窑次烧出来的同款杯子窑位不同釉面效果都会不一样。这意味着商品规格没办法只用几个固定字段撑住数据模型要有足够的弹性去存住这件东西的独特性。第二个特殊要求是库存的非连续性。流水线产品卖完可以随时补货手工陶瓷只能等下一窑。很多小众窑口一个月才烧一次一窑能出的成品数量也有限。如果后台只显示一个库存数字运营完全没法判断缺货之后还要等多久。所以在实际建模时我建议在库存上记录批次或窑次信息哪怕只是一个字符串字段对运营的帮助也非常大。第三个特殊要求是售后压力。陶瓷易碎物流破损率比服装、数码产品高一个量级退换货是高频场景。订单状态机如果不在设计之初就考虑退款、补发、拒收这些分支等上线以后遇到第一波破损投诉代码会改得非常痛苦。1.2 一个陶瓷商城必须承载的业务闭环基于上面这三点我把这个平台的业务拆成了四个核心闭环商品闭环分类浏览、SPU展示、SKU规格选择、详情介绍、搜索筛选。交易闭环购物车、下单、锁库存、支付回调、订单查询。履约闭环发货、物流跟踪、签收、退款、售后。运营闭环商品上下架、批量改价、库存预警、活动推荐、销售统计。这四条闭环里商品闭环和交易闭环是开发量最大的履约闭环是资金风险最高的运营闭环决定了平台上线后能不能高效运转。下面的内容就按这条主线把我实际做这个项目时的设计和踩过的坑展开讲。2. 技术选型与工程初始化Django、数据库与缓存的落地选择2.1 Python加Django的组合到底赢在哪技术选型阶段我的判断标准只有一条这个项目是要交付运营的不是做技术实验所以要选什么都能干、踩坑成本最低的组合。Python加Django正好符合。Django自带ORM、模板引擎、Admin后台、表单处理、认证系统和Session管理这些能力恰好是交易系统的地基。尤其是Admin后台开发期直接拿给运营录测试商品能省出好几天搭后台的时间。如果换Flask用户注册登录、后台管理、CSRF防护这些都要自己拼短期内看起来轻巧项目一长就会变成一堆半成品的缝合。FastAPI适合纯API服务和高并发异步场景但陶瓷商城这种偏内容展示、需要SEO友好页面的项目用Django的服务端渲染再配合必要的前端增强性价比高得多。如果你坚持前后端分离Django加Django REST FrameworkDRF也没有问题。DRF的序列化器、视图集、权限控制都是现成的和Django的ORM配合得很顺。2.2 版本、数据库与Redis的选型细节我的实际选择是Django 4.2 LTS加Python 3.11。选LTS版本的原因很直接Django大版本升级经常带破坏性变更生产环境冒不起这个险。4.2的官方维护周期覆盖到2026年对一个商城系统来说足够了。数据库用MySQL 8.0而且从开发第一天就连接MySQL不碰SQLite。这是很多项目的大坑开发时用SQLite风平浪静部署到MySQL后各种约束行为不一致排查起来极其痛苦。既然上线迟早要换不如一开始就统一。Redis在这个项目里承担两类职责第一是商品列表页和详情页的缓存第二是配合Celery做订单超时关闭、支付回调处理这些异步任务。商城页面的访问热点非常集中一天里90%的请求可能都打在首页和商品详情页上这两个页面不缓存的话数据库很快会成为瓶颈。2.3 初始化阶段最容易翻车的三个配置Django项目创建完先把settings里的时区和语言改掉这是第一件事。LANGUAGE_CODE zh-hans TIME_ZONE Asia/Shanghai USE_I18N True USE_TZ True新同学最容易漏的是USE_TZ。默认情况下Django是启用时区的所有模型里的DateTimeField存进MySQL的都是UTC时间展示时如果不转换订单创建时间就会比真实时间慢八小时。Django模板里自带的timezone过滤器可以解决关键是心里要有这根弦。第二件事是MySQL连接的字符集。建库时一定要指定utf8mb4连接时也要显式传charset否则商品描述里出现生僻字、特殊符号会直接写入失败。DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: ceramic_mall, USER: mall_user, PASSWORD: your_db_password, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, }, } }注意这类密码不要直接写死在代码里用环境变量读取避免误提交到Git仓库。第三件事是媒体文件和静态文件的目录分开商品图片属于上传的媒体文件不能和静态资源混在一起否则上线时静态文件的收集策略会让你头疼。3. 商品中心建模SPU、SKU与库存扣减的核心设计3.1 先分清SPU和SKU再写模型这是整个项目最关键的一步。很多人建商品表一上来就一个表管所有几百个字段堆在一起改一次痛一次。标准做法是把商品拆成两层SPU是款SKU是具体可卖的商品单元。举个例子页面展示的龙泉青瓷手作主人杯是SPU而粉青釉·300ml·直筒款是SKU。顾客在详情页选择规格选中的每一个组合都对应一个SKU价格和库存挂在SKU上SPU只负责承载公共信息。有一种错误做法是把规格信息拆成一张规格名-规格值表然后用代码去拼。这种方案在早期电商系统里很常见但对陶瓷品类来说过于复杂。我建议用JSONField来存规格属性比如{釉色: 粉青, 容量: 300ml}够灵活改动规格不需要做数据库迁移。代价是没法在数据库层面对规格做精细过滤但实际的搜索需求通常落在分类和名称上影响不大。3.2 分类、商品、SKU的落地代码分类模型先搭好支持二级分类就够了陶瓷品类一般不会超过三层class Category(models.Model): name models.CharField(分类名称, max_length50) slug models.SlugField(URL标识, max_length80, uniqueTrue) parent models.ForeignKey( self, verbose_name父级分类, nullTrue, blankTrue, on_deletemodels.CASCADE, related_namechildren ) sort_order models.IntegerField(排序权重, default0) class Meta: ordering [sort_order, id] verbose_name 商品分类 verbose_name_plural verbose_name商品SPU模型要特别注意状态字段我习惯用IntegerChoices而不是字符串后面做批量上下架操作时非常方便class Product(models.Model): class Status(models.IntegerChoices): DRAFT 0, 草稿 ON_SALE 1, 在售 OFF_SALE 2, 下架 name models.CharField(商品名称, max_length200) subtitle models.CharField(商品副标题, max_length300, blankTrue) category models.ForeignKey(Category, on_deletemodels.PROTECT, verbose_name商品分类) cover models.ImageField(封面图, upload_toproducts/cover/%Y/%m/) gallery models.JSONField(图集, defaultlist, blankTrue) detail models.TextField(详情描述, blankTrue) status models.SmallIntegerField(状态, choicesStatus.choices, defaultStatus.DRAFT) is_featured models.BooleanField(首页推荐, defaultFalse) created_at models.DateTimeField(创建时间, auto_now_addTrue) updated_at models.DateTimeField(更新时间, auto_nowTrue) class Meta: ordering [-created_at] verbose_name 商品 verbose_name_plural verbose_nameSKU是价格和库存的载体class Sku(models.Model): product models.ForeignKey(Product, on_deletemodels.CASCADE, related_nameskus) name models.CharField(SKU名称, max_length200) spec models.JSONField(规格属性, defaultdict) sku_code models.CharField(SKU编码, max_length50, uniqueTrue) price models.DecimalField(售价, max_digits10, decimal_places2) origin_price models.DecimalField(划线价, max_digits10, decimal_places2, nullTrue, blankTrue) stock models.IntegerField(可售库存, default0) locked_stock models.IntegerField(锁定库存, default0) is_active models.BooleanField(启用状态, defaultTrue) sort_order models.IntegerField(排序, default0) batch_note models.CharField(批次/窑次备注, max_length200, blankTrue) class Meta: ordering [sort_order, id] verbose_name SKU verbose_name_plural verbose_name这里我特意加了batch_note字段就是前面提到的批次/窑次备注。手工陶瓷补货不连续运营需要知道这批卖完下批从哪里来这个字段能省掉很多沟通成本。3.3 stock与locked_stock双库存设计的业务逻辑库存字段我拆成了两个stock代表可售库存locked_stock代表已被订单锁定但还没发货的库存。这套设计解决的是下单后库存被占用的问题。时间线是这样的用户下单成功系统把stock减一、locked_stock加一支付成功不做库存变动仓库发货时locked_stock减一如果订单超时取消或用户主动取消locked_stock减一、stock加一把库存还给可售池。这么做的好处是后台任何时候都能看到一个准确信号locked_stock高说明有大量订单待发货仓库要赶紧处理locked_stock长期居高不下就要排查是不是有支付成功的订单卡住了。扣库存的代码一定要防超卖。我推荐用带条件的原子更新而不是先查再改from django.db.models import F from django.db import transaction def lock_sku_stock(sku_id, quantity): updated Sku.objects.filter(pksku_id, stock__gtequantity).update( stockF(stock) - quantity, locked_stockF(locked_stock) quantity, ) if updated 0: raise ValueError(库存不足锁定失败)这里的关键是filter里的stock__gtequantity条件UPDATE语句在数据库层面判断库存足够才执行天然避免并发超卖。如果写成先select再update两个人同时下单就可能把最后一个库存都买走。注意恢复库存时也要用同样的原子更新千万别用读出来改完再存回去的写法。4. 交易链路实现购物车、下单和支付回调的闭环4.1 购物车匿名会话和登录用户怎么合并购物车的实现方案有Session和数据库两种。Session的优点是匿名用户可以先用缺点是无法跨设备数据库的优点是登录后永久保存缺点是没登录的用户没有载体。我的做法是同时支持。CartItem表里user字段和session_key字段都保留匿名用户购物车挂在session_key上登录之后把当前session_key下的购物车条目迁移到user名下再和user名下的旧条目做合并。迁移逻辑里要注意相同SKU的条目只合并数量不要产生重复行。class CartItem(models.Model): user models.ForeignKey( User, on_deletemodels.CASCADE, nullTrue, blankTrue, related_namecart_items ) session_key models.CharField(max_length64, blankTrue, db_indexTrue) sku models.ForeignKey(Sku, on_deletemodels.CASCADE) quantity models.PositiveIntegerField(default1) checked models.BooleanField(是否勾选, defaultTrue) created_at models.DateTimeField(auto_now_addTrue)登录转换的逻辑写在登录回调里做完迁移再跳转。有一个易错点用户设备上没有session_key但是购物车表里却残留着session数据这种孤儿数据要定期清理不然表会无限膨胀。4.2 下单流程锁库存和建订单必须在同一个事务里下单的完整流程我整理成四步读取购物车中勾选的商品校验SKU是否有效、库存是否足够。生成订单号计算总价创建Order主表和OrderItem明细。在同一个数据库事务里锁定库存也就是调用前面写的lock_sku_stock。事务提交后跳转第三方支付平台发起支付。这里最核心的原则是订单和库存锁定必须同生共死任何一个失败都要整体回滚。transaction.atomic def create_order(user, cart_item_ids, address): items CartItem.objects.select_related(sku).filter( id__incart_item_ids, checkedTrue ) if not items.exists(): raise ValueError(没有选中的商品) order_no generate_order_no() order Order.objects.create( order_noorder_no, useruser, total_amountcalculate_total(items), ) for item in items: lock_sku_stock(item.sku_id, item.quantity) OrderItem.objects.create( orderorder, skuitem.sku, product_nameitem.sku.product.name, sku_nameitem.sku.name, priceitem.sku.price, quantityitem.quantity, subtotalitem.sku.price * item.quantity, ) items.delete() return order订单表的关键字段如下address字段建议直接快照收货人信息不要关联地址表的主键否则用户后面改地址会影响历史订单class Order(models.Model): STATUS_PENDING_PAYMENT 1 STATUS_PAID 2 STATUS_SHIPPED 3 STATUS_DELIVERED 4 STATUS_FINISHED 5 STATUS_CANCELLED 6 STATUS_REFUNDING 7 STATUS_REFUNDED 8 STATUS_CLOSED 9 order_no models.CharField(订单号, max_length32, uniqueTrue) user models.ForeignKey(User, on_deletemodels.PROTECT) status models.SmallIntegerField(订单状态, choices..., defaultSTATUS_PENDING_PAYMENT) total_amount models.DecimalField(订单总额, max_digits10, decimal_places2) receiver_name models.CharField(收货人, max_length50) receiver_phone models.CharField(联系电话, max_length20) receiver_address models.CharField(收货地址, max_length200) payment_no models.CharField(第三方支付流水号, max_length64, blankTrue) created_at models.DateTimeField(auto_now_addTrue) paid_at models.DateTimeField(nullTrue, blankTrue)很多项目会在这一步忘记一件事下单之后购物车里的商品应该被清掉但用户如果中途支付失败购物车也已经被清了体验很差。我的处理是创建订单成功后把已下单的商品从购物车移除同时在前端提示用户订单已生成若未支付可在订单列表继续支付。4.3 支付回调幂等性是资金安全的关键支付回调是整个交易链路里最容易出资金事故的地方。核心原则是回调处理函数必须幂等重复通知和并发通知都不能影响最终结果。我处理第三方支付平台异步通知的逻辑是验签先用平台公钥验证通知签名验签失败直接拒绝。幂等检查用订单号查出Order如果订单已经是已支付状态且payment_no一致直接返回成功不做任何更新。加锁更新在事务中select_for_update锁定订单把状态改为已支付记录支付流水号写入paid_at。返回成功通知第三方支付平台已处理成功避免平台反复重推。transaction.atomic def handle_payment_notify(order_no, payment_no, amount): order Order.objects.select_for_update().get(order_noorder_no) if order.status Order.STATUS_PAID and order.payment_no payment_no: return True if order.status ! Order.STATUS_PENDING_PAYMENT: return False order.status Order.STATUS_PAID order.payment_no payment_no order.paid_at timezone.now() order.save(update_fields[status, payment_no, paid_at]) return True这里的select_for_update保证了两条同时到达的支付通知不会把订单状态更新两次。status判断防止已经取消的订单又被付款成功这时候要触发退款流程而不是直接发货。我在这个环节还额外加了一个金额校验回调携带的支付金额必须和订单总额一致不一致时直接告警并人工复核这是资金安全的一道重要闸门。5. 订单状态机与售后设计从待付款到退款关闭5.1 订单状态的全量清单和流转规则订单状态是整个履约过程的中枢。我把这个系统的订单状态整理成了九种状态含义触发条件库存影响待付款下单成功未支付创建订单锁定库存已付款支付成功待发货支付回调保持锁定已发货仓库已发货运营发货操作释放锁定库存已签收物流显示签收物流回调/手动确认无已完成交易结束签收后N天自动完成无已取消支付前取消用户取消/超时关闭返还库存退款中售后申请已受理用户发起售后无已退款退款成功财务操作无已关闭不可继续交易超时未支付后关闭返还库存这里最容易出错的是已付款和已发货之间的库存语义。我的规则是付款成功不释放锁定库存直到发货操作才把locked_stock减掉。这个节奏和财务对账是一致的发货代表库存真正离开了仓库。5.2 超时关闭订单的定时任务待付款订单超过15分钟未支付系统要自动关闭并把库存返还。这个交给Celery的定时任务处理from celery import shared_task from django.utils import timezone from datetime import timedelta shared_task def close_timeout_orders(): deadline timezone.now() - timedelta(minutes15) orders Order.objects.filter( statusOrder.STATUS_PENDING_PAYMENT, created_at__ltdeadline, ) for order in orders: order.cancel_with_stock_restore()cancel_with_stock_restore方法里要注意并发问题用户在超时前1秒支付成功定时任务同时也在跑。所以恢复库存和改状态必须在同一个事务里而且用select_for_update锁住订单行先判断当前状态还是待付款才执行关闭。这个细节我一开始没做测试时就复现了订单显示已支付但库存被恢复了的怪现象排查了很久才发现是定时任务和支付回调撞了车。5.3 陶瓷易碎售后模块的隐藏需求陶瓷品类售后率高设计退款流程时要预判几种场景签收前发现破损通常拒收或即时反馈物流回传后仓库补发或退款。签收后破损需要用户提供照片取证客服确认后走退款。商品描述与实物不符走退货退款涉及退货物流跟踪。退款申请不应该直接改订单状态我设计了一个独立的售后单模型。售后单和订单是一对多关系一个订单可以发起多次售后每次售后单有独立的处理状态。这样订单状态机不会被售后的各种小状态撑爆而且财务对账时售后的退款金额可以从售后单维度单独统计不用去订单表里做复杂的条件聚合。提示售后单创建后如果对应商品已经售罄系统不要再拦截创建操作因为陶瓷容易碎补发可能根本无货可发要允许售后单记录无货待换或直接退款的处理结果。6. 运营后台实战从Django Admin到批量管理与销售看板6.1 Django Admin作为起步针对运营习惯做增强Django Admin最大的价值是零成本可用。我在项目第一天就注册了Product和Sku模型运营立刻就能录商品。但直接用默认Admin是不行的需要针对运营的日常操作做增强。我重点做了四件事第一SKU用TabularInline嵌在Product编辑页里运营在同一个页面完成商品信息所有SKU价格库存的维护。class SkuInline(admin.TabularInline): model Sku extra 0 fields [name, sku_code, price, stock, locked_stock, is_active, sort_order]第二给ProductAdmin加上常用的列表筛选和搜索admin.register(Product) class ProductAdmin(admin.ModelAdmin): list_display [name, category, status, is_featured, stock_status, created_at] list_filter [status, is_featured, category] search_fields [name, subtitle] inlines [SkuInline]第三加批量上架和下架的action运营选中几十个商品一键切换状态。第四在列表页加一个自定义的库存状态展示列缺货、库存紧张、正常一眼就能看出。6.2 批量改价、库存预警与补货提醒陶瓷行业经常遇到价格调整窑口涨价了、活动要开始了、尾货要清仓。一个个改SKU会疯掉所以后台必须支持选一批SKU按比例或固定值调价。我的实现是一个自定义管理命令加一个后台表单页运营上传SKU编码和最新价格的Excel系统批量校验并更新。这一步的坑在于价格字段是DecimalFieldExcel里如果填了空格或换行符解析时要先清洗否则会报错。库存预警我做了最简版本运营后台首页展示一个低库存清单阈值10件以下标红。别小看这个功能手工陶瓷补货周期长提前知道哪些SKU要断货运营才能尽早协调窑口排期。我在开发时给库存低于阈值的SKU单独做了一个通知位每个工作日给运营发一封汇总邮件比让运营自己去翻列表要靠谱得多。6.3 销售看板给运营看真正有用的数字看板我砍掉了大部分炫技图表只留三个数字今日销售额、今日订单数、待发货订单量。再加一个近七日热销商品Top10。from django.db.models import Sum, Count from django.utils import timezone def dashboard_data(): today timezone.localdate() today_orders Order.objects.filter(created_at__datetoday) return { today_sales: today_orders.filter(status__in[2, 3, 4, 5]).aggregate(sSum(total_amount))[s] or 0, today_order_count: today_orders.count(), pending_ship: Order.objects.filter(statusOrder.STATUS_PAID).count(), }运营先靠这几个数字判断今天状态有异常再下钻看订单列表比一个花花绿绿的BI页面实用得多。销售统计的日期过滤条件我用的是支付成功时间而不是下单时间因为用户下单后不付款的情况很常见按下单时间统计会把数字灌水和财务口径对不上。7. 部署上线与性能优化开发完成只是第一步7.1 部署架构Nginx、Gunicorn与进程守护上线部署我采用的是经典组合Nginx Gunicorn Django MySQL Redis。gunicorn ceramic_mall.wsgi:application \ --bind 127.0.0.1:8000 \ --workers 3 \ --timeout 60worker数量不是越多越好对Django这种有阻塞IO的同步框架我一般按CPU核心数乘以2再加1来起步然后压测调整。worker太多反而会因为数据库连接数过多把MySQL拖垮。Nginx负责静态文件、媒体文件和反向代理server { listen 80; server_name your-domain.com; client_max_body_size 20m; location /static/ { alias /var/www/ceramic_mall/static/; } location /media/ { alias /var/www/ceramic_mall/media/; } location / { 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; } }进程守护我习惯用supervisor崩溃自动拉起部署更新时执行reload就好。7.2 性能优化三板斧预取、索引、缓存商城性能问题90%集中在数据库查询。第一板斧是消除N1查询。商品列表页必须在一次查询里把关联的SKU、分类都带出来products Product.objects.filter(statusProduct.Status.ON_SALE).select_related( category ).prefetch_related(skus)第二板斧是给高频查询字段加索引。订单号、支付流水号、用户ID、订单创建时间、SKU的商品外键这些字段在列表页和详情页反复被查询都是明确的索引候选。第三板斧是页面缓存。商品详情页和列表页可以用cache_page装饰器按URL缓存加上失效时间。陶瓷商城的商品数据变动频率低内容页缓存15分钟完全没问题但要注意有购物车信息或用户个性化内容的页面不能整页缓存。我在首页和分类页用的是Redis直接缓存渲染后的HTML片段命中后连Django的模板渲染层都不用进压测下来QPS提升非常明显。7.3 媒体文件与图片处理商品图片是陶瓷商城最重要的资产直接决定转化率。我建议所有上传图片走Pillow做缩略图列表页用小图详情页用大图减少移动端流量压力。图片格式优先WebP兼容性不符合要求时回退JPEG。媒体文件的备份要纳入日常巡检我由于一开始没做定期备份某次服务器磁盘故障导致一批商品图丢失那次的教训非常深刻。现在我的习惯是数据库每天全量备份媒体文件每周增量备份并且备份文件至少保留两份异地副本千万别让备份和业务数据躺在同一块硬盘上。我在实际做完这个项目之后最大的感受是商城系统的难点从来不在能不能写出来而在业务变化时数据模型和状态机扛不扛得住。陶瓷这个品类把商品离散性、库存非连续性、售后高发这三件事同时摆到你面前逼着你在设计阶段就把它们一个个想清楚。如果开头图省事套模板后期每一个不就是改个状态吗的需求都会变成一次伤筋动骨的改动。最后分享一个小技巧开发过程中给所有状态字段都预留一个未知/异常的枚举值线上数据一旦出现意料之外的状态不要急着删记录先查清楚来龙去脉再处理。电商项目的数据完整性永远比界面美观重要。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询