Django+Flask混合架构:宠物商城与医疗预约系统设计实战
近两年接到的Python全栈类咨询里出现频率很高的一类题目就是电商预约组合系统比如这个django-flask基于python的宠物食品商城医疗预约系统设计与实现。它几乎把Web开发的核心模块都占全了用户体系、宠物档案、商品SPU/SKU、购物车、订单、支付回调、医生排班、预约冲突检测、后台管理横跨Django和Flask两个主流框架。这篇文章我会完整拆解这个项目的业务设计、技术选型、表结构、核心代码、部署方式和踩坑记录重点讲清楚为什么这么设计代码为什么这么写哪些坑必须避开给正在做毕业设计、作品集或者想从单一框架转向混合架构的开发者一份能直接抄作业的参考。在写正文之前先说明技术选型的总体结论这个项目我采用的是Django做主站业务、Flask做预约API的混合结构两个应用共享同一个MySQL库用Redis统一登录态通过Nginx做统一入口。这个方案在当前场景下合理且可落地但它也有边界条件。下面我从头讲起。1. 项目定位与需求拆解1.1 为什么把商城和预约放进同一个系统养宠人群的线上行为主要集中在两类日常囤货主粮、零食、猫砂、驱虫药和定期就医疫苗、体检、皮肤病复诊。这两类场景的用户高度重合但业务形态差别非常大。商城是典型的商品-购物车-订单-支付电商链路医疗预约是典型的资源调度-时间槽-状态变更服务链路。把它们放在同一个系统里最大的收益是数据主线的打通。用户可以只注册一次宠物档案在商城下单和预约挂号时都能用后台运营人员能同时看到订单量和就诊记录从产品角度也可以在用户购物后主动推荐附近的体检套餐或者在看病后推荐对应处方食品。这种组合不是拍脑袋凑功能而是有明确的业务闭环逻辑。1.2 django-flask双框架的分工思路很多新手最不理解的一点是Django已经够强大了为什么还要引入Flask我在实际项目里总结了几类合理动因模块边界差异明显。商城模块需要大量Model定义、Admin后台、表单校验、分页组件Django全家桶能显著减少重复劳动预约模块本质上是对外输出JSON的API服务讲究轻量和灵活Flask加SQLAlchemy足够连Django的Admin都用不上。团队协作与技能复用。如果两个人一起开发一个熟Django、一个熟Flask按业务边界拆开各自维护自己的代码库合并冲突也比同框架硬碰少。未来扩展预留。Flask端后续可以独立拆成微服务承接排班计算、预约提醒推送、高并发时段查询等任务Django主站保持相对稳定。要强调的是双框架不是必须的纯粹用一个Django也能完成所有功能。如果你打算仿照这个思路做必须先确认两件事数据边界是否清晰用户身份是否能统一。边界不清晰的话双框架会让项目变成维护噩梦。1.3 适合谁来参考这个项目适合三类人需要综合型选题的学生、想丰富作品集的求职者、以及想搞明白多应用协作的读写爱好者。它能一次覆盖RBAC权限、文件上传、富文本内容、订单状态机、并发控制、接口鉴权、定时任务、部署上线等高频技能点。每项技术单独拎出来都不难但串在一起对工程能力的要求是很全面的。2. 系统架构与模块设计2.1 四层架构与请求流向整个系统从部署视角看分成四层接入层Nginx监听80/443分发静态资源按路径把动态请求转发给两个后端服务。应用层Django实例跑在8000端口处理商城与后台Flask实例跑在5000端口处理预约相关接口。服务层MySQL保存所有业务数据Redis保存会话token、热点数据和分布式锁。依赖层支付网关回调、短信通知服务开发期用日志模拟、对象存储商品图片。请求流向大体是浏览器访问页面路径Nginx把请求转发到DjangoDjango返回渲染好的HTML前端页面里的预约模块发起AJAX请求到/api/appointment路径Nginx把这条路径转发到FlaskFlask返回JSON。因为所有请求都走同一个域名、同一个Nginx天然避免了跨域问题这个设计在上线时非常省心。2.2 功能模块全景下面这张表是整个项目拆分后的模块归属开发时最好按表里这个边界建代码目录和仓库不要混着写。模块所属应用核心功能用户模块Django注册登录、个人中心、收货地址管理宠物档案Django宠物信息CRUD、疫苗记录、体重与过敏史商品模块Django商品分类、SPU/SKU管理、库存管理购物车与订单Django购物车、订单状态机、支付回调处理搜索与筛选Django商品名搜索、价格区间、品类筛选排班管理Flask科室、医生、排班时段配置预约模块Flask时段查询、预约提交、取消预约通知服务Flask预约短信/站内信提醒后台管理Django Admin商品、订单、医生、排班的一体化管理这个表看起来简单但它决定了数据库表怎么分、接口怎么对。后面写代码时凡是跨应用的数据操作都要格外小心比如订单创建时要不要校验宠物档案预约时要不要查用户的会员等级这类跨模块逻辑尽量收敛到接口层不要散落在各处。2.3 两条核心业务链路商城下单链路用户浏览商品列表和详情 → 加入购物车 → 在购物车页修改数量并提交订单 → 创建待支付订单并锁定商品库存 → 用户跳转支付 → 支付网关回调接口更新订单为已支付 → 后台发货 → 用户确认收货。预约挂号链路用户选择就诊宠物 → 选择科室内科、外科、疫苗接种等 → 查看医生可约时段 → 提交预约申请 → 后端校验该时段剩余名额和该宠物是否已存在有效预约 → 生成预约单 → 用户支付定金可选 → 预约成功系统在指定时间前发送提醒。两条链路有一个共同特征都存在检查然后操作的竞态条件。商城是查库存数减少库存预约是查剩余名额再占用名额如果没有并发的正确姿势系统一上真实流量就会出bug。后面第4部分会专门讲这两块代码怎么写。3. 数据库设计数据实体与核心表结构3.1 商城侧SPU/SKU与订单商品设计要区分SPU和SKU。SPU是商品概念比如全价猫粮三文鱼配方SKU是具体的售卖单元比如2kg装、5kg装它们价格和库存都不同。拆分之后以后做按宠物类别推荐、多规格选择都会方便很多。orders表是商城最重要的表字段设计要注意几个点order_no要加唯一索引用业务规则生成比如202501011200001234。status用smallint0待支付、1已支付、2已发货、3已签收、4已取消、5售后中用状态机约束合法流转。下单时把收货信息直接以JSON快照方式存进order而不是下单后再去关联收货地址表避免用户改地址导致历史订单信息错乱。与京东、淘宝的逻辑一样订单明细要独立成order_item表记录每个SKU的单价、数量、快照名称和快照图片防止后续商品改价影响历史订单。3.2 预约侧排班表与预约表的关系预约系统的表结构核心就两张表排班表schedule和预约表appointment。排班表字段大致如下字段类型说明idint主键doctor_idint医生ID外键到doctorwork_datedate出诊日期start_timetime时段开始时间end_timetime时段结束时间slot_limitint该时段最大可预约数booked_countint当前已预约数statussmallint0正常 1取消 2停诊关键设计是加上唯一索引(doctor_id, work_date, start_time)。这样在同一日期同一医生同一开始时间不可能出现两条排班从数据库层面防止重复数据。预约提交时对schedule行加锁更新booked_count具体代码见第4部分。appointment表字段大致如下id、appointment_no、user_id、pet_id、doctor_id、schedule_id、visit_date、visit_time、status0待支付、1已预约、2已完成、3已取消、4爽约、remark。有一个细节值得说明为什么排班和预约拆分而不是直接在appointment里存医生和日期因为同一时段可能有多个名额如果不拆分就无法处理一个时段被约多次的场景也就没法做名额限制。3.3 连接两边的宠物档案宠物档案是连接商城和预约的核心实体。一个用户可以养多只宠物所以是user 1对多 pet。pet_profile表字段id、user_idname、species猫/狗/其他breed品种birthday用于计算年龄判断疫苗和体检建议genderweightallergy_info过敏史vaccine_recordJSON数组保存疫苗类型和接种日期这套设计的价值在于商城的按宠物年龄推荐主粮、预约的幼猫首次免疫方案都可以基于这份档案做规则判断。比如某宠物已经10岁以上推荐体检套餐时自动推送老年宠专项这些是后期加分的差异化功能也方便在文档里写系统具备智能推荐能力。4. 核心功能实现与关键代码4.1 订单模块并发扣库存的正确姿势先看错误示范很多初次写商城的人会写取出库存减一再存回去这在单用户测试时没问题一旦并发就会出现超卖。正确的做法是使用数据库行级锁或者条件更新。下面是用Django ORM实现的行级锁写法注意必须在transaction.atomic内运行from django.db import transaction from django.db.models import F transaction.atomic def create_order(user, sku_id, count): # 锁定该SKU行 sku GoodsSKU.objects.select_for_update().get(idsku_id) if sku.stock count: raise ValueError(库存不足) # 扣减库存 sku.stock - count sku.save(update_fields[stock]) # 生成订单号和订单明细 order Order.objects.create( order_no_gen_order_no(), useruser, total_amountsku.price * count, status0 ) OrderItem.objects.create(orderorder, skusku, countcount, pricesku.price) return orderselect_for_update会对该行加写锁直到事务提交或回滚才释放。同一时间其他事务如果也想锁这一行就会阻塞等待。这样库存判断和扣减就是原子操作。如果不想锁行也可以直接走一步条件更新updated GoodsSKU.objects.filter(idsku_id, stock__gtecount).update(stockF(stock) - count) if not updated: raise ValueError(库存不足)条件更新的思路是把判断放进where里影响行数为0就说明库存不够。两种方案都能用行锁方案更直观条件更新方案少一次锁等待。我个人在业务逻辑复杂、后续要同时建订单表数据的场景下偏向使用事务内行锁因为可以保证整个订单创建过程的原子性。提示支付回调必须做幂等。回调接口先根据order_no查订单状态如果已经是已支付直接返回成功不要再次改状态、不要再次扣库存、不要重复加销量。4.2 预约模块时间槽冲突检测Flask端用SQLAlchemy写预约提交的逻辑核心是防超卖和防重复预约。下面这个函数可以直接复用# appointment_service.py from sqlalchemy.orm import sessionmaker from models import Schedule, Appointment def book_appointment(user_id, pet_id, doctor_id, schedule_id): with Session() as session: try: # 1. 锁定排班行 schedule session.query(Schedule).filter( Schedule.id schedule_id ).with_for_update().one() # 2. 判断名额 if schedule.booked_count schedule.slot_limit: return {code: 40002, msg: 该时段已约满} # 3. 判断宠物是否已有有效预约同一时段 exists session.query(Appointment).filter( Appointment.schedule_id schedule_id, Appointment.pet_id pet_id, Appointment.status.in_([0, 1]) ).first() if exists: return {code: 40003, msg: 该宠物在此时间段已有预约请重新选择} # 4. 占用名额并创建预约单 schedule.booked_count 1 appointment Appointment( appointment_no_gen_no(AP), user_iduser_id, pet_idpet_id, doctor_iddoctor_id, schedule_idschedule_id, visit_dateschedule.work_date, visit_timeschedule.start_time, status0 ) session.add(appointment) session.commit() return {code: 0, msg: 预约成功, data: {appointment_no: appointment.appointment_no}} except Exception as e: session.rollback() return {code: 50000, msg: 服务器异常请稍后重试}这里的核心是with_for_update()它锁住schedule这一行后其他人来查这条排班都会等锁等前一个事务提交后看到的最新booked_count就是正确的。如果不用这个锁两个请求同时读booked_count0然后都加1就会出现超卖。另外同一宠物同一时段不可重复预约的判断也要注意。应用层判断虽然做了但数据库层面最好再加一道保险。MySQL支持唯一索引可以设计一张约束表或者调整表结构使宠物排班在有效状态内唯一否则删除或取消状态的记录会被唯一索引卡住需要后续优化状态设计。稳妥的做法是在应用层用锁保护同时定时清理异常重复数据大多数中小场景够用。4.3 两端认证打通Token方案Django和Flask要识别同一个用户最简单的方案是共享Token Redis。用户登录在Django完成Django生成一个随机Token把userId写进Redis过期时间设置为7天。Flask端不查数据库用户表只查Redis就能确认身份。Django登录接口示意import uuid, redis r redis.Redis(host127.0.0.1, port6379, db0) def login(request): username request.POST[username] password request.POST[password] user authenticate(usernameusername, passwordpassword) if user is None: return JsonResponse({code: 40001, msg: 用户名或密码错误}) token uuid.uuid4().hex r.setex(ftoken:{token}, 7 * 24 * 3600, user.id) r.setex(fuser:{user.id}, 7 * 24 * 3600, token) # 用于注销时定位token return JsonResponse({code: 0, token: token, username: user.username})Flask端写一个登录校验装饰器from functools import wraps from flask import request, g import redis r redis.Redis(host127.0.0.1, port6379, db0) def login_required(fn): wraps(fn) def wrapper(*args, **kwargs): token request.headers.get(Authorization, ).replace(Bearer , ) user_id r.get(ftoken:{token}) if not user_id: return {code: 401, msg: 未登录或登录已过期}, 401 g.user_id int(user_id) return fn(*args, **kwargs) return wrapper这样做的优点是两端都无状态不依赖Django的session也不需要在Flask里维护一套用户密码体系。注销时Django里把两个key都删掉即可。要注意的是Redis的过期时间要设置合理太短影响体验太长不安全一般7天合适。5. 双框架协同的工程细节5.1 同一个数据库如何配置Django和Flask连同一个MySQL库这是双框架方案的关键前提。Django的配置如下DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: pet_shop, USER: pet_user, PASSWORD: os.environ.get(DB_PASSWORD), HOST: 127.0.0.1, PORT: 3306, CONN_MAX_AGE: 60, OPTIONS: {charset: utf8mb4}, } }Flask的SQLAlchemy配置app.config[SQLALCHEMY_DATABASE_URI] mysqlpymysql://pet_user:{}127.0.0.1:3306/pet_shop?charsetutf8mb4.format(os.environ.get(DB_PASSWORD)) app.config[SQLALCHEMY_ENGINE_OPTIONS] { pool_pre_ping: True, # 连接前探活避免MySQL重启后拿到死连接 pool_recycle: 3600, # 一小时后回收连接防止MySQL wait_timeout断开 }这里有个最容易踩的坑Django建表默认表名是appname_modelname比如orders表默认可能是shop_order。Flask里SQLAlchemy如果直接查这张表要么在模型里指定__tablename__ shop_order要么让Flask只负责预约相关物理表的独立管理。我最开始没注意这个问题结果Mr.Flask一直报table does not exist后来一看数据库实际表名和代码模型对不上浪费了大半天。5.2 跨模块一致性怎么保证当订单创建时需要同时更新宠物档案的就诊次数或者预约完成后要给商城积分这类跨应用操作不能写在同一个数据库事务里。我的处理原则是优先保证主业务的一致性次要业务用补偿或异步方式。拿预约成功后发短信来说主业务是写预约表发短信通知是副作用。正确做法是先提交预约事务再把发送短信这个任务丢到Redis队列或本地任务表由独立worker消费。如果短信服务挂了不影响预约主流程只需重试队列。如果两个应用真的要在一个事务里操作对方业务的数据我一般不建议硬做。简化版做法是通过REST接口互相调用。比如Flask预约成功后调用Django侧的一个HTTP接口记录一条动态失败了记日志重试即可。小型项目不要为了追求分布式一致性而上复杂中间件保持简单才能按时交付。5.3 定时任务与状态处理预约提醒和超时关单是这个项目必做的定时任务。预约提醒Flask端用APScheduler每5分钟扫一次appointment表找出就诊时间前24小时且status1的记录发送站内信或短信然后标记提醒状态。超时关单商城订单30分钟未支付需要自动取消并释放库存。Django端可以写一个management command配合系统crontab每分钟执行也可以直接在Django里加APScheduler。我的建议是小项目别引入CeleryAPScheduler在单机场景非常够用。定时任务里最大的坑是任务重复执行。如果用crontab要保证上一次任务没跑完时不会启动下一个实例简单做法是用Redis的SETNX做一个分布式锁拿到锁才执行任务执行完释放。6. 部署上线实践6.1 生产环境进程与Nginx商城与预约虽然用不同框架开发但上线可以放在同一台服务器。进程方式Djangogunicorn绑定127.0.0.1:80004个worker。Flaskgunicorn绑定127.0.0.1:50002个worker单独处理API请求。定时任务启动一个额外的APScheduler进程只跑定时任务。Nginx配置的核心是路由分流server { listen 80; server_name pet.example.com; client_max_body_size 20m; location /static/ { alias /var/www/pet_shop/static/; } location /media/ { alias /var/www/pet_shop/media/; } location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } 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; } }这样浏览器访问/api/appointment/list时请求会被转发到Flask其他路径都交给Django。因为所有请求都通过同一个Nginx入口、同一个域名前端和后端之间不存在跨域问题预约模块的页面里写AJAX请求会很轻松。6.2 环境配置与安全生产环境和开发环境的配置必须隔离。我习惯用环境变量加.env文件管理敏感信息不写死在代码里。重点检查这几项DEBUG必须为False否则一报错就把堆栈暴露给用户了。SECRET_KEY不要提交到版本库用环境变量注入。MySQL账号要单独建一个专用账号不要用root。Redis绑定127.0.0.1并设置requirepass生产环境不要裸奔。另外Django的python manage.py check --deploy会在上线前帮我们发现安全配置问题这是很多新手忽略的工具建议跑一遍看报告逐项处理。6.3 数据库迁移与备份Django端用migration管理表结构但要注意Flask负责的预约相关表要规定好谁来维护。我推荐让Django也把预约相关表做成migration统一由Django生成和管理表结构Flask只负责查询和写入不负责建表。这样避免两套建表脚本互相覆盖。上线前的备份策略不能省略每天全量备份MySQL每小时备份binlog至少保留近7天备份。宠物医院场景涉及病历数据数据安全等级高不能儿戏。7. 常见问题排查速查7.1 商城模块的典型问题问题现象可能原因处理办法下单提示库存不足但数据库库存还有查询和更新不是原子操作并发读到旧值用select_for_update或条件更新扣库存支付回调成功订单还是待支付回调接口没有正确的事务提交或订单号匹配失败先按order_no查询订单校验金额后再更新状态同一订单收到两次回调库存被扣两次回调处理没有幂等更新前检查状态已支付直接返回成功不重复扣库存订单取消后库存没恢复只有在支付成功时才扣库存取消应该恢复统一走订单状态机确保状态变化时执行对应库存操作商城侧大多数问题都出在并发和幂等这两件事上。我的建议是写订单和库存相关代码时先画出状态机和所有触发入口哪些入口能改库存一目了然排查问题也快得多。7.2 预约模块的典型问题问题现象可能原因处理办法页面显示有余号提交却说已约满并发请求导致booked_count超限用事务内锁schedule行再判断名额同一宠物同时约了两个时段应用层判断漏了并发场景加锁后再校验或者数据库加唯一约束医生停诊用户预约没被通知停诊操作只改状态没触发通知停诊时批量更新未完成预约状态并发送通知查询空闲时段特别慢schedule表没有建立组合索引给(doctor_id, work_date, status)建联合索引页面显示有余号和提交时已约满差别就在于查询和提交之间是否有锁保护。预约系统凡是遇到名额判断必须出现行锁或条件更新否则流量稍大必然出错。7.3 双框架集成的典型问题问题现象可能原因处理办法Flask查不到Django建的表表名不符Django默认带前缀在Flask模型里显式__tablename__指向实际表名Flask写的数据Django读不到连接了不同的数据库检查两端连接串的NAME是否一致Token在Django生成了Flask不认Redis的key命名不统一统一约定token前缀不要各写各的预约接口偶尔报数据库连接错误连接池长期空闲被MySQL断开SQLAlchemy配置pool_pre_ping和pool_recycle双框架集成的坑多数是假设不一致造成的不是技术方案本身的问题。所以在一开始就要把接口约定、表名约定、Redis key约定、错误码规范写成文档两边照着约定走能省下80%联调时间。结语一点个人体会这类双业务、双框架项目做下来我最大的体会是真正难的不是某个框架怎么用而是面对两个应用、一个共享数据库时的边界意识。数据表由谁维护、状态由谁更新、跨端逻辑在哪里收敛这些问题如果在开发第一天就定清楚后期会很顺畅。再送一个小技巧不要一上来就追求Django和Flask各自独立部署在不同服务器先把两个进程都放在同一台机器上跑通全部流程再按压力测试结果决定要不要拆。混合架构的第一步是能一起跑第二步才是拆得开。希望这篇拆解能帮你少走弯路把项目顺利做完。