ARTICLE DETAIL

资讯详情

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

Python Django打造小区团购平台:从订单状态机到支付回调的实战解析

Python Django打造小区团购平台:从订单状态机到支付回调的实战解析 这两年小区团购从大厂业务慢慢变成很多中小团队也能落地的事情不少人找到我时第一句话就是“帮我们搞一个基于python的小区团购平台能下单、能支付、能提货就行。”听起来挺简单可真把需求问细之后你会发现这套系统的核心难点根本不在“写个电商网站”而在于预售、成团、支付回调、到货核销这一整条业务链路的状态一致性。这篇文章我想抛开那种只能跑通Demo的项目复述直接从需求梳理、技术选型、数据库建模到核心代码实现讲清楚一个可落地的python小区团购平台是怎么一步步搭出来的。无论你是想拿它做毕业设计、课程项目还是真的打算跑一个社区团购小业务都能从里面找到可以直接抄的方案和必须避开的坑。1. 先弄明白小区团购的业务链路再谈技术实现1.1 小区团购和普通电商系统差在哪很多第一次做这类系统的开发者会下意识地觉得小区团购不就是个简化版淘宝吗用户注册、逛商品、加购物车、下单支付、后台发货。如果你真的按这个思路去做大概率会在第一个月就被团长和用户同时骂死。小区团购最核心的差异是“预售制”。传统电商是商品已经在仓库里用户下单后马上出库小区团购则是先让用户下单凑够一定数量或者到了截止时间平台才去采购、配送。这意味着你的数据库里必须有一个“团购活动”的概念而不仅仅是商品和订单。用户购买的不是普通商品而是“某一次团购活动中的商品”。同一个苹果今天开团和下周开团价格可能都不一样库存也不一样。另一个差异是团长角色的存在。团长不是平台员工他可能是小区门口便利店的老板也可能是小区业主。用户在小程序或者H5上下单后商品被统一配送到团长所在的自提点由团长负责分拣和核销。所以系统里必须支持按小区、按团长维度去归集订单而不是像普通电商那样按快递地址发货。最后还有线下核销环节。普通电商的终点是签收小区团购的终点是用户到提货点取货这个动作必须有一个可验证的凭证通常是核销码。否则团长只能看着手机比对姓名碰上同名用户就是一场灾难。1.2 平台中的几种角色和核心流程一个完整的小区团购平台至少包含四类角色平台管理员负责审核商品、创建团购活动、查看订单和结算数据。用户/消费者浏览活动、下单支付、到点取货。团长管理自提点、查看本点订单、核销提货、核对佣金。供应商可选供货商登录后台查看采购量或者由平台统一处理。核心业务流程一句话就能说清商品上架 → 发起团购活动 → 用户浏览下单支付 → 达到成团条件或活动截止 → 平台采购配送 → 商品到达自提点 → 团长核销 → 用户提货完成。这里面最容易漏掉的是“成团条件”和“活动截止”两个时间点。很多业务在活动开始时并没有达到最低起送量系统需要判断是自动退款还是允许超时继续等。实际操作中我建议把成团判断做成定时任务而不是在用户支付时实时判断因为用户支付的时间点无法决定整个活动是否最终成团。1.3 动手之前必须画出的订单状态机我在带团队做这类项目时第一步不是建表而是画状态机。因为小区团购里订单状态的流转路径比普通电商多很多你不先定义清楚后面写代码就会到处是if else。订单状态至少需要这些待支付已下单未付款已支付支付回调成功已履约已到货等待核销这一步可选已完成用户已提货已取消未支付用户主动取消已退款未成团或售后每个状态之间允许哪些跳转必须明确。比如已支付只能变成已完成或已退款不能直接回到待支付已完成不能再退款。这个约束在数据库层可能体现不出来但在服务层必须严格校验否则支付回调重复或者操作员手误就会把订单状态搞乱。2. 技术选型为什么我最后用Django而不是Flask2.1 框架对比与真实取舍小区团购平台的技术选型问题几乎每个来咨询的人都会问。有人想用Flask觉得轻量、自由有人想上FastAPI觉得异步性能高。我的建议很直接默认Django没有特殊原因不要换。框架自带ORM后台管理用户认证生态成熟度适用场景Django成熟Admin开箱即用内置完整高业务系统、管理后台Flask需要自己配SQLAlchemy需要集成Flask-Admin需要自己实现或扩展中轻量接口服务FastAPI需配SQLAlchemy或Tortoise弱需自行集成中高高并发API、前后端分离决策的核心是小区团购是一个典型的“管理后台重、接口业务逻辑多、并发量其实不大”的系统。Django自带的Admin可以在几乎不写前端代码的情况下把商品管理、订单管理、活动管理全部做出来这对初期运营来说效率极高。DRFDjango REST Framework再用来写用户端H5接口一套框架通吃前后端。有人会说Django太重了“杀鸡用牛刀”。但做过实际项目的人都知道一个需要后台、需要权限、需要订单流转的系统框架带的每一块功能最后都会用上。Flask虽然轻但你要自己拼认证、拼ORM、拼后台整体工作量和出错概率反而更高。2.2 开发环境与依赖清单我建议使用Python 3.10或3.11版本不用追求最新但也不要停留在3.7以下一些新库已经不再支持老版本。安装完Python后一定记得用python -m venv venv创建虚拟环境避免不同项目的依赖冲突。核心依赖清单如下django4.2.xWeb框架djangorestframework用户端APImysqlclient 或 pymysqlMySQL驱动django-redis redis缓存与并发控制celery django-celery-beat处理定时任务比如自动退款django-ratelimit接口频控防薅羊毛openpyxl 或 xlwt导出Excel报表如果是在国内网络环境下安装记得把pip源切换成国内镜像不然下载大包时经常超时。配置方法很简单在用户目录下的pip.ini或pip.conf里加入镜像地址就行这是我每次新建环境时必做的一步。2.3 前端方案怎么选用户端我用的是Vue3 Vant搭建的移动端H5页面因为小区团购的用户基本都是微信里点开链接H5最方便不用走应用商店审核。但对于没有前端基础的开发者我更推荐用Django模板渲染加少量JavaScript直接实现页面不追求花哨功能可用才是第一位。团长端和管理后台直接用Django Admin加上自定义页面。团长核销不需要单独App只要做一个手机端适配的核销页面输入核销码就能完成操作。这样可以把开发成本压缩到最低把精力集中在后端业务上。3. 数据库建模团购平台的十张核心表是怎么设计的3.1 用户、小区与团长关系建模数据库是一个业务系统的地基小区团购的表设计比普通电商多出来的是“小区”“团长”“团购活动”这几个维度。用户表不建议直接加一堆字段而是通过OneToOne扩展Profile的方式这样后续加团长佣金、用户积分这类扩展时不用动User表。class Community(models.Model): name models.CharField(max_length64, verbose_name小区名称) address models.CharField(max_length255, verbose_name详细地址) pickup_point models.CharField(max_length255, verbose_name自提点位置) pickup_remark models.TextField(blankTrue, verbose_name自提点描述) status models.BooleanField(defaultTrue, verbose_name是否启用) created_at models.DateTimeField(auto_now_addTrue) class UserProfile(models.Model): user models.OneToOneField( settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, related_nameprofile ) community models.ForeignKey( Community, on_deletemodels.SET_NULL, nullTrue, related_namemembers ) is_leader models.BooleanField(defaultFalse, verbose_name是否为团长) # 团长相关联的小区一个团长可以服务一个或多个小区 leader_communities models.ManyToManyField( Community, blankTrue, related_nameleaders )这里要注意的是“团长和小区是多对多关系”。一个团长可能在A小区和B小区同时设自提点所以用ManyToMany而不是外键。用户下单时必须关联到具体的小区这样配送侧才知道把这个订单归到哪个自提点去。3.2 商品、团购活动与阶梯价的设计商品表与团购活动表是理解这套系统的最关键点。不要想着在商品表里直接写死库存和价格因为同一个商品在不同团购活动中价格和库存完全不同。class Category(models.Model): name models.CharField(max_length32, uniqueTrue) class Product(models.Model): name models.CharField(max_length128) category models.ForeignKey(Category, on_deletemodels.PROTECT) unit models.CharField(max_length16, default份, verbose_name单位) cover_image models.URLField(blankTrue) detail models.TextField(blankTrue) status models.CharField(max_length8, defaulton) class GroupBuyActivity(models.Model): product models.ForeignKey(Product, on_deletemodels.PROTECT) price models.DecimalField(max_digits10, decimal_places2, verbose_name团购价) original_price models.DecimalField(max_digits10, decimal_places2, default0) # 库存冗余在活动表里不从商品表扣 stock models.IntegerField(verbose_name活动总库存) remain_stock models.IntegerField(verbose_name剩余库存) start_time models.DateTimeField() end_time models.DateTimeField() delivery_date models.DateField(verbose_name送达日期) min_buyers models.IntegerField(default1, verbose_name最低成团人数) max_per_user_limit models.IntegerField(default5, verbose_name每人限购) status models.CharField( max_length16, choices[(publish, 进行中), (closed, 已截止), (canceled, 已取消)], defaultpublish )价格为什么用DecimalField而不用FloatField这是个老生常谈但依然有人犯的错浮点数在计算机里本身就是不精确的做金额计算一定要用Decimal。后面讲踩坑的时候我会再单独展开。3.3 订单与支付流水表设计订单表是整个系统中字段最多的表也是最容易设计漏的。我的建议是宁可多几个冗余字段也不要频繁联表。比如下单时把小区名、团长ID、活动标题直接冗余到订单上查询核销列表时就不用再JOIN多张表了。class Order(models.Model): class Status(models.TextChoices): PENDING_PAYMENT pending_payment, 待支付 PAID paid, 已支付 COMPLETED completed, 已完成 CANCELED canceled, 已取消 REFUNDED refunded, 已退款 order_no models.CharField(max_length32, uniqueTrue, verbose_name订单号) user models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.PROTECT) activity models.ForeignKey(GroupBuyActivity, on_deletemodels.PROTECT) community models.ForeignKey(Community, on_deletemodels.PROTECT) leader models.ForeignKey( settings.AUTH_USER_MODEL, on_deletemodels.PROTECT, related_nameleader_orders, nullTrue, blankTrue ) product_name models.CharField(max_length128) quantity models.IntegerField(default1) total_amount models.DecimalField(max_digits10, decimal_places2) status models.CharField(max_length20, choicesStatus.choices, defaultStatus.PENDING_PAYMENT) pickup_code models.CharField(max_length8, db_indexTrue, verbose_name核销码) pickup_time models.DateTimeField(nullTrue, blankTrue) created_at models.DateTimeField(auto_now_addTrue) class PaymentLog(models.Model): transaction_id models.CharField(max_length64, uniqueTrue, verbose_name支付平台流水号) order models.ForeignKey(Order, on_deletemodels.PROTECT) amount models.DecimalField(max_digits10, decimal_places2) status models.CharField(max_length16, defaultsuccess) raw_data models.JSONField(defaultdict) created_at models.DateTimeField(auto_now_addTrue)核销码我建议用6到8位纯数字因为团长在手机端手输最方便不要生成那种带0和O、1和I容易混淆的字符组合。核销码在订单创建时随机生成同一个活动里允许重复也可以但为了保险还是加个db_index实际校验时再和团长ID一起过滤。3.4 表索引与事务约束的实战建议很多初学者不重视索引等订单量上来之后一个按状态筛选的查询慢到好几秒才发现问题。订单表的status、leader_id、community_id这三个字段联合查询频率最高建议做一个联合索引。支付日志表的transaction_id必须加唯一索引这不仅是查询性能问题更是幂等机制的兜底。在数据库层面订单状态流转的正确性不能只靠代码自觉。比如用户点击取消订单时要用UPDATE ... WHERE statuspending_payment这种条件更新语句通过受影响行数判断是否真的取消成功。如果更新0行说明状态已经变了就不能再继续后续逻辑了。4. 后端核心实现下单、支付回调、核销这三大难点的完整代码4.1 创建团购活动时的校验链创建团购活动看起来简单就是往数据库插一条记录但业务约束非常多活动时间不能交叉重叠、库存不能为负数、商品必须是上架状态。把这些校验写在create视图里比在Django Admin里手动盯着强得多。class ActivitySerializer(serializers.ModelSerializer): class Meta: model GroupBuyActivity fields __all__ def validate(self, attrs): if attrs[end_time] attrs[start_time]: raise serializers.ValidationError(结束时间必须晚于开始时间) if attrs[start_time] attrs[delivery_date]: raise serializers.ValidationError(送达日期必须晚于活动截止时间) if attrs[stock] 0: raise serializers.ValidationError(库存必须大于0) return attrs def create(self, validated_data): validated_data[remain_stock] validated_data[stock] return super().create(validated_data)这里的细节在于把remain_stock初始化为stock。这个冗余字段是为了下单时直接更新活动表而不需要每次去聚合计算已售数量性能上会好很多。4.2 用户下单与库存扣减并发场景下的正确姿势下单是整个系统的重头戏也是并发问题最容易爆发的地方。小区团购虽然整体并发不高但活动开始那几分钟可能有好几十人同时抢购代码不够严谨就会出现超卖。先说一个最简单的保证安全的方式数据库悲观锁。订单和扣减库存必须放在同一个数据库事务里并且对活动记录执行select_for_update()在事务提交前其他对这个活动库存的更新操作都会排队。from django.db import transaction from django.db.models import F def create_order(request, activity_id, quantity): if quantity 5: raise ValueError(超出限购数量) with transaction.atomic(): # 锁住活动记录避免其他下单请求同时修改剩余库存 activity GroupBuyActivity.objects.select_for_update().get(idactivity_id) if activity.status ! publish: raise ValueError(活动已结束) if activity.remain_stock quantity: raise ValueError(库存不足) if activity.end_time timezone.now(): raise ValueError(已超过下单截止时间) # 扣减剩余库存 activity.remain_stock F(remain_stock) - quantity activity.save(update_fields[remain_stock]) order_no generate_order_no(request.user.id) pickup_code generate_pickup_code(6) order Order.objects.create( order_noorder_no, userrequest.user, activity_idactivity_id, community_idrequest.user.profile.community_id, quantityquantity, total_amountDecimal(activity.price) * quantity, pickup_codepickup_code, ) return order有几个细节值得说明。使用F(remain_stock) - quantity而不是先查出数字再减是为了把“读-改-写”合并成一条更新语句防止拿到过期数据。虽然前面已经用select_for_update()锁住了记录但用F表达式是双重保险而且少一次Python层面的赋值操作。悲观锁在小区的规模下完全够用但如果哪天真做成了爆款活动几千人同时抢这个方案会显得慢。那时候可以考虑把库存扣减放到Redis里用Lua脚本保证原子性再把最终结果异步同步回数据库。这里我就不展开Redis Lua的完整代码了等真有这个量级再切换不迟过早优化才是最大的浪费。4.3 支付回调的幂等处理支付回调是小区团购平台里最容易出bug的地方没有之一。你永远不知道支付平台会回调几次也不知道回调的顺序会不会乱。所以回调接口的第一原则是无论收到多少次重复请求最终结果必须一致。csrf_exempt def pay_callback(request): if request.method POST: try: data json.loads(request.body) except Exception: return JsonResponse({code: FAIL}) transaction_id data.get(transaction_id) order_no data.get(order_no) amount Decimal(data.get(amount)) with transaction.atomic(): # 锁住支付日志记录处理并发重复回调 log PaymentLog.objects.select_for_update().filter( transaction_idtransaction_id ).first() if log is not None: # 已经是处理过的回调直接返回成功不做任何重复操作 return JsonResponse({code: SUCCESS}) order Order.objects.select_for_update().get(order_noorder_no) if order.total_amount ! amount: return JsonResponse({code: FAIL, msg: 金额不一致}) # 只有待支付订单才能流转到已支付 if order.status ! Order.Status.PENDING_PAYMENT: return JsonResponse({code: SUCCESS}) PaymentLog.objects.create( transaction_idtransaction_id, orderorder, amountamount, raw_datadata, ) order.status Order.Status.PAID order.save(update_fields[status]) return JsonResponse({code: SUCCESS}) return JsonResponse({code: FAIL})这段代码最核心的保障有两层。第一层是PaymentLog表的transaction_id唯一索引数据库层面保证同一笔回调只能插入一次即使两个并发请求都通过了log is None的判断也只有一个能插入成功另一个会报IntegrityError需要在外面捕获后当成成功返回。第二层是订单状态跳转校验只有pending_payment才能改为paid从业务层面再次拦截。我当时第一次接入支付回调时没有做幂等结果用户支付成功后后台收到两次回调第二次回调又把订单状态从“已支付”变成“已支付”倒是没问题但代码里如果还有库存回补逻辑那库存就会被多加一次后台数据立刻乱套。4.4 成团判断与超时未成团的自动退款活动结束后系统需要判断团是否成团。成团条件可以是人数达到min_buyers也可以是下单总量达到某个数值。这里我用Celery定时任务每30分钟扫描一次已经超过end_time且状态还是publish的活动。shared_task def check_group_activities(): now timezone.now() expired_activities GroupBuyActivity.objects.filter( statuspublish, end_time__ltenow ).select_related(product) for activity in expired_activities: with transaction.atomic(): activity GroupBuyActivity.objects.select_for_update().get(idactivity.id) order_count Order.objects.filter( activityactivity, statusOrder.Status.PAID ).count() if order_count activity.min_buyers: # 未成团取消活动并对所有已支付订单退款 activity.status canceled activity.save(update_fields[status]) orders Order.objects.filter( activityactivity, statusOrder.Status.PAID ) for order in orders: order.status Order.Status.REFUNDED order.save(update_fields[status]) refund_to_user(order) else: activity.status closed activity.save(update_fields[status])这里要强调一个业务细节退款的触发点一定要放到定时任务里而不是在用户支付成功那一刻判断能否成团。因为成团看的是整场活动的累计数据用户在20:00支付时看不到23:59的活动结果过早判断会误退款。4.5 提货核销让团长用手机完成最后一环核销接口是团长端最常用的功能体验要求就一条快。团长在手机页面上输入6位核销码后端校验通过后立即更新订单状态整个过程不能超过1秒。login_required def pickup_verify(request, code): if not request.user.profile.is_leader: return JsonResponse({code: FAIL, msg: 无权限}) with transaction.atomic(): order Order.objects.select_for_update().filter( pickup_codecode, leaderrequest.user ).first() if order is None: return JsonResponse({code: FAIL, msg: 无此核销码}) if order.status ! Order.Status.PAID: return JsonResponse({code: FAIL, msg: 当前状态不可核销}) order.status Order.Status.COMPLETED order.pickup_time timezone.now() order.save(update_fields[status, pickup_time]) return JsonResponse({code: SUCCESS})注意核销时用select_for_update()锁住订单记录防止两个手机同时提交核销请求导致重复核销。很多朋友忽略了这个场景但实际上团长有时候会手滑点两次或者两台设备同时登录团长账号没有锁就会把同一单核销两次后面的对账工作会非常痛苦。5. 开发中踩过的坑从日志到定位问题的完整排查链路5.1 支付回调重复处理导致订单状态错乱第一次真实上线时我遇到了库存越变越多、后台订单金额对不上的诡异情况。一开始怀疑是活动设置错了但排查数据后发现问题集中在支付刚完成的订单上。打开支付平台的回调日志才发现同一个支付成功事件在800毫秒内被回调了两次。第一次回调把订单从待支付改成已支付第二次回调时拦截条件没写好代码又重新执行了一次扣减库存和更新状态逻辑相当于一单被扣了两次库存又被反向加了一次。定位到原因后我做的第一件事是给PaymentLog.transaction_id加唯一索引然后重写回调处理逻辑先查日志再跳转状态把整套逻辑改成文章前面那段代码。从那以后再也没有出现过重复处理的问题。这个经历让我意识到凡是外部系统会异步回调的接口第一优先级永远是幂等。5.2 高并发抢购时的超卖问题上线第二个星期某款鸡蛋活动开团后50秒就被抢完但后台remain_stock变成了负数。排查思路是这样的先看下单接口日志发现同一时刻有多笔请求同时通过了库存校验说明在“查询剩余库存”和“更新剩余库存”之间多个请求读到了同一个旧值。再看到PHP转Python的老项目惯例——为了所谓性能在事务外面先查库存然后才开事务做更新这在并发下就是典型的超卖写法。修复方式就是我前面用的select_for_update()加上F()表达式。需要说明的是select_for_update()必须用在transaction.atomic()块内才有效很多新手把这个函数写在事务外面根本没锁住任何数据它同样也不会报错但效果为零。5.3 金额计算用float埋下的佣金对账大雷团长的佣金比例是10%有一笔订单金额是19.9元算出来的佣金是1.9900000000000002元数据库字段如果刚好是FloatField存进去再读出来就是1.99以外的数。我当时排查对账差异时翻到这一层才明白问题不是出在业务逻辑而是从商品价格到订单金额再到佣金计算全程都在用浮点数。修改方式是把所有涉及金额的字段全部改成DecimalField计算时用Python的Decimal类型而不是float。这个修改看着不大但涉及表结构迁移和数据修正如果项目还没上线一开始就要用Decimal。已上线的项目可以写一个数据迁移脚本把所有浮点金额字段统一收敛到Decimal。5.4 用户找不到提货点的体验问题技术上的坑解决后还有一个偏业务的问题用户到了小区门口依然找不到自提点。光一个“XX小区”字段没法指引实际自提点可能在西门内侧车库改装的房间。我在社区表里增加了pickup_point和pickup_remark两个字段同时在订单详情页显示提货时间窗口、自提点文字描述和一张引导图片。这个改动对技术来说不值一提但用户满意度提升非常明显团长少接了大量问路电话。6. 部署上线后如何做性能和体验优化6.1 一台低配服务器就够用了如果每天订单量在几百到一两千单一台2核4G的Linux服务器完全够用。部署方案建议Nginx处理静态文件和反向代理Gunicorn运行Django应用MySQL存业务数据Redis做缓存、限频和订单并发控制。这些服务全部装在同一台机器上没有任何问题注意给MySQL预留至少1G内存就行。部署时有一个容易被忽略的点是静态文件处理。Django的DEBUGTrue时能直接服务静态文件但上线后DEBUGFalse静态文件必须交给Nginx否则后台页面所有样式都会丢失。我用python manage.py collectstatic把静态文件收集到指定目录再在Nginx里配置location /static/指向该目录一个下午就能部署完。6.2 接口层防重复提交与防薅羊毛用户端H5的“去支付”按钮如果没做处理快速点两下就会创建两个订单虽然不是大问题但会让用户困惑。我在前端做按钮disabled在后端加了一层防重校验同一个用户对同一个活动在30秒内不允许重复创建相同数量的订单。接口频控也是必须做的尤其是核销接口虽然只有团长能用但万一核销码被人批量猜出来后果很严重。我给核销接口加了基于IP和用户ID的频控同一用户一分钟内最多允许请求30次。这个量级对于正常操作完全够用又能挡住大部分自动化爆破。RATELIMIT_ENABLE True RATELIMIT_USE_CACHE default RATELIMIT_VIEW django_ratelimit.exceptions.RatelimitException6.3 下一阶段可以扩展的方向系统跑稳定之后再谈扩展才有意义。我建议优先做这几件事团长佣金结算模块按月生成账单支持导出Excel。团长专属分享码用来跟踪业绩给团长分润时更公平。基于小区维度的数据分析看板统计各小区订单量、复购率方便选品。如果业务成熟可以把H5换成微信小程序入口更短用户转化率会高不少。这里要提醒的是不要一开始就上微服务、消息队列、分布式事务。小区团购的业务复杂度摆在眼前单机Django应用部署好、索引建好、日志打全已经能扛住绝大多数社区业务了。真到了需要拆服务的那天你的订单量早就养活得起一个技术团队了。我在实际做这个项目的过程中最大的体会是这类业务系统的技术难点不在某一个算法或框架而在对业务规则的理解和边界条件的处理。把订单状态机画清楚把回调幂等做好把并发扣库存写对这套系统的骨架就稳了。如果你正在做同类项目建议先跑通一个最简可用的单小区MVP再逐步加上多小区、佣金、数据分析这些外围功能。技术方案永远可以重构但业务规则没摸透之前代码写得再漂亮也经不起真实用户的使用。
返回列表