Django+Flask双框架实战:房屋租赁信息管理系统架构与集成方案
看到“django-flask基于python的房屋租赁信息管理系统”这个标题很多人的第一反应是一个项目为什么要同时用两个Python Web框架又不是表演技术选型。我做这类系统做到后期才明白这个组合背后是非常现实的需求——既想要Django自带Admin后台带来的“管理端快速上线”又想要Flask轻量路由带来的“前台接口高可控”。这篇文章不聊空泛概念直接把这个房屋租赁信息系统的架构设计、核心模块、框架混部方案以及几个真正让我失眠的集成坑完整复盘一遍。内容既适合正在做毕业设计、课程项目的学生参考也适合公司内部要做类似管理系统的人拿来对比选型最起码能帮你少浪费一周时间。1. 为什么一套房屋租赁系统要同时用Django和Flask1.1 需求拆解信息管理系统的天然模块边界先别急着写代码。凡是做房屋租赁信息管理系统绝大多数需求拆开以后都长这样管理端房源录入、房源审核、租客管理、合同签署、账单记录、押金退付、数据统计。用户端房源搜索、房源详情查看、预约看房、提交入住申请、个人信息维护。通用能力用户登录注册、权限控制、系统配置、文件上传房源图片、身份证附件。问题来了管理端天然需要大量“表格化页面增删改查权限分配”Django在这一块几乎是开箱即用的自带Admin、自带用户体系、自带ORM迁移。而用户端如果也用Django那套CBV、DRF当然也不是不行但很多接口其实就是几个查询、一个预约提交完全没有必要把序列化器、视图集、路由注册那一整套沉重的机制塞进去。此时用Flask的蓝图路由代码量可以压缩到三分之一接口定义也直观得多。我当时做需求分析时最直观的感受是这个系统不是“一个单体应用”而是“两个靠数据紧密联系的子系统”。既然边界天然存在那么技术栈按边界划分就是合理的设计而不是炫技。1.2 两个框架的分工逻辑与选型理由先说结论Django管“管理后台数据持久层”Flask管“用户端查询接口预约流程”。这个分工基于三个考虑第一数据模型不用写两遍。Django的ORM在迁移、建表、字段校验方面非常成熟所有核心表结构都放在Django项目里定义和管理Flask侧不直接建立模型而是通过复用Django配置来读取同一套模型。这样既保证了“一套模型维护”又不需要重复定义Table类。第二Admin带来的效率提升太明显。房屋租赁系统里的房源状态流转待审核→已上架→已下架→已出租、合同状态流转待签署→生效→到期→退租在Django Admin里用list_filter、search_fields、actions就能搭出很好用的运营后台。如果换成Flask所有页面都要自己写模板、自己处理分页、自己做权限工程量至少多出两倍。第三用户端接口确实轻。这类系统的用户端访问场景是高频读、低频写。搜索房源、看详情、提交预约核心操作就这么几个。Flask的request.args解析、jsonify返回、Blueprint模块拆分写起来非常顺手也不强制你学习Django REST Framework那一套序列化规则。当然使用两个框架也有代价部署时要起两个服务、日志体系要分开、集成边界要处理。但这些代价会在第五章详细说比起“一个框架死磕到底”带来的额外开发量我认为是值得的。1.3 统一数据层共用一套MySQL的思路两个框架使用同一套MySQL数据库时最容易犯的错是“各建各的模型”。比如Django里定义房源表叫houseFlask里用SQLAlchemy又定义一张HouseInfo表字段名、索引、关联关系各搞一套后面数据同步起来就是灾难。我在项目中定的原则是** MySQL表结构只由Django负责创建和迁移Flask侧直接调用Django的ORM环境不另起映射**。也就是说Flask应用启动时会先初始化Django环境然后从Django的models模块导入模型类。这样两边的查询结果、字段属性、外键关系完全一致Admin后台修改的数据用户端接口立刻能读到。可能有人会问Flask里强行初始化Django不会很别扭吗实际操作是这样的# flask_app/__init__.py import os import django os.environ.setdefault(DJANGO_SETTINGS_MODULE, rent_system.settings) django.setup()之后在Flask的视图函数里就可以这样用from flask import Blueprint, jsonify, request from houses.models import House api_bp Blueprint(api, __name__) api_bp.route(/houses/int:house_id) def house_detail(house_id): house House.objects.filter(idhouse_id, statusonline).values( title, community, area, rooms, price, address ).first() return jsonify(house or {})这样做的最大好处是Django的模型字段比如FloatField、DecimalField、ForeignKey在ORM查询中自动完成类型转换SQLAlchemy那边还要自己维护一套Column定义完全没有必要。2. 系统整体架构与请求分发从一条URL说起2.1 两种混部方案对比与我的最终选择两个框架都写好了接下来最关键的问题是用户访问一个URL怎么决定发给Django还是Flask这里有两种主流方案。方案ANginx做HTTP层分发。Django用uWSGI监听9001端口Flask用Gunicorn监听5000端口。Nginx根据路径前缀转发/admin/开头走Django/api/开头走Flask/static/和/media/直接走静态文件目录。方案B单进程内用werkzeug的DispatcherMiddleware。把Django的WSGI应用和Flask应用挂到同一个进程由WSGI中间层按路径分发。两种方案我都在测试环境跑过最终选择了方案A。原因有三个进程隔离挂掉一个不影响另一个。Flask接口如果出现极端查询把进程打满Nginx可以直接摘掉它的权重Django后台不受影响。日志独立。Django的请求日志和Flask的访问日志可以分文件追踪排查用户端问题时不至于被后台的Admin扫描请求淹没。部署灵活。以后用户端接口如果要做网关、限流、独立扩容Nginx配置直接改权重就行不需要动代码。方案B适合学习或者单机低并发演示。完整代码很优雅但生产环境不太建议# wsgi.py 方案B示例 from werkzeug.wsgi import DispatcherMiddleware from django_wsgi import application as django_app from flask_app import app as flask_app application DispatcherMiddleware(django_app, {/api: flask_app})方案A的Nginx核心配置长这样server { listen 80; server_name rent.example.com; location /admin/ { uwsgi_pass 127.0.0.1:9001; include uwsgi_params; } location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static/ { alias /data/rent/static/; } location /media/ { alias /data/rent/media/; } }注意一个容易被忽略的点Django Admin的静态文件CSS、JS默认走Django的/static/路径Flask的静态资源也默认叫/static/。如果不用Nginx统一接管两个框架会互相抢静态资源后台样式丢失是很典型的混部问题。我直接用Nginx把/static/代理到collectstatic命令收集后的目录两边都不再自己管静态文件。2.2 Django侧的数据模型设计数据模型是整个系统的地基。我在这里只列出最核心的四张表以及它们之间关系设计的思路。用户表直接继承Django的AbstractUser额外增加手机号、用户类型、身份证号脱敏。用户类型分房东、租客、经纪人、管理员。注意保留username字段作为登录名手机号单独加unique约束不要直接用手机号替换USERNAME_FIELD否则创建超级用户、Admin登录都会踩坑。# accounts/models.py from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): user_type models.CharField( max_length10, choices((landlord, 房东), (tenant, 租客), (agent, 经纪人), (admin, 管理员)), defaulttenant ) phone models.CharField(手机号, max_length20, uniqueTrue, blankTrue) id_card models.CharField(身份证号(脱敏展示), max_length20, blankTrue)房源表核心是状态字段和多维检索字段。除了标题、面积、价格、户型这些基础字段特别要注意把社区、区域、朝向、装修建立成可检索字段。我用db_indexTrue给community、district、price、status分别加索引实际测试中查询速度提升非常明显。# houses/models.py class House(models.Model): STATUS_CHOICES ( (pending, 待审核), (online, 已上架), (offline, 已下架), (rented, 已出租), ) title models.CharField(房源标题, max_length100) community models.CharField(小区, max_length100, db_indexTrue) district models.CharField(区域, max_length50, db_indexTrue) area models.FloatField(面积(㎡), default0) rooms models.IntegerField(室, default1) halls models.IntegerField(厅, default1) price models.DecimalField(月租金(元), max_digits8, decimal_places2, db_indexTrue) deposit models.DecimalField(押金(元), max_digits8, decimal_places2) orientation models.CharField(朝向, max_length10, default南北) decoration models.CharField(装修, max_length10, default精装) floor models.IntegerField(所在楼层, default1) total_floors models.IntegerField(总楼层, default1) address models.CharField(详细地址, max_length200) landlord models.ForeignKey( User, on_deletemodels.PROTECT, related_namehouses, limit_choices_to{user_type: landlord} ) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultpending) description models.TextField(描述, blankTrue) published_at models.DateTimeField(发布时间, auto_now_addTrue)合同表合同是“人”和“房”连接的关键。这里用OneToOneField关联房源表示一个房源在某一时间段只能有一个生效合同。租客字段用ForeignKey指向User。额外记录月租金、押金、开始日、结束日、状态。# contracts/models.py class Contract(models.Model): STATUS_CHOICES ( (pending, 待签署), (active, 生效中), (expired, 已到期), (terminated, 已退租), ) house models.OneToOneField(House, on_deletemodels.CASCADE, related_namecontract) tenant models.ForeignKey(User, on_deletemodels.CASCADE, related_namecontracts) start_date models.DateField(开始日期) end_date models.DateField(结束日期) monthly_rent models.DecimalField(月租金, max_digits8, decimal_places2) deposit models.DecimalField(押金, max_digits8, decimal_places2) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultpending) created_at models.DateTimeField(auto_now_addTrue)付款流水表记录每笔租金的收缴、押金收付、退押记录。字段里必须有合同外键、金额、日期、类型方便后面做账单统计和逾期提醒。# payments/models.py class PaymentRecord(models.Model): PAY_TYPE ( (rent, 租金), (deposit, 押金), (refund, 退押金), ) contract models.ForeignKey(Contract, on_deletemodels.CASCADE, related_namepayments) amount models.DecimalField(金额, max_digits8, decimal_places2) pay_date models.DateField(付款日期) pay_type models.CharField(类型, max_length10, choicesPAY_TYPE) remark models.CharField(备注, max_length200, blankTrue)2.3 Flask侧API模块组织Flask部分没有做成一个大单文件而是按业务拆成三个蓝图房源检索、预约看房、合同查询租客本人查自己合同和账单。这样后期维护时很清晰。启动文件如下# flask_app/wsgi.py import os import django os.environ.setdefault(DJANGO_SETTINGS_MODULE, rent_system.settings) django.setup() from flask import Flask from flask_cors import CORS from flask_app.routes.house import house_bp from flask_app.routes.appointment import appointment_bp from flask_app.routes.contract import contract_bp app Flask(__name__) CORS(app) app.register_blueprint(house_bp, url_prefix/api/houses) app.register_blueprint(appointment_bp, url_prefix/api/appointments) app.register_blueprint(contract_bp, url_prefix/api/contracts) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)房源检索接口是用户端的核心接口最重要的一点是“查询集中过滤后才能分页”。如果先按页截取再过滤页数一多数据就错乱了。下面这个写法是标准的# flask_app/routes/house.py from flask import Blueprint, request, jsonify from django.db.models import Q from houses.models import House house_bp Blueprint(house, __name__) house_bp.route() def search(): qs House.objects.filter(statusonline) keyword request.args.get(keyword, ).strip() price_min request.args.get(min_price, typefloat) price_max request.args.get(max_price, typefloat) page request.args.get(page, 1, typeint) size min(request.args.get(size, 10, typeint), 50) if keyword: qs qs.filter(Q(title__icontainskeyword) | Q(community__icontainskeyword) | Q(address__icontainskeyword)) if price_min is not None: qs qs.filter(price__gteprice_min) if price_max is not None: qs qs.filter(price__lteprice_max) total qs.count() houses list(qs.order_by(-published_at).values( id, title, community, district, area, rooms, halls, price, deposit, floor, total_floors, orientation )[(page - 1) * size: page * size]) return jsonify({total: total, page: page, size: size, items: houses})注意一个细节values()里只查列表页需要的字段不要把整行数据都捞出来再丢给前端后面遇到大数据量你就明白这个习惯有多重要。3. 核心业务落地房源、租客、合同与账单3.1 房源信息管理与状态流转房源信息管理是管理后台最重要的模块。我在Django Admin里注册了房源模型并配置了list_display、list_filter、search_fields让运营人员可以快速查找和审核。# houses/admin.py from django.contrib import admin from .models import House admin.register(House) class HouseAdmin(admin.ModelAdmin): list_display (title, community, district, price, status, landlord, published_at) list_filter (status, district, orientation, decoration) search_fields (title, community, address) actions [make_online, make_offline] admin.action(description批量上架所选房源) def make_online(self, request, queryset): queryset.update(statusonline) admin.action(description批量下架所选房源) def make_offline(self, request, queryset): queryset.update(statusoffline)状态流转是这个模块最容易出bug的地方我用自定义表单约束来保证流程合理待审核房源只有审核通过才能上架已出租房源不能直接改回待审核下架房源可以重新上架但不能直接被删除。下面是审核表单的一个简化示例# houses/forms.py from django import forms from .models import House class HouseAuditForm(forms.ModelForm): class Meta: model House fields [status, audit_comment] def clean_status(self): status self.cleaned_data[status] current self.instance.status if current rented and status in (pending, online): raise forms.ValidationError(已出租房源不能重新变为待审核或上架状态) return status这里有一个实际运营中的教训不要允许随意删除房源。我最初天真地给Admin开了删除权限结果运营误删了一条还在合同期内的房源记录连带着合同关联都断了。后来我把delete权限去掉改成“已下架”状态替代删除数据完整性问题就少了很多。3.2 合同生命周期与租客账单合同的生命周期是从“待签署”到“生效中”再到“已到期”或“已退租”。我用一个独立的合同管理视图来处理状态变更而不是让运营直接改status字段。签署合同时要做三件事创建合同、把房源状态改成“已出租”、生成第一笔租金账单。这三件事必须在一个数据库事务里完成否则会出现合同生效了房源还是“已上架”的脏数据。# contracts/services.py from django.db import transaction from .models import Contract from houses.models import House from payments.models import PaymentRecord def create_contract(house, tenant, start_date, end_date, monthly_rent, deposit): with transaction.atomic(): contract Contract.objects.create( househouse, tenanttenant, start_datestart_date, end_dateend_date, monthly_rentmonthly_rent, depositdeposit, statusactive ) house.status rented house.save(update_fields[status]) PaymentRecord.objects.create( contractcontract, amountdeposit, pay_datestart_date, pay_typedeposit, remark签约押金 ) return contract租金账单是每个月周期性产生的。我采用的方式是写一个Django management command每天凌晨扫描生效中的合同如果当前日期是合同开始日到结束日之间的对应付款日就自动为该合同创建一笔租金账单。这里有个细节同一合同同一个月绝对不能生成两笔租金我用“合同年月”唯一索引保证幂等。# payments/management/commands/generate_rent_bills.py from django.core.management.base import BaseCommand from django.db.utils import IntegrityError from django.utils import timezone from contracts.models import Contract from payments.models import PaymentRecord class Command(BaseCommand): help 每天生成当月应付租金账单 def handle(self, *args, **options): today timezone.localdate() contracts Contract.objects.filter( statusactive, start_date__ltetoday, end_date__gtetoday ) for contract in contracts: year_month today.strftime(%Y-%m) exists PaymentRecord.objects.filter( contractcontract, pay_date__yeartoday.year, pay_date__monthtoday.month, pay_typerent ).exists() if not exists: try: PaymentRecord.objects.create( contractcontract, amountcontract.monthly_rent, pay_datetoday, pay_typerent, remarkf{year_month}月租金 ) self.stdout.write(f已生成账单: 合同{contract.id} {year_month}) except IntegrityError: pass这个脚本用crontab配置每天早上8点执行一次运营后台第二天早上就能看到当天的应收账单比手动录入省了太多时间。3.3 管理后台的定时任务租金提醒与到期统计定时任务除了自动生成租金账单还有一个核心功能就是“提前提醒”。我在合同生效期间设定一个时间窗合同剩余30天时提醒经纪人联系租客是否续租账单逾期7天未支付时提醒运营人员电话催缴。实现方式同样是management command每天扫描数据然后把待办事项写入一个提醒表。后台的首页看板直接读取这张提醒表运营一登录就能看到“今天需要处理的事情”而不是自己去逐个合同翻。到期统计这块我也顺带做了合同结束日与租客退房日可能会不一致退房时如果实际退房日期晚于合同结束日期系统会自动按天计算超期租金并在退押金时扣除。这个规则一开始没有实现后来运营手工算错了一笔押金被投诉了才认真补上。4. 工期中让我失眠的五个集成细节4.1 CSRF与跨框架表单提交Django默认在所有的POST表单里要求CSRF token而Flask API默认不做这个校验。两个框架共存以后最容易出现的问题是管理后台的表单可以正常提交但从Flask页面或外部系统调用Django的登录接口时CSRF校验把请求拦住了。我的处理方式是Django侧保留完整的CSRF保护凡是模板渲染的表单都带{% csrf_token %}。Flask侧作为API服务使用JWT做身份校验不碰CSRF。如果未来要做“从用户端直接提交到Django后台”的集成就得显式获取CSRF cookie并且把token放到请求头里这个逻辑需要单独封装不能让Flask直接裸调Django视图。4.2 会话体系互不信任这是两个框架混部时最隐蔽的坑。Django有自己基于session的登录态Flask也有自己的session两者互不通用。用户如果在Flask用户端登录了再访问Django Admin依然需要重新登录。反之亦然。我的解决方案是统一使用JWT用户端登录后拿到Access Token后续请求都带Authorization头。而Django Admin的内部登录仍然使用自身的session不对用户端开放。这样两个框架的会话体系完全隔离互不干扰。运营人员用Admin后台普通用户用用户端API边界清晰也不用搞什么双token同步的复杂逻辑。但有一个点必须提醒如果Flask接口需要识别当前用户是否是管理员你可以调用Django的User模型验证JWT里的用户ID和权限但绝对不要直接修改Django session表。跨框架的用户身份判断只依赖token不依赖框架内部的会话存储。4.3 静态文件与后台样式丢失我在这上面浪费了整整一个下午。现象是Django后台页面加载出来只有纯文字CSS和JS全部404。排查后发现是Nginx的location /static/规则把所有静态文件都引到了Flask的静态目录而Flask根本没管Admin的静态文件。解决办法就是前文说的用collectstatic把Django所有App的静态文件统一收集到项目外部目录Nginx直接alias到那个目录。同时把Django的STATIC_ROOT设置为绝对路径避免相对路径在不同部署环境下解析错乱。Flask侧也是一样如果它需要自己的JS/CSS不要放在和Django冲突的/static/下改用蓝图的static_url_path/assets/或者直接把前端静态资源交给CDN。4.4 时区与租金日期边界租金账单的生成逻辑看似简单真正跑起来以后发现时区问题很恶心。服务器默认是UTC而我们的业务时间都是北京时间如果直接用timezone.now()的日期来生成账单在UTC时间晚上8点到12点之间对应北京时间已经是第二天的凌晨账单日期就错位了。我在项目中定了一个规则所有业务日期计算使用timezone.localdate()也就是把当前时间转成Asia/Shanghai时区后再取日期。所有管理命令在执行时也统一使用这个函数不直接调用datetime.now()。这个坑在开发环境几乎碰不到因为开发机的系统时区通常已经被设成北京时区一旦换到云服务器部署就现出原形。合同到期日还有另一个边界问题如果合同到期日是当天但租客还没搬走当日生成的“逾期提醒”会对运营造成干扰。我的处理方式是提醒脚本里设置一个宽限期到期日24小时内不提醒超过24小时才进入待办列表。4.5 并发与数据一致性一个典型场景同一个房源被两个租客同时提交签约申请系统必须保证只有一个能签约成功。如果代码不处理并发很可能同时读到的都是待签约状态最后产生两个生效合同。解决办法是在签约操作中使用select_for_update锁住房源记录并且在合同表里用OneToOneField约束“一房一合同”。即使两个请求同时进来数据库层的唯一约束也会保证只有一个事务能提交成功另一个只能捕获IntegrityError或等待锁释放。from django.db import transaction def sign_contract(house_id, tenant_id, ...): with transaction.atomic(): house House.objects.select_for_update().filter(idhouse_id).first() if not house or house.status rented: raise ValueError(房源不可签约) # 创建合同、更新房源状态、创建账单...这个锁只对同一数据库内的事务有效如果是分库分表架构就要换成分布式锁但在这个规模的项目里select_for_update已经足够。5. 上线前后的性能优化与后续扩展5.1 查询慢的根源与索引设计系统上线一个月后数据量涨起来用户端搜索接口开始偶尔变慢。我打开慢查询日志发现主要慢在三个地方搜索条件只用了项目最开始的单列索引多条件组合查询时MySQL只能选其中一个索引其他字段都在回表。页面列表接口把description、address这类长文本字段也查出来了传输数据量大。按价格排序时如果只用ORDER BY price而字段索引里没有包含statusMySQL需要先过滤再排序数据量一上来就慢。我的优化动作是加了一个组合索引CREATE INDEX idx_house_status_price_pub ON house(status, price, published_at);同时把所有列表查询都改成了values()指定返回字段禁止SELECT *。改造以后接口的P95响应时间从720ms降到110ms效果还是很明显的。这里要特别提醒Django使用者ORML的.filter()能用查询集链就不要在Python里做二次过滤否则数据库层无法用到索引。就算用Python过滤能跑通数据量大了以后代码的复杂度会指数上升。5.2 缓存、异步任务与部署形态搜索接口和房源详情接口是用户端最频繁的读操作我在Nginx前面加了一层Redis缓存。房源详情页直接缓存完整JSON设置5分钟过期搜索列表按“关键词价格区间页码”拼缓存key过期时间设为30秒。这样就算某个时段访问量突然上来数据库压力也很有限。租金账单生成类的定时任务是纯计算任务没有拆异步队列因为数据量不足以支撑额外的Celery复杂度。但如果后续要做图片压缩、短信通知、电子合同PDF生成这时候就值得引入消息队列了。我当时把部署形态分为两层Nginx负责入口和静态文件Django和Flask分别由uWSGI和Gunicorn托管。两个服务的内存占用都不高跑在2核4G的服务器上完全够用。上线后我还做了两件容易被忽视的事一是每个接口都加了统一的JSON返回结构无论成功失败都返回code、message、data前端不用到处判断异常格式二是把异常日志和业务日志分开用loguru把Flask的日志写到独立文件Django的错误日志由标准logging管理排查问题时非常清爽。5.3 还能往哪里扩展这套架构做稳定以后扩展方向其实很明确。搜索可以上Elasticsearch把房源标题、小区、区域、地址都建立全文索引解决LIKE %keyword%无法走索引的问题。预约看房成功以后可以接短信通知或者企业微信机器人提醒经纪人。合同到期前30天提醒也可以升级成自动推送消息给租客端而不只是后台看板。最有价值的扩展我认为是做数据报表把每月的租金应收、实收、逾期率、房源空置天数算出来给运营做经营决策参考。这些统计用Django的ORM聚合函数比用Python手算高效得多我后来的版本里加了按月收入趋势和户型分布统计运营反馈整个团队的工作“心里有数”了。最后分享一个小技巧在Flask接口里复用Django ORM时一定要在应用初始化阶段就把django.setup()执行完千万不能放到视图函数里。我第一次就是放在请求里才初始化结果gunicorn多worker场景下每个worker重复初始化数据库连接池直接被打爆。把它放到wsgi.py顶层执行后运行了近半年再没出过类似问题。