
我去年帮一个城中村片区做过一套民宿租赁管理系统需求方对技术栈就一句话用Python写网上说可以“django-flask”。这句话背后藏着很多开发者都遇到过的问题——到底是用Django还是Flask还是两个一起上实际把系统跑起来之后我对这个问题的答案比之前清晰了很多。这篇文章就是完整的复盘从业务调研、架构取舍、数据模型到Django实现核心业务、Flask做轻量查询API再到上线后踩过的几个真坑。如果你也在规划类似的租赁管理系统不管是毕业设计还是外包项目这篇应该能帮你少走不小弯路。1. 城中村民宿租赁到底在租什么、管什么很多人一看“民宿租赁管理系统”下意识就套用酒店PMS或者长租公寓SaaS的那套理解。但城中村的租赁业务和这两者都不一样差别非常具体直接决定了系统该怎么做。1.1 城中村民宿的业务形态与系统目标我服务的这个片区房东大多是本地村民一栋楼往往五六层房东自己住一层剩下全部拿来出租。租客成分很杂附近服务业的年轻人、小商贩、短期的工地工人。租期也非常混乱日租、周租、月租、季租混着来而且经常是“先住后补合同”。这就导致原来的管理方式几乎全是纸质的一个本子记房态一个本子记账。到了月底要算水电费得挨个房间看电表读数再用计算器按一遍。催缴租金靠微信语音。房东不是不想用系统而是市面上的酒店系统太重、太贵长租公寓系统又假设租约都是半年一年起步搬到城中村场景里完全水土不服。所以这系统要解决的核心问题就三条第一房态可视化哪间房空着、哪间房今天到期打开手机一眼能看明白第二账目清晰押金、定金、租金、水电费、退款每一笔钱都有来路和去处月底自动出账单第三提醒到位谁该交租了、谁的租约快到期了提前提醒不用人肉翻本子。另外还有一个隐形刚需操作门槛必须极低。实际用这套系统的人不是IT从业者而是五十多岁的房东和前台阿姨。如果界面绕来绕去他们宁可重新拿回本子。1.2 用户角色低保真后台才是刚需系统我把角色分成四类这个划分后来证明非常关键系统管理员管账号、权限、系统参数通常是我自己或者技术维护方。房东/管家管自己的楼栋和房间录房源、看房态、发起退房退款。收银/前台负责实际办入住、收押金、收租金他们最在意“收钱路径短”。租客最轻量只查自己的合同、账单和租期剩余天数再用一个报修入口。这四类角色对界面的要求完全不同。房东和管理员喜欢“表单化”的后台而前台和租客则更依赖手机页面。这也是我后来坚持“Django做后台、Flask做手机端查询API”的一个原始动机如果只做一个纯Web后台前台阿姨是不可能抱着一台电脑到处跑着办入住的。1.3 一条完整租赁链从空房到退房把业务抽象成一条链路系统其实就干一件事让房间在“空置、预定、已住、保洁、维修”这几个状态里稳定流转并在流转过程中把该收的钱收清楚。一条典型链路是空房被看到 - 租客交定金预定 - 到期办理入住签合同、收押金、收首期租金 - 租期中按约定周期生成租金账单 - 水电费按表录入 - 退房检查 - 扣除水电和损坏费用 - 退还押金余款 - 房间转保洁 - 重新上架为空房。每个环节都伴随至少一笔账系统设计的时候必须把“房态流”和“资金流”同步考虑而不能分开设计——这是我踩过坑后最深刻的体会。分开设计的结果就是状态对不上账后期非常痛苦。2. Django为主、Flask为辅这套组合架构怎么落地标题里写“django-flask”不同人理解不一样。有人以为是要同时用两个框架有人只是想表达“用Python系的框架都行”。从实际项目出发我把这两种理解都照顾到了。2.1 为什么选型上来就会是“django-flask”这种说法我判断这种说法大概率来自课程设计和外包报价场景。Django和Flask是国内Python Web项目里两个最常被提到的框架写标书的人并不确切知道它们的区别于是干脆并列写上。但作为真正动手实现的人必须做一次明确的取舍而不是稀里糊涂地“两个都是”。我的取舍标准是看模块成熟度。这个系统涉及到用户登录、后台管理、数据建模、权限控制这几块Django的Admin、ORM、auth体系几乎是白送的开发效率极高。而手机端需要一个轻量的查询接口如果用Django直接写也不是不行但Flask写只读API更快上下文也干净——不需要加载Admin那一大套中间件和模板系统。所以最终架构落定为主工程用Django 4.x承载全部业务写入和后台管理Flask 2.x只作为旁路服务对外提供只读查询API服务手机端和未来的小程序。两个服务共享同一个MySQL库各司其职。2.2 我采用的进程与库划分部署结构是这样的Nginx监听80/443负责反向代理和静态文件。路径/admin/、/manage/开头的所有请求转给Django的Gunicorn进程。路径/api/开头的请求转给Flask进程。Django进程负责所有写操作包括房源、租约、账单、退款。Flask进程只做SELECT查询并且只查几张核心表。这个划分的关键逻辑是写入路径只有一个写入逻辑不会出现“两套代码写同一个表”的冲突。Flask这边哪怕代码写得再随意它不写库就不会把数据搞坏。手机端要发起的那些“预约看房”之类的写操作我的做法是单独开一个Django下的API端点Flask只负责展示类的查询。这样就将复杂度控制在一个可控范围。2.3 共享数据库时的迁移与权限约束两个框架连同一个MySQL听起来简单实际要处理两个问题迁移归属和连接健康。迁移归属上我明确规定数据库结构变更只走Django的Migration。凡是涉及表结构修改的一律在Django工程里写model、迁移、上线。Flask这边不建任何表它如果需要查询一张新表直接依赖Django已经迁移好的现成表。从源头上避免两边各自建表、互相覆盖的混乱。连接健康上Django默认的数据库连接和Flask长连接策略不一样。Django方面我设置了CONN_MAX_AGE60让短请求也能复用连接。Flask这边用了带连接池的SQLAlchemy并开启pool_pre_ping避免MySQL断连后Flask还拿着失效连接继续查询。这件事在后面踩坑部分还会细讲。2.4 什么时候该砍掉Flask只留Django如果你正在评估自己的项目我觉得可以更果断一点。如果你的系统只有Web后台没有独立手机端那么只留Django完全够用连Flask都不用写。网上很多模板把Flask加进来纯粹是技术展示不是业务刚需。反过来如果手机端有实时查询房价、查空房、查账单这类高频只读场景而且未来打算拆独立小程序或者App那Flask旁路查询服务就值了。理由很简单一次查询不需要经过Django的一整套认证中间件响应能快几十毫秒更重要的是就算Flask服务被并发打崩Django核心业务和后台管理还能正常跑不会因为一个查询接口爆炸导致整站不可用。3. 核心表结构房源、租约、账单、流水四层模型数据模型是整个系统里最不能省时间的部分。城中村民宿系统通常有几十个房间单表查询没问题但表与表之间的关联设计一旦有漏洞后期改起来伤筋动骨。3.1 用户与角色不新建用户表用Django的Group撑住一开始我差点进入自己写用户表的误区后来想通了Django自带的User模型加Group和Permission已经足够。系统管理员是一个Group房东是一个Group前台是一个Group租客我单独用一张TenantProfile去关联User。这样既享受Django auth的成熟登录体系又不用自己维护密码逻辑。给租客建TenantProfile是因为租客不一定要有后台登录权限他们只是通过手机端用token访问这个Profile上记录手机号、身份证号、常驻地址等额外的身份信息。3.2 楼栋、房间与价格策略房源我拆成了楼栋和房间两层而不是直接一张房间表。原因是城中村房东往往有不止一栋楼每栋楼地址不同水电表独立统计收入也要按楼栋拆开看。核心模型大致长这样class Building(models.Model): name models.CharField(max_length64, verbose_name楼栋名称) address models.CharField(max_length255, verbose_name详细地址) owner models.ForeignKey(User, on_deletemodels.PROTECT, related_namebuildings, verbose_name房东账号) remark models.TextField(blankTrue, verbose_name备注) class Room(models.Model): STATUS_CHOICES [ (0, 空置), (1, 已预定), (2, 已入住), (3, 保洁中), (4, 维修中), ] building models.ForeignKey(Building, on_deletemodels.CASCADE, related_namerooms) room_no models.CharField(max_length32, verbose_name房号) area models.DecimalField(max_digits6, decimal_places2, verbose_name面积) daily_price models.DecimalField(max_digits8, decimal_places2, default0, verbose_name日租价) monthly_price models.DecimalField(max_digits8, decimal_places2, default0, verbose_name月租价) status models.SmallIntegerField(choicesSTATUS_CHOICES, default0, db_indexTrue, verbose_name当前状态) is_active models.BooleanField(defaultTrue, verbose_name是否在租)这里有个细节我把日租价和月租价分开存因为城中村民宿的日租和月租差价非常大月租通常按日租的六折甚至五折算。而且后续做空房查询时前端会根据“今天住一晚”还是“长住一个月”展示不同价格不能用一个价格字段硬套。3.3 租约的三态进行中、已结束、已取消租约是系统的核心我设计的表叫RentalContract它关联房间和租客记录起止时间、押金金额、租金方案。关键字段如下class RentalContract(models.Model): STATUS_RUNNING 0 STATUS_FINISHED 1 STATUS_CANCELLED 2 room models.ForeignKey(Room, on_deletemodels.PROTECT, related_namecontracts) tenant models.ForeignKey(TenantProfile, on_deletemodels.PROTECT, related_namecontracts) start_date models.DateField(verbose_name入住日期) end_date models.DateField(verbose_name退房日期) deposit models.DecimalField(max_digits8, decimal_places2, default0, verbose_name押金) monthly_rent models.DecimalField(max_digits8, decimal_places2, default0, verbose_name月租金) status models.SmallIntegerField(choicesSTATUS_CHOICES, defaultSTATUS_RUNNING, db_indexTrue)一个容易忽略的问题同一个房间不允许同时有两个STATUS_RUNNING的合同。这个约束我不仅在Django层面做了校验还在数据库层面建了部分唯一索引双保险。不然并发请求下前台手快点了两次入住同一天可能冒出两份有效合同房态彻底乱掉。这里也建议你加一个MonthlyRenewal或直接在合同上维护last_renew_month字段。城中村很多租客是“一个月一个月住”的合同到期前如果续租我在原合同上改end_date而不是新建合同这样账单和流水能保持连续。3.4 财务押金、租金、水电代收怎么落账财务我用了“账单流水”两层的设计。账单是应收概念流水是实收概念两者分开月底对账才有依据。class Bill(models.Model): BILL_TYPE [ (deposit, 押金), (rent, 租金), (utility, 水电费), (refund, 退款), (penalty, 违约金), ] contract models.ForeignKey(RentalContract, on_deletemodels.PROTECT, related_namebills) bill_type models.CharField(max_length16, choicesBILL_TYPE) amount models.DecimalField(max_digits8, decimal_places2, verbose_name账单金额) status models.SmallIntegerField(default0, verbose_name0未付 1已付 2部分付 3已取消) due_date models.DateField(verbose_name截止日期) class Transaction(models.Model): bill models.ForeignKey(Bill, on_deletemodels.PROTECT, related_nametransactions, nullTrue, blankTrue) direction models.SmallIntegerField(choices((0, 收入), (1, 支出)), verbose_name方向) amount models.DecimalField(max_digits8, decimal_places2, verbose_name金额) pay_time models.DateTimeField(auto_now_addTrue, verbose_name交易时间) pay_method models.CharField(max_length16, verbose_name支付方式cash/wechat/alipay/bank) operator models.ForeignKey(User, on_deletemodels.PROTECT, verbose_name操作人)水电费比较特殊。我单独建了UtilityReading表记录每个房间每次抄表的电表读数和用水吨数然后按“本次读数 - 上次读数”得出用量再乘以单价生成utility类型的账单。这个逻辑可以确保不管抄表周期多不规则都能正确算出本期费用。水电单价允许房东在楼栋配置里设置因为城中村不同楼栋的水电价格经常不一样。4. 业务闭环的Django实现房态机、账单与后台数据模型只是骨架真正让系统跑起来的是业务闭环。这一节我把核心流程的代码级实现讲一遍。4.1 用一个字典把房态转换焊死房态如果不限制跳转系统很快就会产生脏数据。比如“已预定”的房间不能直接变成“已入住”必须先经过“签约”“维修中”的房间也不能被下单。我用一个字典定义合法转换ROOM_STATUS_FLOW { 0: [1, 4], # 空置 - 已预定、维修中 1: [0, 2, 4], # 已预定 - 空置(取消)、已入住、维修中 2: [0, 3], # 已入住 - 空置(退房)、保洁中(待保洁) 3: [0], # 保洁中 - 空置 4: [0], # 维修中 - 空置 } def change_room_status(room, target_status, operatorNone): allowed ROOM_STATUS_FLOW.get(room.status, []) if target_status not in allowed: raise ValidationError(f非法房态流转{room.get_status_display()} - {target_status}) room.status target_status room.save(update_fields[status])这样做的好处是所有业务入口统一调用这个函数哪怕未来新增一个“今日待入住”的快捷键也绕不开状态限制。逻辑集中排查问题时只需要盯这一个函数。4.2 入住、退房、续租的代码级流程入住的完整流程我放在一个Django事务里from django.db import transaction transaction.atomic def check_in(room, tenant, start, end, deposit, first_month_rent, operator): if room.status ! Room.STATUS_AVAILABLE: raise ValidationError(房间当前不可入住请刷新房态) contract RentalContract.objects.create( roomroom, tenanttenant, start_datestart, end_dateend, depositdeposit, monthly_rentfirst_month_rent, statusRentalContract.STATUS_RUNNING, ) Bill.objects.create(contractcontract, bill_typedeposit, amountdeposit, due_datestart) Bill.objects.create(contractcontract, bill_typerent, amountfirst_month_rent, due_datestart) change_room_status(room, Room.STATUS_OCCUPIED, operator) return contract这里押金和首期租金同时生成账单前台收钱时在Transaction里登记一笔或者在账单上标记已付再关联对应的支付方式。退房则要额外处理水电费和押金退款。我写了一个finalize_contract方法逻辑分为三步录入截止日期的水电读数自动生成水电账单计算押金应退 原押金减去待扣的水电欠费、损坏赔偿若为负数则生成“补收”账单合同状态置为FINISHED房间转入保洁中。续租最建议在原有合同上延end_date并生成下一期租金账单避免租客和房源重新绑定也方便查看同一个租客的连续居住历史。4.3 账单生成与水电费计算的防错设计账单生成的频率不是统一的。月租租客按自然月生成日租租客按“每晚”或者“入住时一次性收完”城中村多以“入住时就收完整段房费”为主。我在系统里提供了一个“按期间生成账单”的任务支持选择开始和结束日期系统按日租价乘以含首含尾天数的逻辑一次性生成租金账单并把入住时已收到的金额自动标记为已付。水电费计算的关键就是防止积分错误。我用Django的DecimalField在代码里也坚持用Decimal而不是floatfrom decimal import Decimal, ROUND_HALF_UP def calc_utility_cost(current_reading, last_reading, unit_price): usage current_reading - last_reading cost (usage * unit_price).quantize(Decimal(0.01), roundingROUND_HALF_UP) return cost另外抄表时如果当前读数小于上次读数直接拦截报警多半是上次抄错或者换表了。这类小校验能省掉大量月底对账的精力。4.4 把Admin调成房东看得懂的后台Django Admin默认界面非常“后台”房东第一次打开会发懵。我做了几个关键配置让它变得可用在RoomAdmin里配置list_filter为楼栋和状态让房东按自己最关心的维度筛选list_display里直接展示楼栋、房号、状态、月租价、当前租客合同后台默认按“进行中优先”排序结束的合同折叠到筛选里在首页额外挂一个简单的统计面板显示每个楼栋“入住中房间数”“今日到期合同数”“本月实收金额”。没必要过度定制这些调整工作量不大却能让房东真的愿意用。后面维护期收到的反馈大多来自这些朴实的功能而不是花哨的图表。5. Flask侧只读API与移动端对接手机端是前台和租客频繁使用的入口。我坚持用Flask做只读API有一个原因它可以跟Django的繁重请求上下文完全隔离接口写起来非常轻。5.1 API的边界设计只读能解决90%的查询我对外提供的核心接口非常少按日期和区域查询可用房间返回房间号、面积、日租价、月租价查询某房间未来N天的价格日历按租客手机号查询自己的合同、账单、缴费记录公共房源列表带分页。这四个接口覆盖了手机端的绝大多数高并发查询。所有会产生写入的动作比如提交预约看房、报修我仍然通过Django侧专门的API入口处理因为预约要创建记录报修要创建工单这些必须走完整的Django逻辑享受事务和权限控制。5.2 token鉴权不碰Django的sessionFlask侧不能依赖Django的session cookie因为跨框架解密和会话共享很麻烦。我的方案是租客端发放token存Redis有效期7天。Flask封了一个简单的装饰器做鉴权import functools import redis r redis.Redis(host127.0.0.1, port6379, db0) def login_required(f): functools.wraps(f) def wrapper(*args, **kwargs): token request.headers.get(X-Token) tenant_id r.get(ftenant_token:{token}) if token else None if not tenant_id: return {code: 401, msg: 登录已过期}, 401 kwargs[tenant_id] int(tenant_id) return f(*args, **kwargs) return wrapperDjango负责在租客登录成功后把token uuid4().hex和租客ID写入Redis这样两边只用Redis做缓存同步不需要共享session数据库。这个设计后来证明非常省心。5.3 空房查询与缓存降级策略空房查询是最高频接口也是并发压力最大的接口。我做了两级缓存对“按日期查询某楼栋可用房”的请求结果以available:{building_id}:{date}为key缓存30秒如果Redis服务异常接口直接降级为查询MySQL并返回一个“数据可能非最新”的标记让前端提示用户刷新。缓存时间特意设为30秒而不是更长因为空房信息变化快宁可多查几次库也不要展示陈旧的信息导致“抢房失败”的客诉。5.4 弱网环境下的移动端体验城中村手机网络并不稳定尤其是楼栋地下室和电梯间。移动端对接时我做了两件事所有列表接口强制分页每页20条不允许客户端一次拖走全部数据前端在下拉刷新失败时保留上一次成功返回的数据并展示“上次更新时间”而不是转圈卡死。图片资源这块单独强调一下房源照片若要显示在手机端上传后必须压缩否则弱网下面一张2MB的图能卡掉用户所有耐心。我在Django侧保存原图的同时生成一个thumb缩略图Flask接口只返回缩略图地址。6. 上线之后踩到的四个坑完整排查链路这套系统真正跑起来是在上线后的第二周开始陆陆续续出来的问题全都是我在设计阶段没预料到的。挑了几个最有代表性的完整还原一下当时的排查过程。6.1 双框架同库MySQL连接数一夜飙高现象上线第三天运维收到告警MySQL连接数超过260。正常这台服务器最多几十个连接。排查过程先看SHOW PROCESSLIST发现大量来自Flask进程的Sleep连接每条都挂着不释放。再看Flask侧日志确认是SQLAlchemy每次请求都新建连接请求结束也没归还池子。原因是Flask侧连接池参数没有配置SQLAlchemy默认行为在低并发下也能把连接堆起来。解决办法Flask侧SQLAlchemy加上engine create_engine( mysqlpymysql://user:pass127.0.0.1/dbname, pool_size10, max_overflow20, pool_pre_pingTrue, pool_recycle600, )Django侧则把CONN_MAX_AGE设为60秒。改完后连接数稳定在20个以内。这个坑提醒我多进程连接同一个库时连接池参数必须一开始就配好不要等到生产环境炸了才补。6.2 float算水电费月底对账差了几毛钱现象月底对账的时候某栋楼的水电费总额跟手工算的差了几毛钱房东很不高兴。排查过程我核对数据库后发现个别账单金额出现类似23.550000000000004这样的值。顺藤摸瓜找到原因是录入电价时用了float乘法。代码里usage * unit_price两个float一乘精度就丢了。解决办法全项目金额字段全部改用Decimal计算也全部用Decimal再没有出现过金额尾差。当时觉得很基础的问题恰恰是最容易忽略的——只要有一次用float乘账就对不上。6.3 跨天房费28号住到3号到底是几天现象租客入住时前台在系统里选了“12月28日入住1月3日退房”系统算出房费4天租客说住了5晚双方当场争执。排查过程代码里计算天数用的是(end_date - start_date).days得到4天。但租房行业的口径是“含首含尾”28号晚上住到3号早上实际占用的晚数是5晚28、29、30、31、1、22号晚上还住着3号白天退房。解决办法我统一口径为“含首含尾”计算逻辑改为(end_date - start_date).days 1并且在入住确认弹窗上直接展示“本次共住X晚”字样。系统内所有日期差价、水电估算都按这个规则来。从此再没因为这个吵过架。6.4 房东手机上传20MB原图接口直接超时现象房东在手机端上传房源照片选的是手机相册原图20MB的图通过Nginx传到Django请求等待超时失败。排查过程先看Nginx日志发现上传请求耗时长Nginx默认的client_max_body_size只允许1MB直接返回413。调整后能传了但传上去的图太大手机端展示和加载都很慢弱网下根本打不开。解决办法前端在上传前用canvas把图片最长边压缩到1600px以内质量压缩到80%再传给后端。后端保存文件并生成400px缩略图供列表页使用。这样单个请求体积从MB级降到300KB以内弱网也能顺利上传。7. 把系统装进一台低配服务器顺带聊聊还能怎么演进整套系统最终部署在一台2核4G的云服务器上跑了半年没出过硬件问题。部署方案属于典型的小型Python Web应用组合给同样预算紧张的项目做个参考。7.1 服务器与进程布局我用的是Ubuntu 22.04装好以下组件Nginx负责反向代理、静态文件、图片访问Gunicorn跑Django主服务3个workerGunicorn单独一个worker跑Flask服务因为接口只读压力不大MySQL 8.0业务数据库Redistoken缓存、空房查询缓存Supervisor守护两个Python进程崩溃自动拉起。Nginx配置里两个服务的location互不干扰location /api/ { proxy_pass http://127.0.0.1:8001; proxy_set_header X-Real-IP $remote_addr; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header X-Real-IP $remote_addr; }这里要特别提醒Django的静态文件和上传图片一定要交给Nginx直接处理不要让Python进程读文件。我在系统里把MEDIA_ROOT配置指向一个本地目录Nginx用alias映射到/media/路径图片加载性能至少翻倍。7.2 备份与日常维护我写了一个简单的shell脚本每天凌晨3点执行mysqldump备份数据库保留最近7天再通过rsync推送到另一台内网机器。服务器上还加了日志按天切割避免access.log无限膨胀把磁盘挤爆。这种规模的项目不需要上什么自动化编排平台一条crontab加两个脚本就够用了。#!/bin/bash backup_dir/backup/mysql date_str$(date \%Y\%m\%d) mysqldump -u backup_user -ppassword rental_db $backup_dir/rental_$date_str.sql find $backup_dir -name *.sql -type f -mtime 7 -exec rm {} \;7.3 后续演进建议如果租客端需求继续扩大我的建议是把Flask侧那些只读API逐步换成更标准化的接口文档甚至引入接口网关。而如果业务变得非常复杂需要处理多店、多片区Django这边可以考虑引入DRFDjango REST Framework把Django本身变成一套完整的API服务然后让Flask慢慢退役。以我个人的体会来说这套“Django管业务、Flask旁路查询”的组合最大的价值不是技术有多新而是让不同需求的模块都能选最合适的最小实现。真正踩过坑之后才明白技术选型争论不重要重要的是每个模块的职责边界清晰团队改得动、上线不容易出问题。最后再分享一个小经验像这种带财务和状态流转的业务系统开发前先花一周把房态流转图和账务规则写在纸上比多写一万行代码都管用。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。