有一段时间没写全栈项目复盘了,这次想聊聊一个很有意思的选题:宠物食品商城和医疗预约系统。估计不少人在课程设计或个人项目阶段看到过类似题目,第一反应通常是“这不就是电商加个预约功能嘛,Django一套写完不就行了”。但实际上,真正把这两块业务揉在一起跑通,并且敢放到线上真刀真枪处理并发和支付回调时,很多看似简单的问题都会浮出水面。我这次用了一个双框架方案,Django负责商城主站,Flask独立跑预约服务,整个系统就是典型的“django-flask基于python的宠物食品商城医疗预约系统”,下面把设计思路、实现细节和踩坑过程完整拆开讲一遍。
整个系统涵盖用户登录、商品浏览、购物车下单、订单支付、支付回调、宠物档案、医生信息、预约挂号、就诊记录、提醒通知等多个模块。开发语言是Python 3,后端框架为Django 4与Flask 2,数据库用MySQL,缓存和分布式锁依靠Redis,异步任务交给Celery。如果你正准备做同类项目,或者在做一个同时包含交易和预约两个业务形态的系统,这篇文章的建模思路、并发处理和部署经验可以直接参考。
1. 需求拆解:先把“商城+预约”翻译成数据模型
任何系统在设计之前都要先回答一个问题:核心业务对象是什么,它们之间怎么流转。很多半途卡壳的项目,问题大多出在没想清楚关系就开始写models,结果后面越改越乱。这个项目里我用两天时间专门做数据建模,把所有角色、业务流和状态画清楚之后,才动手编码。
1.1 三大角色与核心业务流
这个系统主要有三类用户:宠物主人(买家)、兽医(服务提供者)、平台管理员。
宠物主人的核心动作是:注册登录、完善自己的宠物档案、浏览食品商品、加购下单付款、为宠物预约就诊、查看就诊记录。兽医的核心动作是:维护个人排班、查看预约列表、填写问诊记录。管理员的动作则集中在商品上下架、订单管理、排班审核、数据统计。
业务流拆成两条主线。商城线:商品 -> 购物车 -> 订单 -> 支付 -> 库存扣减 -> 发货(虚拟物流状态)。预约线:宠物档案 -> 选择医生 -> 选择时段 -> 生成预约 -> 就诊 -> 诊疗记录。两条线并非完全独立,支付回调成功之后,订单状态要更新,如果该订单关联了预约服务,还要触发预约状态的推进。这种跨业务域的联动是后续设计中比较挠头的地方,我放在了第4、5章详述。
1.2 表结构设计中的关键取舍
先看核心表设计,这不只是字段罗列,每个表都是业务规则的物理载体。
用户体系直接基于Django内置的User表做扩展,新增Profile模型存储手机号、地址、宠物数量、用户类型标识。宠物档案PetProfile单独建表,关联User,包含昵称、品种、年龄、体重、疫苗接种状态、绝育状态、过敏史备注。这里有一个容易踩的坑:宠物档案一旦被预约订单引用,主人在就诊前修改档案内容,医生看到的病历可能就和当前档案不一致。我最后采用“快照”策略,预约成功时把当时的宠物年龄、体重、过敏史一并拷入预约记录,避免后续修改导致医疗信息错乱。
商品模块包括Product(商品基本信息和价格)、Category(多级分类)、Sku(规格库存,如不同口味、不同重量)、CartItem、Order、OrderItem。很多二手项目把库存直接放在Product表里,看似省事,但真实运营时一个食品商品会有多个SKU,不同口味库存不同。这里建议建立单独的Sku表,用商品ID和规格值如“鸡肉味、1.5kg”做唯一标识。
预约模块设计为:Vet(医生档案,关联User)、VetSchedule(医生排班)、AppointmentSlot(展开后的可预约时段)、Appointment(预约单)、MedicalRecord(诊疗记录)。排班和时段分开建表是一开始就定下的原则,排班只定义“周二上午9点到12点出诊”,真正可被预约的slot由算法按服务时长展开生成,这样既能支持批量排班,也能灵活控制每个时段的预约人数上限。
下面是核心数据模型简化对照,实际字段相对更多,看这个表能快速理解业务边界。
| 模块 | 核心表 | 关键字段 | 作用 |
|---|---|---|---|
| 用户 | auth_user + profile | user_type, phone, address | 区分主人、兽医、管理员 |
| 宠物 | pet_profile | user_id, breed, weight, vaccine | 沉淀宠物健康基础信息 |
| 商城 | product / sku | price, stock, spec_value | 商品规格与库存控制 |
| 交易 | orders / order_items | order_no, status, pay_amount | 记录订单与明细 |
| 预约 | vet_schedule / appointment | week_day, start_time, end_time, status | 医生排班与预约状态 |
| 医疗 | medical_record | appointment_id, diagnosis, prescription | 记录诊疗内容 |
主键统一用自增ID,业务唯一标识另外建唯一索引。订单号用“前缀+日期+随机串”生成,确保在支付回调和对账环节能稳定关联外部支付平台。
2. 双框架的职责划分:为什么商城交给Django、预约服务交给Flask
我知道很多人看到“一个项目用两个Web框架”会觉得多余,甚至怀疑是不是炫技。但在这个项目里,双框架不是噱头,而是被业务形态逼出来的选择。商城和预约两个子系统在开发节奏、事务边界、并发特性上差异很大,强行塞进同一个单体进程,反而会在后期互相拖累。
2.1 Django成熟生态更适合交易系统
商城主站选择Django,主要看中的是四个能力:自带Admin后台方便管理商品和订单,ORM的事务管理成熟,Form和ModelForm能快速搞定表单校验,以及Auth用户体系可以直接复用扩展。
库存和订单涉及的是一致性问题,Django的transaction.atomic()配合select_for_update()行锁可以稳定处理高并发扣库存,这一点在社区里有大量验证案例,用起来心里有底。另外,支付回调这类外部接口进来时,Django提供的CSRF豁免和签名验签机制也能比较规整地落地。
商城模块的开发周期比预约长,Django自带的后台让运营人员零成本上手管理商品和订单状态。F类表达式更新库存时F('stock')-1是原子操作,这是裸手写SQL容易出错、用其他框架又不一定内置的便利。
2.2 Flask独立承担预约服务的轻量API
预约模块的痛点在于高并发的时段抢占、定时提醒、状态快速流转。Flask很轻,自己能定义清晰的路由和蓝图,配合SQLAlchemy也能独立访问数据库,放到一个小服务里性能开销比一套完整Django应用小很多,启动和迭代反馈也更快。
预约服务不是从头开发时拍脑袋定的,而是经历了先合后拆的过程。最初预约模块也写在Django里,结果发现一个问题:医生端每隔几秒轮询新预约,用户端又在频繁抢时段的场景下,Django整个进程的日志被预约接口刷屏,偶尔还会因为长事务阻塞影响商品下单接口。后来决定把预约相关API独立成Flask服务,用Redis做数据缓存和分布式锁,让Django只负责核心交易,两边数据库共用但业务压力天然隔离,问题才算真正解决。
2.3 工程目录与通信边界
双框架在同一个仓库下共享迁移脚本和公共工具模块,目录结构如下:
pet_platform/ ├── django_app/ # Django 主商城应用 │ ├── apps/ │ │ ├── users/ │ │ ├── shop/ │ │ └── orders/ │ ├── config/ │ └── manage.py ├── flask_service/ # Flask 预约微服务 │ ├── app.py │ ├── blueprints/ │ │ ├── schedule.py # 排班与时段 │ │ └── appointment.py # 预约操作 │ └── utils/ ├── celery_tasks/ # 异步任务 │ ├── tasks.py │ └── beat_schedule.py ├── common/ # 公共常量与工具 └── deploy/两个服务的通信方式很简单:Flask服务直接读取共享的MySQL库,Redis做缓存和锁;Django需要触发预约动作时,不直接写预约表,而是往消息队列里发一个事件,由Celery worker消费后调用Flask暴露的内部接口或直接落库。这样做的好处是商城和预约的事务边界互不污染,后续哪个模块压力大就单独扩容哪个服务。
3. 宠物食品商城落地:从购物车到订单回滚的实战链路
交易模块是整个系统的经济命脉,这部分写不好,系统上线就会被用户骂下线。这一章不讲泛泛的“电商怎么做”,只讲我在实现过程中真正卡过壳的三件事:库存防超卖、订单状态机、支付回调幂等。
3.1 商品库存的“超卖”防护
商品模块最容易被忽视的是SKU粒度的库存扣减。商品页展示的“有货”状态可能对应Sku表里的多个规格,用户提交订单时如果只锁商品,不锁SKU,并发场景下极容易出现库存超卖。我的下单接口核心逻辑如下:
from django.db import transaction from django.db.models import F def create_order(user, sku_id, quantity): with transaction.atomic(): sku = Sku.objects.select_for_update().get(id=sku_id) if sku.stock < quantity: raise StockNotEnough() sku.stock = F('stock') - quantity sku.save(update_fields=['stock']) order = Order.objects.create(...) OrderItem.objects.create(order=order, sku=sku, ...) return orderselect_for_update()会在数据库层面锁定匹配的SKU行,另一个并发请求进来只能等锁释放。F表达式更新库存保证读取和写入是原子操作,不是在Python里先读出来再算好写回去。这两点缺一不可。
真正上线之后会发现问题不止这一层:订单创建了,但是用户迟迟不付款,库存一直被占用。起初设置30分钟不付款自动取消订单,恢复库存。后来发现恶意用户下单不付款占库存,影响真实顾客购买。于是加了异常检测策略:同一个IP或者同一设备短时间内创建超过3笔未支付订单,直接触发风控,禁止继续下单。这里不是复杂算法,就是简单计数加限制,但效果立竿见影。
3.2 订单状态机与支付回调幂等
订单状态我设计了六个:待支付、已支付、已发货、已完成、已取消、售后中。状态只能按既定方向流转,不能跳变。
支付回调是整个交易链路最容易出错的地方。外部支付平台的回调可能会因为网络超时而重复推送,如果回调处理逻辑里没有幂等保护,同一订单可能被退款两次或重复发货。我的做法是在本地建一张payment_notify表,记录每一个收到的回调通知,用订单号加支付流水号做唯一索引。处理前先插一条通知记录,如果插入成功说明第一次收到,继续处理业务;插入报唯一键冲突则说明是重复回调,直接忽略。
notify, created = PaymentNotify.objects.get_or_create( order_no=order_no, pay_serial=payload['serial'], ) if not created: return 'duplicated' # 只有首次收到回调才继续更新状态 order.mark_as_paid(payload)这种“先记录后处理”的思路,本质是让数据库替我们判断幂等,比在Python里加内存锁可靠得多。
3.3 一个真实的并发下单复盘
开发完第一版后,我用并发脚本模拟了100个用户同时抢购同一款限量猫粮。第一次跑下来,库存从10变成7,出现了看起来匪夷所思的结果:订单创建了3笔,但库存只扣了3,而用户看到的是下单成功。
排查发现原因非常典型:前端点击“提交订单”按钮没有做防重,同一个用户双击两次触发两个并发请求;后端代码里是先查库存、再创建订单、再扣库存,这三个步骤没有放在同一个事务里。查询库存时还有3件,两个请求都通过了检查,但真正扣减的时候只扣了一次。修复方式是两条腿走路:前端按钮提交后立即禁用,后端整个流程包进transaction.atomic()。
后来又把下单逻辑改成了“先扣库存再创建订单”,因为先建订单再扣库存,订单存在于数据库里但库存扣减失败时,需要额外清理订单,事务回滚虽然能解决,但代码分支明显变多。先扣库存再建订单,一旦后续步骤失败,事务全部回滚,库存恢复,逻辑更干净。
4. 医疗预约系统的难点:排班展开、抢号冲突与状态流转
预约系统比商城更偏向高并发短事务,也是这个项目里和Flask服务绑定最深的部分。表面上就是“选医生、选时间、提交预约”,但落到数据层,很多细节必须处理到位。
4.1 医生排班与时间槽展开算法
医生排班表只存基础规则,不存具体的可预约时刻。比如某个医生排班为“每周二、周四上午9点到12点”,这里的“上午9点到12点”只是出诊时间,真正的预约slot需要按服务时长展开。
我定义基础服务时长为30分钟,上午的排班会生成9:00-9:30、9:30-10:00、10:00-10:30一直到11:30-12:00这样6个可预约时段。展开算法最需要注意的边界问题是跨天和午休:一个排班如果是从上午9点持续到下午1点,直接按30分钟切分会产生跨越午餐休息的slot,但实际医院会有午休时间,不允许预约。我的做法是允许排班记录里配置休息时间段,展开时自动跳过休息时间内的slot。
def expand_slots(start, end, step=30, break_ranges=None): slots = [] cur = start while cur + step <= end: slot_end = cur + step if break_ranges and any(b_start < slot_end and b_end > cur for b_start, b_end in break_ranges): cur = slot_end continue slots.append((cur, slot_end)) cur = slot_end return slotsslot生成后存入AppointmentSlot表,每个slot有默认最大预约数。普通门诊一次slot最多1个预约,疫苗注射类可以设为3个。这属于业务规则可配置字段,而不是硬编码。
4.2 同一时间段多人预约的并发控制
抢号场景的经典问题:某个医生只剩下一个10点时段,两个用户同时提交预约请求,如果都先查“该时段剩余名额为1”,再通过检查,就会有两个预约单同时成功,医生电脑上就会出现两单时间重叠。
最初我尝试用数据库唯一索引约束“医生ID + 开始时间”,保证同一开始时间只有一个预约单,但马上碰到疫苗类slot允许多人预约,唯一索引直接失效。纠结之后采用了两种手段叠加:MySQL事务加锁 + Redis分布式锁。
事务内锁住slot行,检查已预约人数是否小于上限:
# SQLAlchemy, Flask 服务内 with db.session.begin(): slot = AppointmentSlot.query.with_for_update().get(slot_id) booked = Appointment.query.filter_by(slot_id=slot_id, status='confirmed').count() if booked >= slot.max_limit: raise SlotFull() appointment = Appointment(...) db.session.add(appointment)因为并发请求会锁住同一行slot记录,后续请求必然等待前一个事务提交后才执行查询,不会出现同时通过检查的情况。加上Redis锁是为了防止服务内出现长事务拖慢数据库,抢锁成功后立刻开启数据库事务,抢不到就快速返回“该时段已被约满”。
Redis锁的代码比较模板化,设置过期时间必须用一条命令完成,防止服务崩溃后锁无法释放:
import redis, uuid r = redis.Redis(...) lock_key = f'appointment:lock:{slot_id}' token = str(uuid.uuid4()) if r.set(lock_key, token, nx=True, ex=10): try: # 执行数据库事务 pass finally: if r.get(lock_key) == token: r.delete(lock_key) else: raise SlotFull('Another user is booking')4.3 提醒通知与爽约/取消策略
预约状态我设计了四个:待就诊、已完成、已取消、爽约。用户提前24小时可以免费取消;24小时内取消会记录为违约一次,违约累计3次限制未来7天不可再预约;预约时间过去且用户未取消未就诊,状态自动置为爽约。
提醒通知使用Celery的定时任务实现,每天早上8点扫描当天所有待就诊预约,给主人手机号发送短信提醒,并推送站内信。这里有个经验:消息推送不能直接在同步请求里做。早期的版本是在提交预约成功后立刻发短信,用户下单耗时多了一秒多,高峰期短信服务超时还会导致预约接口报错。后来改成预约成功只生成一条待发送消息,Celery worker异步消费,下单接口的响应时间立刻降下来了。
5. 双框架间的事务一致性:共享数据库与异步补偿
一个项目两个框架,代码可以分开,数据却无法完全隔离。商城订单和预约单之间有关系,用户取消订单也可能要联动处理预约,这些跨框架的数据变更不能依赖HTTP调用去同步,否则一个服务抖动,另一个服务的数据就永久不一致了。
5.1 为什么不把所有表都交给Django管理
虽然两个服务连的是同一个MySQL,但表的使用边界划分得很清楚。Django管理的表包括用户、商品、SKU、订单、支付记录;Flask管理的表包括医生排班、预约slot、预约单、诊疗记录。骑跨在边界上的宠物档案表,我归到Django这边,因为宠物档案最终关联的是用户,而预约服务只读取这份数据。
如果每个服务都直接读写对方管理的表,事务边界会瞬间烂掉。举例来说,预约服务要修改订单状态完成支付联动,如果直接写订单表,Django这边的ORM缓存感知不到,后续用户查询就可能读到旧状态。我的规矩是:自己管好自己的表,跨服务的数据变更通过事件驱动。
5.2 跨框架的数据联动
提交流程是这样的:用户在商城里购买商品时,如果购物车中某个商品类别是疫苗套餐或体检套餐,订单创建后会产生一个“待履约”的预约资格。用户下单支付成功后,Django订单模块更新订单状态后,发送一个order_paid事件到消息队列。Celery worker消费事件后调用Flask服务的预约创建接口API,生成一张预占预约单,状态为“未排期”。
如果Flask服务暂时不可用,消息会留在队列中反复重试,不会丢数据。用户侧看到的是“支付成功,预约排期同步中”,等到Flask恢复,自动补齐预约单。这种异步最终一致性的思路,比同步调用A服务失败就要回滚B服务靠谱得多。
5.3 补偿任务与对账脚本
异步方案再稳,也无法保证100%不出现问题,队列消息丢失、Redis过期、服务长时间宕机这些极端情况都可能发生。所以我写了一个每日对账脚本,核心逻辑是:找出所有订单状态是已支付但近1小时内没生成预约单的记录,重新触发预约创建流程。
对账脚本还处理了另一类脏数据:预约单创建成功、但对应的订单在支付回调中被判定失败(比如金额不一致)。这类预约单会被自动置为失效,并把占用的slot名额释放出来,等待下一次对账或用户重新排期。
这类脚本一开始放在Django的management command里,后来发现涉及Flask服务的表,就单独建了一个audit/目录,两个框架都可以调用公共工具类,避免代码重复。
6. 联调、部署与上线后的运维备忘
开发和联调、部署上线是两码事,很多项目写完代码本地跑得欢,一上服务器就暴露问题。这一章整理我在部署前后遇到的高频坑,以及最后的进程编排方案,供你直接抄作业。
6.1 前端联调时最容易被忽略的CSRF和跨域
商城前端和后端是分开部署的,前端请求Django接口时,如果发送POST请求不带CSRF Token,会被403拦截。这个问题在本地联调时往往没问题,因为Django默认只校验带Session的请求,而本地开发可能直接用了@csrf_exempt,但生产环境CSRF校验一开,前端就全乱了。
解决思路很简单:所有写操作都通过自定义的X-CSRFToken请求头发送,Django中间件校验通过后才处理。Flask服务因为是无Session的纯API服务,跨域配置直接在CORS扩展里加白名单,注意不要用allow_origin='*',否则带着Cookie的请求会被浏览器拦截。这里我踩过一个隐蔽的坑:跨域白名单把前端测试域名配错了端口,前端拿不到数据,服务端日志一切正常,最后查了半小时CORS头才发现是端口不匹配。
6.2 Nginx+Gunicorn+Celery的进程编排
部署结构如下:Nginx监听80/443,反向代理到Django的Gunicorn和Flask的Gunicorn;Redis做缓存和锁;MySQL存数据;两个Celery worker进程分别处理商城任务和预约任务;Celery beat独立进程负责定时提醒与自动取消订单。
Gunicorn的worker数量没有随意拍脑袋,服务器是CPU 4核:
- Django worker数 = 2 * 4 + 1 = 9
- Flask worker数 = 2 * 4 + 1 = 9
- Celery worker并发数 = 4
这个公式是Gunicorn官方文档的推荐,亲测避免了worker过多导致内存溢出、worker过少导致请求排队的问题。每个worker单独跑满CPU性能不佳,是I/O密集型应用常见误区。
6.3 上线首月遇到的三个真实问题
第一个是数据库连接泄漏。Flask服务的数据库连接池容量默认较小,预约高峰期连接被占满,新请求排队等待,页面转圈。排查发现是SQLAlchemy的连接回收时间没设置,长连接断开后没有自动清理。修改为每300秒回收连接后问题消失。
第二个是Celery任务重复执行。订单自动取消任务在某个时刻被派发了多次,同一订单被重复检查、重复恢复库存。给任务执行加上了Redis分布式锁,保证同一个订单同一时刻只有一个任务在处理。
第三个是静态文件404。Django的DEBUG关闭后,静态文件不再由Django自己服务,必须交给Nginx配置alias路径。这个问题经典但容易忽略,经常是后台管理页面的CSS全没了,检查Nginx配置后才发现alias路径写错了一层目录。
部署时的关键配置项汇总如下:
| 配置项 | 推荐值 | 原因 |
|---|---|---|
| Gunicorn worker | 2 * CPU + 1 | 平衡并发与内存占用 |
| SQLAlchemy pool_size | 10 | 避免连接数过高打满数据库 |
| SQLAlchemy pool_recycle | 300秒 | 防止长连接失效 |
| Celery task_acks_late | True | 任务失败后可重新调度 |
| Redis key过期时间 | 锁/缓存按业务区分 | 杜绝死锁与脏缓存 |
| CSRF校验 | 生产环境全开 | 保证接口安全 |
聊到这里,这个系统的主体设计和实现已经比较完整了。说几个我在实际开发中的体会:双框架并没有让项目变复杂,反而让每个服务职责更清晰;商城和预约的联动采用异步最终一致性,而不是强一致,用户感知几乎没有延迟,但系统抗风险能力强了很多。最后再给一个建议:如果重做一次,我会在前期就把“宠物档案快照”和“slot展开算法”的边界定义得更早,这两块后面返工的成本比预想高不少。这个方向做完之后,其实还可以往处方药复购、疫苗到期提醒这些场景扩展,因为它们都建立在已经跑通的“用户-宠物-订单-就诊”闭环之上,加横向功能比另起炉灶要轻松得多。