ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零开发花草交易系统:Django订单状态机与库存扣减实战

从零开发花草交易系统:Django订单状态机与库存扣减实战 做花草交易系统之前我一直以为这就是个电商四件套的活商品、购物车、订单、支付套个Django模板一会儿就能敲完。真上手才发现花草这个品类把鲜活商品四个字吃得死死的——生命周期按天算、规格千奇百怪、运输怕压怕闷、售后还不能走退货退款的老路。断断续续折腾了一个多月用Python 3.10和Django 4.2把这个系统从零到部署捋了一遍中间踩坑无数。这篇文章我按需求拆解、数据模型、交易主流程、订单状态管理、部署排查五个部分复盘整个过程给正在做类似项目的朋友一个真实的参考。1. 花草交易系统的需求拆解与核心难点1.1 花草交易和普货电商的根本差异很多新手拿到这种题目第一反应就是照着淘宝抄用户注册、商品列表、购物车、下单、支付、后台管理。但花草交易有几个普货电商几乎不会遇到的特有问题必须提前想清楚。第一是生命周期。切花从花棚出来之后寿命是按天算的放得越久价值越低。所以系统里每一件商品都要有几个时间维度上架时间、预计可售时间、到期下线时间。我在商品表里加了shelf_life字段来表示鲜切花的最佳观赏周期同时用available_until来自动下架避免用户下单之后才发现花已经过了最佳状态。第二是规格的颗粒度。花草不是简单的一件两件。一盆绿萝可能分大号小号一束玫瑰可能分10枝、20枝、33枝多肉还要按单头、群生来定价。所以SKU设计不能只靠一个价格字段得支持规格组合。第三是物流特殊性。鲜花的包装是立体、易损的而且不同品类能不能挨着放都有讲究某些花材会产生乙烯会加速其他花材衰败。系统里必须能记录运输注意事项比如冷藏、竖放、防压。这个问题我在订单配送信息表里加了个transport_note字段后端很好加但需求层面如果不提前想清楚后面补字段会很难受。第四是售后预期。鲜活的商品一旦出货概不退还但平台又不能完全不管。所以我做了一个到货报损的流程用户收货后48小时内可以上传照片申请赔付而不是走传统的退货退款。这个逻辑单独放在售后模块里不跟退货流程混在一起。1.2 技术栈选型为什么用Django而不是别的这个项目我选的是Django 4.2 LTS Python 3.10。选Django的理由非常直接它是一个带电池的全家桶框架自带ORM、Admin、认证、表单、模板引擎很适合这种业务边界相对清晰的管理系统。具体到花草交易系统Django有几个功能几乎是白送的福利Admin后台。花店老板非技术背景需要一个能改商品、看订单的界面。Django自带的Admin只要把模型注册进去一个能用的商品管理系统就有了我只需要把字段做成内联编辑、加上过滤器就行。内置用户认证。注册、登录、密码重置、session管理全都不用自己写拿过来直接用省了大量的时间。ORM事务控制。订单生成要扣库存、要创建订单记录这两步必须在一个事务里完成Django的transaction.atomic()一行代码就能包住。成熟的迁移机制。makemigrations/migrate在开发期改模型非常方便我前后改了六七次数据库结构没有为迁移这件事操过心。有一些人可能会说用FastAPI更轻量或者用Flask更灵活。说实话做这种交易系统表单处理、CSRF保护、Admin后管这些需求如果全用Flask手搓工作量会翻倍。FastAPI适合做API后端但这是一个要实际运营给店主用的系统Django的全家桶属性更适合。1.3 模块划分前后端职责边界我最终把系统拆成五个Appusers、category、plants、orders、payments。这里有一个经验不要按照页面去分模块要按照业务域去分。users管注册登录、收货地址category管分类树plants管商品信息、库存、图片orders管购物车、订单、售后payments管支付这种划分的好处是后面加一个促销功能只需要新增一个promotions的App不需要改其他模块的代码结构。Django的App机制本质上就是在引导你往微内核的方向走。2. 数据模型设计从花花草草到订单状态的映射2.1 商品与SKU多规格下怎么建表先放models.py的关键代码。from django.db import models from django.contrib.auth.models import User class Category(models.Model): name models.CharField(类目名称, max_length50) parent models.ForeignKey( self, on_deletemodels.CASCADE, nullTrue, blankTrue, related_namechildren, verbose_name父级类目 ) class Meta: verbose_name 商品类目 verbose_name_plural verbose_name def __str__(self): return self.name class Plant(models.Model): category models.ForeignKey(Category, on_deletemodels.PROTECT, verbose_name类目) name models.CharField(商品名称, max_length100) sku_code models.CharField(SKU编码, max_length32, uniqueTrue) price models.DecimalField(售价, max_digits8, decimal_places2) stock models.PositiveIntegerField(库存, default0) unit models.CharField(单位, max_length10, default盆) shelf_life models.PositiveSmallIntegerField(保质期(天), nullTrue, blankTrue) available_until models.DateField(可售截止日期, nullTrue, blankTrue) is_on_sale models.BooleanField(是否上架, defaultTrue) transport_note models.CharField(运输注意事项, max_length255, blankTrue) cover_image models.ImageField(主图, upload_toplants/cover/%Y/%m/, blankTrue) def __str__(self): return f{self.sku_code} {self.name} class PlantSpec(models.Model): plant models.ForeignKey(Plant, on_deletemodels.CASCADE, related_namespecs, verbose_name商品) name models.CharField(规格名, max_length30) # 例如大号带盆 price_delta models.DecimalField(价格增量, max_digits8, decimal_places2, default0) stock models.PositiveIntegerField(规格库存, default0) class Meta: verbose_name 商品规格 verbose_name_plural verbose_name def __str__(self): return f{self.plant.name} - {self.name}设计时有几个点我特别想强调一下第一category表为什么要用自关联而不是做成固定两层因为花店的商品分类会有层级花卉 鲜切花 玫瑰 红玫瑰。如果你写死了两层后期扩展就只能加字段非常难看。自关联用parent字段指向自己支持无限层级Admin里也支持递归展示。第二价格为什么要用DecimalField而不是FloatField做交易系统涉及钱的地方一定不要用浮点数。0.1 0.2在浮点里不等于0.3累计到订单总额会出莫名的错误到时候查虫查到怀疑人生。DecimalField在数据库层面就是用定点数来存的做金额累加、比较、舍入都安全。第三为什么把规格单独拆了一张PlantSpec表花草商品同一个名字下会有多个规格而且价格可能不同。如果不拆表就只能把一个规格塞一个Plant记录整个商品列表会被规格稀释后台管理商品时会非常痛苦。拆表之后Plant是真正的商品PlantSpec是可卖的规格订单去关联PlantSpec而不是Plant。2.2 订单模型主表子表的设计逻辑订单部分我用了经典的master-detail结构Order是主表OrderItem是子表。class Order(models.Model): STATUS_CHOICES ( (pending_payment, 待付款), (paid, 已付款), (preparing, 备货中), (shipping, 配送中), (completed, 已完成), (cancelled, 已取消), (refunding, 退款中), (refunded, 已退款), ) order_no models.CharField(订单号, max_length32, uniqueTrue, editableFalse) user models.ForeignKey(User, on_deletemodels.PROTECT, related_nameorders, verbose_name用户) total_amount models.DecimalField(订单总额, max_digits10, decimal_places2) status models.CharField(订单状态, max_length20, choicesSTATUS_CHOICES, defaultpending_payment) receiver_name models.CharField(收货人, max_length30) receiver_phone models.CharField(收货电话, max_length20) receiver_address models.CharField(收货地址, max_length200) transport_note models.CharField(配送要求, max_length255, blankTrue) remark models.CharField(用户备注, max_length255, blankTrue) created_at models.DateTimeField(创建时间, auto_now_addTrue) paid_at models.DateTimeField(支付时间, nullTrue, blankTrue) class OrderItem(models.Model): order models.ForeignKey(Order, on_deletemodels.CASCADE, related_nameitems, verbose_name订单) spec models.ForeignKey(PlantSpec, on_deletemodels.PROTECT, verbose_name商品规格) plant_name models.CharField(商品名称快照, max_length100) spec_name models.CharField(规格快照, max_length30) price models.DecimalField(成交单价, max_digits8, decimal_places2) quantity models.PositiveIntegerField(购买数量, default1)有人可能会问既然OrderItem已经关联了PlantSpec为什么还要再存plant_name、spec_name、price这三个快照字段因为订单创建之后商家完全可能去修改商品名称、调价。如果订单直接实时关联到商品表过了一个月你再去看上个月的订单商品可能已经不在了或者价格变了订单记录就不准了。快照字段的语义是下单那一刻的成交信息与当前商品目录无关这就是为什么电商系统里都会有重复存字段的做法。Order有一个值得说的地方收货人信息没有分开建表而是直接冗余在订单上。这是因为收货地址在下单这个时刻应当是一个历史快照。用户把订单下了之后再改默认地址不能影响这单已经确定的配送信息。把它拼进订单表里就不需要再考虑地址变动的关联问题。2.3 库存扣减的并发控制花草这种商品库存不会特别大但并发扣减的问题依然存在。我在扣减库存的接口里用了select_for_updatefrom django.db import transaction from django.db.models import F transaction.atomic def deduct_stock(spec_id, quantity): spec PlantSpec.objects.select_for_update().get(pkspec_id) if spec.stock quantity: raise ValueError(库存不足) spec.stock F(stock) - quantity spec.save()核心逻辑是在事务里用select_for_update()给这条规格记录加了行级锁其他需要操作同一条记录的事务必须等当前事务提交之后才能执行这样就避免了两个人同时抢最后一盆多肉的情况。这里也有一个取舍也可以用乐观锁比如在更新语句里加一个stock quantity的条件配合受影响行数判断是否成功。两种方案都行。我在这个项目里选行级锁是因为流程简单、代码容易理解后续如果聊到性能问题抛单到消息队列进一步处理也不晚但不要在开发初期就上复杂方案。3. 交易主流程实现从选购到支付的完整链路3.1 购物车用Session还是数据库购物车我采用的是数据库表 登录用户关联的方案。为什么不用纯session的购物车因为花店用户经常是手机、电脑交替使用session存在服务端不同设备登录状态一致但同账号双端切换时不希望购物车丢失。存数据库里只要登录账号相同任何端查看购物车数据都是一致的。class Cart(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, related_namecart_items, verbose_name用户) spec models.ForeignKey(PlantSpec, on_deletemodels.CASCADE, verbose_name商品规格) quantity models.PositiveIntegerField(数量, default1) created_at models.DateTimeField(加入时间, auto_now_addTrue) class Meta: unique_together (user, spec)unique_together这个约束很关键同一个用户把同一个规格加入两次购物车应该是在原有数量上增加而不是插入两行。配合先查再增改的逻辑可以保证购物车里一个用户对一个规格只有一条记录。加购接口我放在plants这个App的views里def add_to_cart(request, spec_id): if not request.user.is_authenticated: return JsonResponse({code: 401, msg: 请先登录}) spec get_object_or_404(PlantSpec, pkspec_id) quantity int(request.POST.get(quantity, 1)) if quantity 0 or quantity 99: return JsonResponse({code: 400, msg: 数量不合法}) cart_item, created Cart.objects.get_or_create( userrequest.user, specspec, defaults{quantity: quantity} ) if not created: cart_item.quantity F(quantity) quantity cart_item.save() return JsonResponse({code: 0, msg: 已加入购物车})这里有个小坑用get_or_create之后在对quantity做加法时拿到的是F表达式对象。如果要随手返回quantity给前端得注意再refresh_from_db()刷新一下否则读出来的是原来的数表达式不是实际值。3.2 下单事务锁定保证库存准确下单接口是交易系统里最核心的一段代码它的操作顺序非常重要从购物车里查出所有选中项计算总金额在事务里逐条扣库存创建Order和OrderItem把购物车里对应的条目删掉login_required transaction.atomic def create_order(request): cart_ids request.POST.getlist(cart_ids) if not cart_ids: return JsonResponse({code: 400, msg: 请选择商品}) cart_items Cart.objects.filter( id__incart_ids, userrequest.user ).select_related(spec__plant) total Decimal(0.00) order_items_data [] with transaction.atomic(): order Order.objects.create( order_nogenerate_order_no(), userrequest.user, total_amounttotal, receiver_namerequest.POST[receiver_name], receiver_phonerequest.POST[receiver_phone], receiver_addressrequest.POST[receiver_address], transport_noterequest.POST.get(transport_note, ) ) for item in cart_items: spec PlantSpec.objects.select_for_update().get(pkitem.spec_id) if spec.stock item.quantity: raise ValueError(f{item.spec.plant.name} {item.spec.name} 库存不足) spec.stock F(stock) - item.quantity spec.save() OrderItem.objects.create( orderorder, specspec, plant_nameitem.spec.plant.name, spec_nameitem.spec.name, pricespec.plant.price spec.price_delta, quantityitem.quantity, ) total (spec.plant.price spec.price_delta) * item.quantity order.total_amount total order.save() cart_items.delete() return JsonResponse({code: 0, order_no: order.order_no})注意我上面的代码里有个故意留的细节先用transaction.atomic()做装饰器方法内部又写了一个with transaction.atomic()。实际上没必要嵌套装饰器已经能保证整个方法在同一个事务里了写with是为了强调关键代码块一定不能拆出事务。代码上嵌套是合法的但没必要。真实代码里应该只用装饰器就行。另外生成订单号的时候最好保证并发下的唯一性。我用的方案是年月日时分秒 用户ID 4位随机数再经过一次唯一约束兜底如果创建时报IntegrityError就重新生成一次。更严谨的做法是用Redis的incr生成自增序列但对这个体量来说没有必要。3.3 支付回调以服务端通知为准支付环节没法在本地开发环境真实跑一个银行接口项目里我接的是模拟支付网关前端调支付接口之后支付网关向Django的callback_url发一个通知请求里面带了订单号和支付状态。系统以这个服务端通知为准更新订单状态而不是信任前端返回。def payment_callback(request): order_no request.POST.get(order_no) result request.POST.get(result) try: order Order.objects.select_for_update().get(order_noorder_no, statuspending_payment) except Order.DoesNotExist: return HttpResponse(fail) if result success: order.status paid order.paid_at timezone.now() order.save() return HttpResponse(success) return HttpResponse(fail)这里有一个很隐蔽的细节支付回调必须做幂等处理。支付网关可能会因为网络抖动把同一个通知发两次如果第一次已经把订单改成paid了第二次还用statuspending_payment去过滤就会查询不到数据直接返回fail。网关收到fail可能还会继续重试形成一个重复请求的循环。所以回调里对订单不存在或者已处理的情况要按已成功处理来返回success阻止网关继续重试。还有一点支付回调的URL一定不要放在需要登录的视图里。支付网关是服务端去请求你的服务器它没有你的session和cookie如果用login_required装饰器回调会直接302跳转到登录页导致支付状态永远更新不了。这也是新手很容易踩的坑。4. 订单状态管理与权限控制4.1 订单状态机的设计与实现我做状态管理的时候没有去引入一堆工作流引擎的依赖因为交易系统的状态流转基本是可以穷举的状态机用手写就够了。重点是先把状态流转图想清楚然后让代码去强制这些流转规则。当前状态允许执行动作下一状态待付款用户取消已取消待付款支付回调成功已付款已付款商家发货配送中已付款用户申请取消需审核退款中配送中用户确认收货已完成已付款/配送中报损审核通过退款中退款中退款完成已退款代码层面我封装了一个方法来做状态迁移class Order(models.Model): # ...字段省略 def transition(self, to_status, action): allowed { pending_payment: {cancelled, paid}, paid: {preparing, refunding}, preparing: {shipping, refunding}, shipping: {completed, refunding}, refunding: {refunded}, } if to_status not in allowed.get(self.status, set()): raise ValueError(f不允许从 {self.status} 流转到 {to_status}) self.status to_status self.save()为什么要加这个限制因为订单状态一旦乱跳后面统计报表、售后对账全都会乱。直接裸改status字段一时爽后面上线运营的时候就是灾难。把转移规则集中到一处所有视图层只能通过transition()去改状态等于给状态流转上了规则闸门。4.2 查询权限永远不要相信前端传来的用户IDDjango里有一个新手很容易犯的错误代码里出现Order.objects.filter(user_idrequest.POST.get(user_id))。这是严重的安全漏洞任何人都可以传别人的user_id来查别人的订单。正确的做法是在所有涉及订单查询的地方都用request.user来过滤login_required def my_orders(request): status request.GET.get(status, ) qs Order.objects.filter(userrequest.user) if status: qs qs.filter(statusstatus) orders qs.order_by(-created_at) return render(request, orders/my_orders.html, {orders: orders})后台管理的权限又不一样。Admin里我用了Django自带的permission系统在OrderAdmin里重写了get_queryset和has_change_permission只有属于order_manager组的用户才能看到所有订单普通客服只能看到自己负责的订单——这个在需求里可能写不出来但上线之后会非常明显。另外一个容易被忽略的地方图片上传目录的权限。花草交易系统商品图片很多我把Media根目录配在项目外层的storage/下面Nginx对Media目录做了限制只允许通过特定URL访问。不能让用户上传脚本文件然后通过图片目录直接访问到服务器这是基础的安全底线。4.3 后台管理让花店老板自己改商品前面说过Django Admin对这个项目是刚需。注册模型之后我做了几个关键的配置from django.contrib import admin from .models import Plant, PlantSpec class PlantSpecInline(admin.TabularInline): model PlantSpec extra 1 admin.register(Plant) class PlantAdmin(admin.ModelAdmin): list_display (sku_code, name, category, price, stock, is_on_sale) list_filter (category, is_on_sale) search_fields (name, sku_code) inlines [PlantSpecInline] list_editable (stock, is_on_sale)TabularInline让管理员能在同一个页面里把商品信息和它下面的多个规格一口气编辑完不需要来回跳转。list_editable则让老板可以在列表页直接改库存和上下架状态不用点击进入详情。这两个配置对运营效率的提升非常明显。为了让老板用起来更顺手我还在Admin里加了autocomplete_fields [category]当分类多了之后下拉框会支持输入搜索自动补全。这些都是很细节但很加分的功能。5. 部署上线与问题排查实录5.1 Docker Nginx uWSGI 的部署方案整个项目我用Docker Compose编排容器分三个webDjango uWSGI、nginx静态文件和反向代理、dbMySQL。Redis容器我留了但没有在部署里默认启用因为销售淡季流量不大项目里用到的session是数据库存储缓存的必要性没那么高。Dockerfile的核心部分FROM python:3.10-slim ENV PYTHONDONTWRITEBYTECODE1 \ PYTHONUNBUFFERED1 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . RUN python manage.py collectstatic --noinput EXPOSE 8000 CMD [uwsgi, --ini, uwsgi.ini]uWSGI配置里有一点特别提醒新手http-socket和socket的区别。Nginx反向代理到uWSGI的时候要用--socket而不是--http。用--http的话Nginx和uWSGI之间相当于还是走HTTP解析多了一层协议开销。用socket模式Nginx通过uwsgi_pass指令直接用uWSGI协议通信性能更好。很多新手在这块混用导致静态文件路径和请求转发各种诡异问题。5.2 几个让我印象深刻的排查过程这次部署我遇到了几个坑每一个都是把文档翻了几遍才定位到的放在这里给后来人避雷。第一个是CSRF验证失败。问题出在用了nginx做反向代理以后前端页面提交表单一直报403 CSRF验证失败。排查半天发现是Django的CSRF_TRUSTED_ORIGINS里没有加上域名。如果前端页面显示的地址是https://shop.example.com而后端Django在内网域名后面CSRF来源校验就会认为跨域。解决方式CSRF_TRUSTED_ORIGINS [https://shop.example.com] CORS_ALLOWED_ORIGINS [https://shop.example.com]另外如果以后要改协议头Django需要用SECURE_PROXY_SSL_HEADER (HTTP_X_FORWARDED_PROTO, https)来识别Nginx转发的https请求否则重定向也会有问题。第二个是静态文件404。DEBUGFalse之后Django不再自动托管static和media文件如果你没有配Nginx和collectstatic页面会变成裸的HTMLCSS全丢。我的配置思路是Django容器启动时先跑collectstatic把静态文件输出到/app/assets/static/目录然后用docker volume挂载给nginx容器。Nginx配置一个location /static/指向这个目录即可。第三个比较隐蔽时区问题。Django的TIME_ZONE没设置为Asia/ShanghaiUSE_TZ True的话数据库里存的是UTC时间页面展示如果不做转换订单创建时间会差8个小时。特别影响后台按天统计订单。后来我在项目settings里设了TIME_ZONE Asia/Shanghai同时保证模板引擎的USE_TZ能正确转换——关键是timestamp只存UTC展示时用本地时区格式化。5.3 上线后的数据维护小贴士系统上线跑了大概一个月日常运营里需要经常做几类操作改商品价格、调库存、下架过季花材。我建议把这些操作全部教老板在Admin里完成同时用Django的信号加了一个审计日志。虽然项目不大但有一个简单的谁在什么时候改了哪个商品的什么字段的记录对店铺运营纠纷是非常有用的。我实现的是一个简单的audit_log表字段包括操作用户、操作时间、模型名、对象ID、变更内容用JSON存before和after。信号里监听post_save只记录关键字段的差异。这个功能不要让老板自己用excel去记虽然系统上线前期他们可能不觉得有必要但一旦出了价格纠纷有一个查询入口会省很多事。另外每周我写了一小段定时脚本扫描available_until快到期的鲜花商品自动生成一个下架提醒的待办列表发到管理后台的通知中心。这个脚本用了系统的manage.pycommand机制放到management/commands/expire_check.py里再用cron定时执行。这比老板每天手动翻列表要靠谱得多。最后再分享一个我做这个项目最大的体会写交易系统业务逻辑上的坑远比代码语法上的坑多。像库存扣减、支付回调的幂等、状态流转的约束这些在设计初期就要想清楚不能等写完了再补。Django框架本身很快真正的功夫都花在业务建模和那些看似小但影响极大的细节上。如果这个项目要继续扩展我会优先做两件事一是把订单拆分到子订单因为花店的商品可能来自不同的花棚和仓库配送分拨的时候需要按子单处理二是接入一个真实的微信支付渠道把模拟支付网关替换掉同时把支付对账的定时任务做起来。这些都是基于现有结构比较容易扩展的Django的项目只要模型设计的时候没做死后面基本都能接住。
返回列表