拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python实战:从群接龙到小区团购平台,搞定库存与订单管理

每天下午四点小区微信群里准时开始接龙“土豆3斤一份接龙张三 1份”“李四 2份”……一百多条消息挤在一起团长统计到凌晨第二天发错货、漏单的纠纷又能在群里吵一天。这是我做这个项目最直接的动机——用 Python 开发一套小区团购平台把商品发布、库存管理、用户下单、统一结算全部搬到系统里。我在两周时间内完成了从需求梳理到上线部署的完整闭环跑通了楼下三个小区的团购业务。这篇文章面向两类人一类是学 Python 想找完整 Web 项目练手的同学另一类是正在用群接龙做社区生意的团长。我会从架构选型、数据建模、核心业务逻辑到踩坑优化把整套设计与实现思路完整展开你可以在理解这些代码的基础上直接改改用到自己的小区里。1. 项目整体设计与技术选型1.1 需求拆解群接龙到底哪里不好用在设计系统之前我先把传统群接龙的痛点列了一遍商品信息混乱一张长图配一段文字用户看不清规格、价格下单全靠猜。消息刷屏导致统计出错群聊里穿插着闲聊和接龙消息漏统计、重复统计是常态。支付与订单不绑定团长手动对账谁转了钱、谁还没转全靠脑子硬记。成团门槛难控制生鲜类团购有起送量凑不够的时候团长要一个个私聊询问效率极低。售后无迹可循退款、补发全靠口头沟通出了问题根本说不清责任。这些痛点本质上指向同一个问题团购需要一个独立的交易系统而不是寄生在聊天工具里。因此我给这个平台定了三个核心目标。第一团长端能快速发布团购活动自主配置起团人数、活动时间、商品库存。第二业主端能像用电商 App 一样浏览商品、加购物车、下单支付。第三系统自动处理成团判定和订单状态流转团长只需要负责备货和发货。1.2 技术选型为什么锁定 Python 技术栈技术选型时我认真比较过 Python、Java 和 Node.js 三个方案。Java Spring Boot 的企业级框架非常成熟事务处理能力强但项目体积重开发和调试周期长对一个小型社区业务来说有点“杀鸡用牛刀”。Node.js 处理高并发 I/O 很出色但如果团队以 Python 为主前后端都写 JavaScript 会拉高维护成本。最终选了 Python Flask理由很直接Flask 足够轻一个文件能跑通原型拆开模块又能支撑完整业务适合这种中等复杂度的项目。Python 的 SQLAlchemy ORM 让模型层很清爽不需要手写一屋子 SQL配合 Jinja2 模板引擎做服务端渲染比前后端分离更快出活也更方便调试。生产环境我用 MySQL 存持久化数据Redis 扛热点缓存和购物车Nginx uWSGI 做部署经典的组合稳定没废话。1.3 模块划分与工程目录整个项目的模块划分参考了 Flask 官方推荐的 Application Factory 模式目录结构我直接放出来community_groupon/ ├── app/ │ ├── __init__.py # 应用工厂初始化扩展与蓝图 │ ├── models/ # SQLAlchemy 模型 │ │ ├── __init__.py │ │ ├── user.py │ │ ├── community.py │ │ ├── activity.py │ │ ├── product.py │ │ └── order.py │ ├── views/ # 蓝图路由 │ │ ├── __init__.py │ │ ├── auth.py # 注册登录 │ │ ├── activity.py # 团购活动 │ │ ├── product.py # 商品 │ │ ├── cart.py # 购物车 │ │ └── order.py # 订单 │ ├── services/ # 业务逻辑层 │ │ ├── order_service.py # 订单与成团逻辑 │ │ └── stock_service.py # 库存扣减 │ ├── utils/ # 工具类 │ │ ├── order_no.py # 订单号生成 │ │ └── response.py # 统一返回格式 │ ├── templates/ # Jinja2 模板 │ ├── static/ # 静态资源 │ └── config.py # 配置开发/生产分离 ├── celery_worker.py # 异步任务超时关单、成团通知 ├── requirements.txt └── run.py这种分层的好处是路由层views只做参数接收和返回业务层services放核心规则模型层models只跟数据表打交道。后面加新功能时只需要在对应层加代码不会出现一个文件写几百行的“面条代码”。2. 数据库设计把业务先落地成表2.1 核心数据表结构小区团购本质上是个小型电商系统所以数据库设计借鉴了经典电商模型再结合“团购”的特殊性做调整。主要包含这些表小区表communityid 主键 name 小区名称 address 地址 delivery_time 默认配送时间段用户表userid 主键 openid 微信登录唯一标识如果只做账号密码可省略 nickname 昵称 phone 手机号 password_hash 密码哈希 community_id 所属小区外键 address 收货地址 role 角色0业主 / 1团长 / 2管理员团购活动表groupon_activityid 主键 title 活动标题 description 活动描述 cover_image 活动封面 status 状态0草稿 / 1报名中 / 2已成团 / 3已结束 start_time 开始时间 end_time 结束时间 min_buyers 成团最低人数 max_buyers 成团上限人数商品表productid 主键 activity_id 所属团购活动外键 name 商品名 spec 规格比如“3斤/份” price 单价 original_price 原价用于展示划线价 cover_image 商品图 stock 库存 sold 已售数量订单表orderid 主键 order_no 订单号唯一索引 user_id 下单用户外键 activity_id 团购活动外键 status 状态0待支付 / 1已支付待成团 / 2已成团 / 3已发货 / 4已完成 / 5已取消 / 6退款中 total_amount 订单总金额 pay_time 支付时间 created_at 下单时间订单明细表order_itemid 主键 order_id 订单外键 product_id 商品外键 quantity 购买数量 price 下单时商品单价购物车表cart_item和支付记录表payment属于辅助表结构比较直观这里不展开。表之间关系一句话说明一个小区有很多用户一个用户属于一个小区一个团购活动包含多个商品一个订单属于一个用户但可以包含多个商品所以订单和商品通过 order_item 建立多对多关联。2.2 拼团状态机订单的一生团购业务和普通电商最大的区别在于订单要经历“成团”这个过程。我专门设计了一个状态机来管理订单生命周期状态值含义触发条件下一步可能去向0待支付用户提交订单支付成功→状态1超时→状态51已支付待成团支付回调成团人数达标→状态2活动结束未成团→状态5/6退款2已成团系统成团判定团长发货→状态33已发货团长操作用户确认→状态44已完成用户确认收货无5已取消用户取消/超时关单无6退款中未成团自动退款退款成功→状态5这个状态机的核心规则是状态只能按箭头方向流转不能跳跃更不能回退。比如一个已支付待成团的订单在成团之前用户反悔了只能走“取消退款”通道不能直接把状态改成已完成。这些逻辑我全部写在 services/order_service.py 里而不是散落在路由层。2.3 订单号生成方案订单号设计是个容易被忽略但影响重大的细节。我第一版用的是“时间戳随机数”上线第二天就撞号了。后来改成import time import random def generate_order_no(user_id: int) - str: # 格式yyyyMMddHHmmss 用户ID末4位 4位随机数 ts time.strftime(%Y%m%d%H%M%S) user_part str(user_id)[-4:].zfill(4) rand_part str(random.randint(1000, 9999)) return f{ts}{user_part}{rand_part}这个方案在单机部署、日订单量几千单的场景下足够用。如果以后订单量涨到日均十万级建议换成雪花算法Snowflake网上有很多现成实现核心思想是用“时间戳机器ID序列号”组合生成全局唯一 ID避免数据库自增主键暴露订单量。3. 核心功能设计与实现3.1 用户注册登录与小区绑定注册登录直接用 Flask 内置的 session 方案配合 Werkzeug 的密码哈希简单可靠。from werkzeug.security import generate_password_hash, check_password_hash class User(db.Model): __tablename__ user id db.Column(db.Integer, primary_keyTrue) phone db.Column(db.String(11), uniqueTrue, nullableFalse) password_hash db.Column(db.String(256), nullableFalse) nickname db.Column(db.String(64)) community_id db.Column(db.Integer, db.ForeignKey(community.id)) role db.Column(db.Integer, default0) # 0业主 1团长 2管理员 def set_password(self, password): self.password_hash generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password)注册接口的逻辑很简单先查手机号是否存在不存在就创建用户同时绑定小区 ID。绑定小区这一步很关键因为团购活动是按小区隔离的——A 小区的人只能参加 A 小区的团购下单时也会自动带上小区地址避免跨区配送的混乱。登录之后用session[user_id]标记登录态在请求里用一个装饰器校验from functools import wraps from flask import session, jsonify def login_required(view): wraps(view) def wrapped(*args, **kwargs): if user_id not in session: return jsonify({code: 401, msg: 未登录}), 401 return view(*args, **kwargs) return wrapped注意如果打算接微信小程序需要把 session 换成 JWT 或者微信 code2session 登录态并维护 token 过期时间。我前期用 session 只是因为网页端开发调试快后期接小程序时再改 JWT 兼容方案。3.2 团购活动发布与商品展示团长发布团购活动是业务起点。我在后台设计了两个表联动的流程先创建活动设置标题、时间、成团人数再往里加商品设置价格、库存。前端用两个表单分步提交后台用一个事务接口保证数据一致性。这里最核心的校验逻辑是活动时间与状态的联动from datetime import datetime class GrouponActivity(db.Model): __tablename__ groupon_activity id db.Column(db.Integer, primary_keyTrue) title db.Column(db.String(128), nullableFalse) status db.Column(db.Integer, default1) start_time db.Column(db.DateTime, nullableFalse) end_time db.Column(db.DateTime, nullableFalse) min_buyers db.Column(db.Integer, default10) property def realtime_status(self): now datetime.now() if self.status 0: return 0 if now self.start_time: return 1 # 未开始前端显示“即将开始” if self.status 3: return 3 if self.status 2: return 2 if now self.end_time: return 3 # 已结束 return 1 # 报名中前端展示商品列表时我还会带上两个关键数字剩余库存和已售数量。product { id: p.id, name: p.name, price: str(p.price), stock: p.stock, sold: p.sold, left_stock: p.stock - p.sold # 剩余库存 }这里有个容易踩的坑不要直接用stock字段作为剩余库存展示因为stock是初始库存sold是累计销量剩余库存应该是两者之差。我之前就是只显示了stock结果用户在卖完之后还能看到“还剩10份”下单时才报错体验非常差。3.3 下单流程与库存扣减下单是整个系统里最容易出 bug 的地方尤其是并发场景。我先说第一版踩坑的写法# 错误示例先查库存再扣减会超卖 product Product.query.get(product_id) if product.stock - product.sold quantity: product.sold quantity db.session.commit() else: return jsonify({code: 400, msg: 库存不足})这段代码在并发请求下会出问题两个请求同时读到stock - sold 5同时判断满足quantity3都去更新sold最终库存被扣成负数。原因很简单——先查后更新不是原子操作。正确做法是在数据库层面扣减时带上条件让数据库自己判断# 正确示例原子化扣减条件写在 UPDATE 里 result db.session.execute( Product.__table__.update() .where(Product.id product_id) .where(Product.stock Product.sold quantity) # 库存必须足够 .values(soldProduct.sold quantity) ) db.session.commit() if result.rowcount 0: return jsonify({code: 400, msg: 库存不足请修改数量})这种“条件更新”利用了数据库的行锁和受影响行数判断并发时只有真正库存足的那个请求能更新成功其他请求rowcount会是 0直接返回失败。我测试下来在并发 20 个请求同时抢 10 份库存的场景下最终结果稳定且不超卖。但注意数据库扣减和 Redis 预扣减要配合使用。我的完整流程是请求进来先走 Redis 扣减DECR stock_key如果扣减失败直接返回失败成功后再走数据库扣减落库最后异步删除 Redis 预扣记录。Redis 扣减是为了扛住瞬时流量数据库扣减是为了数据最终准确。注意Redis 预扣减和数据库扣减存在短暂不一致窗口。如果 Redis 扣成功但数据库扣失败比如商品被下架需要补偿回滚 Redis。我在 stock_service.py 里用了 try/except 回滚逻辑def deduct_stock_with_redis(product_id: int, quantity: int): key fproduct_stock:{product_id} # Redis 预扣 remaining redis_client.decrby(key, quantity) if remaining 0: redis_client.incrby(key, quantity) # 回滚 return False try: # 数据库扣减 db_result deduct_stock_in_db(product_id, quantity) if not db_result: redis_client.incrby(key, quantity) # 数据库失败回滚 return False return True except Exception: redis_client.incrby(key, quantity) raise3.4 成团判定与订单生命周期管理成团判定虽然逻辑不复杂但涉及“谁触发”的问题这决定了系统的实时性和准确性。我先定了一个规则一个团购活动是否成团由两个时机判断——有人支付成功时、以及活动截止定时任务扫描时。有人支付成功时更新订单状态为“已支付待成团”接着统计该活动下所有已支付订单的用户数def check_groupon(activity_id: int): activity GrouponActivity.query.get(activity_id) if activity.status 2: return # 已成团不重复处理 paid_user_count db.session.query( func.count(func.distinct(Order.user_id)) ).filter( Order.activity_id activity_id, Order.status 1 # 已支付 ).scalar() if paid_user_count activity.min_buyers: activity.status 2 db.session.commit() # 通知所有已支付用户成团成功准备备货 return True return False这里用count(distinct user_id)统计而不是统计订单条数那是为了避免同一个人下多单被重复计入成团人数。早期版本我就是统计 Count 订单数结果一个用户下了 5 单直接把成团人数“刷”满了后面才改成去重统计。活动截止时间到了仍未成团时就需要处理退款。我用 Celery 定时任务每分钟扫描一次到点但未成团的活动把所有已支付订单统一进入退款流程celery.task def scan_expired_activities(): activities GrouponActivity.query.filter( GrouponActivity.end_time datetime.now(), GrouponActivity.status 1 # 报名中 ).all() for activity in activities: orders Order.query.filter_by( activity_idactivity.id, status1 # 已支付待成团 ).all() for order in orders: order.status 6 # 退款中 db.session.add(order) # TODO: 调用支付平台退款接口 activity.status 3 db.session.commit()订单状态流转我统一封装在OrderService里所有对外接口都调用这个服务不在路由层直接改状态。这样的好处是后续加短信通知、消息推送模块时只需要在这个服务层扩展。4. 踩坑记录这些坑我替你踩过了4.1 并发超卖差点把库存卖成负数上面提到了“先查后更新”的问题但我还想补充一个更隐蔽的场景就算用了条件更新如果订单表和商品库存更新不在同一个事务里也会出问题。我第二版实现为了让“用户看到更新后的已售数量”把sold的更新和订单插入拆成了两个接口结果出现了订单已创建、库存却没扣的脏数据。后来我把它们包在同一个事务里from sqlalchemy.exc import SQLAlchemyError def create_order(user_id, activity_id, items): try: db.session.begin() order Order( order_nogenerate_order_no(user_id), user_iduser_id, activity_idactivity_id, status0, total_amount0 ) db.session.add(order) db.session.flush() # 让 order.id 可用 total 0 for item in items: product Product.query.get(item[product_id]) # 原子扣库存 result db.session.execute( Product.__table__.update() .where(Product.id product.id) .where(Product.stock Product.sold item[quantity]) .values(soldProduct.sold item[quantity]) ) if result.rowcount 0: db.session.rollback() raise ValueError(f商品 {product.name} 库存不足) order_item OrderItem( order_idorder.id, product_idproduct.id, quantityitem[quantity], priceproduct.price ) db.session.add(order_item) total product.price * item[quantity] order.total_amount total db.session.commit() return order except SQLAlchemyError as e: db.session.rollback() raise e库存扣减、订单头、订单明细必须在一个事务里任何一个失败整体回滚。这是我从这个项目里得到的最大教训之一。4.2 缓存一致性Redis 和 MySQL 的拉锯战项目高峰期商品详情页的 QPS 能到几十直接打数据库虽然 MySQL 也能扛但明显变慢。我上了 Redis 缓存商品详情结果又引出了缓存一致性坑。最初我采用“先更新数据库再删除缓存”的策略但在并发场景下还会漏更新。后来我接受了一个折中方案数据变更时删除缓存读取时再回填并给缓存设 5 分钟过期时间。这个方案适合团购商品这种“低频更新、高频读取”的业务5 分钟内用户看到旧库存也无伤大雅但数据库一定是准确的。def get_product_detail(product_id): cache_key fproduct_detail:{product_id} data redis_client.get(cache_key) if data: return json.loads(data) product Product.query.get(product_id) result { id: product.id, name: product.name, price: str(product.price), stock: product.stock, sold: product.sold } redis_client.setex(cache_key, 300, json.dumps(result, ensure_asciiFalse)) return result做缓存最怕的是“感觉逻辑对但实际情况总差一点”。我建议新手先用最简单的 Cache Aside 模式不要一上来就搞分布式锁和消息队列同步复杂度一高修 bug 的时间远超省下来的数据库压力。4.3 部署上线从本地到 Linux 服务器的那些事本地开发一切正常一到服务器就崩这是所有 Python Web 项目必经的劫。我踩过的坑包括第一依赖版本不一致。本地用 Python 3.10 跑得好好的服务器装的是 3.8SQLAlchemy 语法直接报错。后来我用 requirements.txt 锁版本并且把本地和服务器 Python 版本统一就没再出过这种问题。建议用虚拟环境管理依赖python3 -m venv venv source venv/bin/activate pip install -r requirements.txt第二uWSGI 配错 socket 导致 502。我第一版部署是独立跑 Flask 开发服务器app.run并发一高就卡死。换成 Nginx uWSGI 后配置容易漏掉 socket 文件路径、进程数等参数。我的稳定配置参考[uwsgi] http-socket 127.0.0.1:5000 plugin python3 wsgi-file run.py callable app processes 4 threads 2 stats 127.0.0.1:9191第三静态文件处理和上传目录权限。商品图片上传后Nginx 需要配置 alias 指到上传目录并且目录要有写权限。我之前忘了给apps/static/upload目录加写权限结果图片上传一直报错查了半天才发现是权限问题。第四Celery 没有后台常驻。开发时用celery worker -A celery_worker.celery --loglevelinfo前台跑关闭终端就没了。生产环境我用 supervisord 来守护配置如下[program:celery] command/home/ubuntu/groupon/venv/bin/celery -A celery_worker.celery worker --loglevelinfo directory/home/ubuntu/groupon autostarttrue autorestarttrue stderr_logfile/var/log/celery.err.log stdout_logfile/var/log/celery.out.log5. 项目上线后的数据表现与优化方向项目在三个小区试运行了两个月收集到的数据让我对这类系统有了更直观的认知。平均每次团购活动参与用户在 30-60 人成团率超过 90%一个显著变化是团长备货工作量下降了约 40%——不用再花大量时间核对订单了。这个数据在后台统计页面可以直观看到主要用 SQL 按活动分组聚合stats db.session.query( GrouponActivity.id, GrouponActivity.title, func.count(Order.id).label(order_count), func.sum(Order.total_amount).label(gmv), func.count(func.distinct(Order.user_id)).label(user_count) ).outerjoin(Order, Order.activity_id GrouponActivity.id) .group_by(GrouponActivity.id)后续优化方向我列几个认为最有价值的一是接入微信小程序把用户从网页端迁移到小程序里入口更浅用户体验更好。对应的改造点是登录方式换成微信授权支付换成微信支付订单消息通过订阅消息推送。二是把配送管理做起来。目前取货模式是“团长到小区门口发通知”后续可以加一个自提点管理用户下单时选择自提时间段减少人群聚集。三是增加社区互动功能。有些用户会在群里反馈哪个菜新鲜、哪个菜不新鲜这些信息如果能沉淀到底就能变成选品和供应链的参考下次开团时自动调整商品结构。不过这些都是后话对于一个以学习和解决实际需求为主的项目做到这一步已经具备基本价值了。如果你也想做一个类似的系统我的建议是先把订单流程和库存扣减的细节吃透这是整个系统的命门其他功能都能在后面慢慢补。最后再分享一个个人体会我在这个项目里最大的收获不是用了多高深的技术而是学会从业务角度倒推技术方案。刚开始我也想着用微服务、上 Docker、搞高并发架构后来被群接龙的现实打了一巴掌——一个小区一天就那几十单需要的不是花架子而是稳定、不出错、用户一看就懂的系统。这个小项目让我对“技术选型要匹配业务规模”这句话有了非常真实的体感。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门