资讯动态

Django房屋租赁系统实战:从数据建模到账单支付

发布时间:2026/10/18 4:28:18 来源:尧图企业网站定制
做房屋租赁系统这类选题十个里有八个是先画页面再想数据。房源列表长什么样、预约按钮放哪、支付页面跳什么原型图一套一套轮到了数据库和 Django 项目结构就开始凭感觉堆字段。最后系统确实“能跑”可只要运营方提一句“我想把押金规则改一下”整个改版计划就崩了。这篇内容我打算按真实做项目的顺序来复盘先怎么建模再怎么拆项目然后把预约、签合同、收租这些核心链路一条条落地。目标读者是有一定 Django 基础、打算把房屋租赁系统做成能上线状态的人也包括想拿这个选题练手的同学。我会把踩过的坑和后来验证过可行的写法都放进去尽量让你少走弯路。1. 从“房源—租客—合同—账单”四条线建数据模型1.1 别把“租”当成一个简单状态字段很多初学者做房屋租赁系统第一步就在房源表里加一个status字段空闲、已租、维修。表面看没问题但真实租赁业务里一套房源从发布到退租中间还插着锁房、预约、签约、续租、退租、违约这些环节。如果只靠一个状态字段硬撑系统根本说不清楚“这套房子现在被谁预订了预订的人到底交没交定金”。我后来想明白了一个道理租赁系统的核心不是“房源状态”而是“人与房子的关系状态”。这套房有没有人想租有没有人正在看有没有人已经签合同这些都体现在订单、预约、租约这些关联表里而不是房源表本身。房源表只需要记录它自己的客观属性面积、户型、租金挂牌价、押金规则、是否允许养宠物、当前是否上架。这么设计的好处是同一个房源可以被多个人同时预约不会因为一个status字段互相覆盖同一个房源的前后两份合同也能同时留在系统里方便追溯历史。1.2 四张核心表的字段怎么定才不容易返工我建议在动手写第一个 app 之前先把下面四张表的关系画清楚。第一张是“房源表”它记录房子的固定信息。我常用的字段包括标题、城市、区县、详细地址、面积、厅室数、月租金挂牌价、押金规则、房东、封面图、上架状态。第二张是“用户表”如果是做个人项目可以直接用 Django 自带的用户模型扩展但如果要支持房东、租客、经纪人、运营多角色最好一开始就自定义用户表把user_type字段放进去。第三张是“租约订单表”它才是业务的中心。一次签约必须记录租客、房东、房源、起租日期、结束日期、月租实付金额、押金金额、签约状态、合同快照。第四张是“账单表”每个月按照租约自动生成一条应收记录字段包括所属租约、账期开始、账期结束、应收金额、滞纳金、已付金额、支付状态、支付时间。# houses/models.py from django.db import models class House(models.Model): class Status(models.TextChoices): DRAFT draft, 未上架 ON_SALE on_sale, 招租中 BOOKED booked, 锁房中 OFF off, 已下架 title models.CharField(房源标题, max_length100) city models.CharField(城市, max_length50, db_indexTrue) district models.CharField(区县, max_length50, blankTrue) address models.CharField(详细地址, max_length255) area models.DecimalField(建筑面积, max_digits7, decimal_places1) room models.PositiveIntegerField(厅室数, default1) listed_rent models.DecimalField(挂牌月租, max_digits8, decimal_places2) deposit models.DecimalField(押金, max_digits8, decimal_places2, default0) landlord models.ForeignKey( accounts.User, on_deletemodels.PROTECT, related_namehouses ) status models.CharField( 房源状态, choicesStatus.choices, defaultStatus.DRAFT, max_length16 ) created_at models.DateTimeField(auto_now_addTrue) class Meta: indexes [ models.Index(fields[city, status]), ]注意这里我没有写“当前租客”外键。当前是谁在租应该去租约表里面查“未结束且已生效”的那条记录。这样以后要做历史租客查询就不用翻业务日志了。外键的on_delete我用了PROTECT因为房源一旦有过交易记录就不能随随便便被删掉强删会造成账单和合同悬空。租约表还要特别留意一个点monthly_rent不能直接引用房源表的挂牌价。真实情况下租客可能会讲价房东也可能在续租时给折扣所以租约里必须记录签约时的实际月租。这个金额是用来生成账单的一旦后续房价调整历史账单不能被影响。# orders/models.py class LeaseOrder(models.Model): class Status(models.TextChoices): WAITING waiting, 待付押金 ACTIVE active, 履约中 CANCELLED cancelled, 已取消 FINISHED finished, 已退租 order_no models.CharField(订单号, max_length32, uniqueTrue) house models.ForeignKey(houses.House, on_deletemodels.PROTECT) tenant models.ForeignKey( accounts.User, on_deletemodels.PROTECT, related_namelease_orders ) start_date models.DateField() end_date models.DateField() deposit models.DecimalField(押金, max_digits8, decimal_places2) monthly_rent models.DecimalField(实际月租, max_digits8, decimal_places2) status models.CharField(choicesStatus.choices, max_length16, defaultStatus.WAITING) signed_at models.DateTimeField(blankTrue, nullTrue) contract_snapshot models.JSONField(合同快照, defaultdict, blankTrue)1.3 最容易翻车的字段设计押金、定金、租金混成一笔我见过一个做得非常热闹的原型押金、定金、水电费、保洁费全部塞进一个“费用类型”字段然后又跟支付记录用字符串拼接结果月末对账完全对不上。这是典型的把 Excel 习惯带进了关系数据库。正确的做法是区分“钱的性质”。定金是预约阶段的诚意金签约后可以抵租金押金是租约存续期间的保证金退租时要经过房屋核验才能退租金是周期性应收款按账期单独生成账单。三种钱属于不同的业务阶段应该挂靠在不同表上。支付明细建议单独一张表至少包含支付单号、关联业务单号、支付渠道、支付金额、支付状态、第三方回调流水号、创建时间。这张表只负责“收到过哪些钱”至于钱属于定金还是租金由关联的业务表去解释。这样无论系统以后接几个支付渠道都不会把账搞乱。2. Django 项目初始化与模块拆分一开始就别做单体大仓库2.1 自定义用户模型这一步不能省只要是准备长期维护的 Django 项目我都会建议从第一天就写上自定义用户模型哪怕暂时没有新增字段。原因很简单Django 的auth.User一旦执行过迁移再想改成自定义用户麻烦程度接近重开项目。我帮人看过一些线上项目就是因为一开始没自定义用户后面想加手机号登录只能另建一张Profile表到处关联代码绕来绕去。自定义用户表不复杂核心是继承AbstractUser再加两个字段# accounts/models.py from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): class Type(models.TextChoices): TENANT tenant, 租客 LANDLORD landlord, 房东 BROKER broker, 经纪人 OPS ops, 运营 user_type models.CharField( 用户类型, choicesType.choices, max_length16, defaultType.TENANT ) phone models.CharField(手机号, max_length20, blankTrue, db_indexTrue) created_at models.DateTimeField(auto_now_addTrue)然后在settings.py里加一行AUTH_USER_MODEL accounts.User这句话必须在第一次migrate之前配置好。项目刚startproject完先建立accountsapp改完模型再执行第一次迁移后面就不会被 Django 默认用户表绑住手脚。2.2 多 app 拆分不是越多越好但业务边界要清楚我见过有人把房源、订单、支付、后台全部写进一个叫core的 app视图函数三千行起步。前期确实省事因为不需要在文件之间来回跳但到后面每加一个功能都要小心翼翼生怕改动波及另外两块逻辑。我推荐的拆分方式是accounts管用户、角色houses管房源和预约orders管租约、合同payments管支付和账单operations管运营后台和统计。每一个 app 只处理自己边界内的事跨 app 调用统一走函数或服务层不直接互相操作模型内部的业务字段。在settings.py里我把 app 列表分成两块写方便一眼看出系统自带和业务自定义INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, # 业务模块 accounts.apps.AccountsConfig, houses.apps.HousesConfig, orders.apps.OrdersConfig, payments.apps.PaymentsConfig, operations.apps.OperationsConfig, ]这样拆完以后以后上celery、redis、drf都只是给某个 app 加能力而不是把整个系统的依赖搅在一起。2.3 用数据迁移把角色和权限固化下来很多团队做权限是在后台手工点“添加分组、勾选权限”一旦换环境就全部重来。正确的姿势是用 Django 的migrations做数据迁移把初始的分组和权限一次性固化进代码库。# accounts/migrations/0002_create_initial_groups.py from django.db import migrations GROUPS [landlord, broker, ops, tenant] def create_groups(apps, schema_editor): Group apps.get_model(auth, Group) for name in GROUPS: Group.objects.get_or_create(namename) def remove_groups(apps, schema_editor): Group apps.get_model(auth, Group) Group.objects.filter(name__inGROUPS).delete() class Migration(migrations.Migration): dependencies [ (accounts, 0001_initial), ] operations [ migrations.RunPython(create_groups, remove_groups), ]有这层数据迁移不管是本地开发还是线上部署只要执行migrate角色体系就是一致的不会出现“我本地能进后台测试环境却报没有权限”的灵异问题。3. 核心业务链路房源发布、预约看房、租约签订3.1 房源的图片上传和缩略图问题房源发布的核心难点不是字段多而是图片。一个没有照片的房源在平台上几乎没有转化率。可图片传上来以后不同位置需要不同尺寸列表页要小图详情页要高清图如果每次都拿原图硬缩放页面加载速度会非常差。我通常的做法是在House模型上加一个cover字段存封面图用ImageField上传到独立目录同时用 Pillow 生成一套列表页缩略图保存为cover_thumb。生成缩略图的操作不放在请求线程里而是放到保存逻辑里异步处理避免用户上传图片时页面卡顿。from django.db import models class House(models.Model): # 已有字段省略 cover models.ImageField(封面图, upload_tohouses/covers/%Y/%m/, blankTrue) cover_thumb models.ImageField(封面缩略图, upload_tohouses/thumbs/, blankTrue)如果不想自己写缩略图逻辑直接用 Django 的django-imagekit或easy-thumbnails都行。它们能在模型保存时自动生成不同规格图片缺点是会增加依赖部署时要多配一层存储。自己做只需注意上传目录千万不能放在static里因为collectstatic会把静态文件全部汇总而用户上传的媒体文件是动态增长的应该放到MEDIA_ROOT对应的目录并且对外通过单独的路由提供访问。3.2 预约看房的时间冲突检测预约看房是租赁系统的第一个并发场景。两个人同时想约同一个房源同一时段如果系统不拦着线下就会撞车。最朴素的写法是在提交预约时查一下该房源在目标时间段有没有重叠预约# houses/services.py from datetime import timedelta from .models import Appointment def is_visit_slot_available(house, start_time, end_time): conflict ( Appointment.objects.filter( househouse, status__in[pending, confirmed], ) .filter(visit_time__ltend_time, visit_time__gtstart_time) .exists() ) return not conflict这个查询能挡住常规重叠但挡不住“同一秒提交”的并发。如果系统要上线建议在创建预约的视图里用事务把房源行锁住避免两个请求同时通过校验from django.db import transaction from .models import House transaction.atomic def create_appointment(house_id, tenant, start_time, remark): house House.objects.select_for_update().get(pkhouse_id) if not is_visit_slot_available(house, start_time, start_time timedelta(hours1)): raise ValueError(该时段已被预约) return Appointment.objects.create( househouse, tenanttenant, visit_timestart_time, statuspending, remarkremark )这里我解释一下为什么要select_for_update。当多个数据库连接同时读取到同一个空闲时段它们都认为自己可以预约然后在同一秒写入冲突就发生了。锁定房源这一行之后后到的请求必须等前一个事务提交或回滚才能真正做决定。对于我们这种业务量不大的租赁系统这个方案已经足够可靠没必要引入分布式锁。3.3 租约签订为什么合同必须做“快照”签约是整个系统里最不该马虎的环节。租客在详情页看到的是挂牌价但线下谈判后可能谈到一个更低的月租或者约定首月只付半月房租。这些内容一旦确认就要原样落进合同并且以后不能被房源信息修改影响。所以我强烈建议在LeaseOrder里放一个contract_snapshotJSONField把签约当天的关键条款全部存进去。包括签约月租、押金、起止日期、付款周期、违约规则、房屋现状描述。这样做的好处有三个。第一以后房源表调整挂牌价不影响已经生效的合同第二做财务对账时账单金额直接跟合同快照比对不需要再去翻房源历史第三线上生成的 PDF 合同没保存纸质文件时系统里的 JSON 快照就是最权威的依据。签约操作适合放在一个服务函数里统一处理同时完成三件事创建租约、把房源状态改为BOOKED或RENTED、根据付款规则生成第一张账单。把这三步包在同一个事务里才不会出现租约建了但房源状态还是“招租中”这种脏数据。4. 账单与支付回拨按月收租的逻辑要能自圆其说4.1 不要等用户点“去支付”时才生成账单我见过一个版本账单是在租客点击“立即支付”时才即时生成导致逾期提醒、运营后台统计全部没有账可用。正确的设计应该是租约生效时就按租期生成所有应收账单到了每个账期账单已经躺在数据库里只是状态是“待支付”。生成账单的逻辑可以直接放在管理命令里通过 cron 每天跑一次# payments/management/commands/generate_bills.py from django.core.management.base import BaseCommand from payments.services import generate_bills_for_month class Command(BaseCommand): help 生成指定月份的房租账单 def add_arguments(self, parser): parser.add_argument(--month, typestr, help月份格式 2025-06) def handle(self, *args, **options): month options.get(month) or created, skipped generate_bills_for_month(month) self.stdout.write(fcreated: {created}, skipped: {skipped})generate_bills_for_month里的核心逻辑就是遍历所有状态为ACTIVE的租约判断目标月份是否落在租期范围内然后用get_or_create创建账单。用get_or_create是为了保证命令重复执行不会生成重复账单这是定时任务里最容易踩的坑。4.2 支付回调必须做幂等否则对账一定崩第三方支付平台的回调有个特点它会重试而且并发时可能重复到达。如果你的支付处理逻辑是“收到回调就加余额”那同一笔订单被回调两次金额就会错。我的做法是在支付记录表上建立一个唯一的业务约束同一个租约账单、同一个支付单号只能存在一条记录。class PaymentRecord(models.Model): bill models.ForeignKey(RentBill, on_deletemodels.PROTECT) pay_no models.CharField(支付单号, max_length64) channel models.CharField(支付渠道, max_length32) amount models.DecimalField(支付金额, max_digits8, decimal_places2) status models.CharField(支付状态, max_length16, defaultpending) callback_times models.PositiveIntegerField(回调次数, default0) created_at models.DateTimeField(auto_now_addTrue) class Meta: constraints [ models.UniqueConstraint(fields[bill, pay_no], nameuniq_bill_pay_no) ]在回调视图里利用这个唯一约束做保护。如果插入时发生完整性冲突说明之前已经处理过直接返回成功状态不重复更新账单。账单状态的更新必须放在事务里把待支付账单select_for_update锁住确认应收金额等于回调金额后再改成已支付。整个过程的顺序我建议是先锁账单再查支付记录然后创建支付流水最后更新账单状态、写支付成功时间。任何一步异常都回滚这样对账时永远能从支付流水反推账单状态。4.3 滞纳金要可计算也要可减免真实租赁里租客晚交房租是常态但系统不能因此把账搞死。滞纳金的计算规则要和租约挂钩比如“超过账期结束日 3 天未支付每日按应收金额的 0.1% 叠加”。这个规则建议放配置里不要写死在视图里。每天由定时任务扫描逾期账单重新计算滞纳金并更新到账单字段。这里的关键是减免动作必须留痕。我通常加一个 “调整金额” 字段运营手动减免时只改调整金额不直接修改原始应收这样原始应收、调整金额、实付金额三个数一比较谁在什么时间给谁免了多少一清二楚。场景原始应收调整金额实付金额账单状态正常支付300003000已支付运营减免1003000-1002900已支付逾期加收3000503050已支付部分支付300001000部分支付我尤其推荐在账单状态里保留“部分支付”。因为现实中经常出现租客转了 80%剩下 20% 还在等审批。只给“未支付”和“已支付”两个状态财务人员迟早会在 Excel 里再建一套账。5. 让房东、经纪、运营各司其职后台权限和 Django Admin 定制5.1 租客不应该能进 Django AdminDjango Admin 虽然好用但它默认把所有is_staffTrue的用户都当成后台人员而让租客也进后台是一件很危险的事。所以我一般给后台用户分两类运营人员和业务角色。租客只通过前台门户操作不拥有is_staff标志。分组权限建议这样设计ops组拥有所有模型的管理权限broker组拥有房源和预约的增改权限但不能看支付和账单landlord组只能看自己名下房源的数据。光靠 Django 默认的 Model 权限还不够因为房东需要“只能看自己的房子”这需要在对应 ModelAdmin 里重写get_queryset。5.2 用 Proxy Model 拆出不同的管理后台入口很多时候运营和经纪对同一张房源表的关注点完全不同。运营想看整体上架率、租金分布经纪只想维护自己负责的几个房源。如果你用同一个 ModelAdmin 注册两个角色看到的是同一套字段没法分别定制。Django 的 Proxy Model 就是为这个场景设计的。# houses/admin.py from django.contrib import admin from .models import House class OpsHouseAdmin(admin.ModelAdmin): list_display (title, city, district, listed_rent, status) list_filter (city, status) search_fields (title, address) class BrokerHouseAdmin(admin.ModelAdmin): list_display (title, district, listed_rent, status) list_filter (status,) admin.site.register(House, OpsHouseAdmin)如果希望经纪和运营看到完全不同的页面就再建一个 Proxy Model分别注册不同ModelAdmin。这样同一个数据源入口不同、字段不同、权限不同互不干扰。5.3 运营看板先统计清楚再谈可视化很多教程讲到后台就是列一堆图表但真实运营最需要的往往只有几个指标在租房源数、待签租约数、本月实收租金、逾期未付账单数。这些数据用小聚合查询就能拿到from django.db.models import Sum, Count from orders.models import LeaseOrder from payments.models import RentBill active_orders LeaseOrder.objects.filter(statusactive) rented_house_count active_orders.count() current_month_total RentBill.objects.filter( statuspaid, period_start__year2025, period_start__month6 ).aggregate(totalSum(amount))[total] or 0等业务量真正大了再去接 ClickHouse、Redis 做缓存。系统刚上线就跑复杂报表只会把最核心的租约流程拖慢。6. 上线前的工程化收尾测试、静态资源与部署6.1 把核心流程先写成回归测试我从不信“改了几行配置不可能出问题”这句话。租赁系统最容易回归出问题的恰恰是账单生成逻辑。所以我要求项目里至少保留三类测试预约冲突测试、账单生成测试、支付回调幂等测试。# payments/tests/test_bill_generation.py from django.test import TestCase from payments.services import generate_bills_for_month class GenerateBillsTests(TestCase): def test_active_lease_generates_one_bill_per_month(self): # 构造一个 4 月 1 日到 6 月 30 日的租约 order create_lease(start_date2025-04-01, end_date2025-06-30) created, _ generate_bills_for_month(2025-05) self.assertEqual(created, 1) self.assertEqual(order.bills.filter(period_start__month5).count(), 1)这种测试写起来不复杂但能保证以后你改成period_start字段类型、调整生成命令参数时不至于把整条收租链路弄坏。核心测试就是业务的保险绳。6.2 DEBUGFalse 之后最容易翻车的三个点本地开发时DEBUGTrue问题都被隐藏了。真正要上线我建议提前用这一行命令自我检查python manage.py check --deploy然后逐项处理。最常见的三个问题第一ALLOWED_HOSTS没有配置外部请求会直接 400第二静态文件没执行collectstatic后台样式全部丢失第三媒体文件上传后的访问路径没有配置用户头像一个都看不到。# settings/prod.py DEBUG False ALLOWED_HOSTS [your-domain.example] STATIC_ROOT BASE_DIR / staticfiles MEDIA_ROOT BASE_DIR / media MEDIA_URL /media/代码里还要留意一个细节不要在视图里把完整文件路径拼出来返回给前端应该始终用url obj.cover.url这样的方式Django 会根据MEDIA_URL自动生成可访问地址。6.3 一台普通服务器也能跑的部署方案租赁系统不是高并发应用完全可以用一台低配服务器撑住早期业务。部署上我用的是 Django Gunicorn Nginx 三板斧。Gunicorn 负责跑 Python 应用Nginx 负责接收外部请求、转发给 Gunicorn同时托管静态文件。Gunicorn 启动命令大概长这样gunicorn rental_platform.wsgi:application \ --bind 127.0.0.1:8001 \ --workers 2 \ --timeout 60用127.0.0.1监听而不是0.0.0.0是因为我们不希望 Gunicorn 直接暴露公网外部流量统一走 Nginx 才有机会做静态文件缓存和安全过滤。Nginx 配置里最重要的是把/static/和/media/两个 location 直接指向本地磁盘其余的请求才转发给 Gunicorn。这样一个配置下来几十个人同时在线看房源完全没问题。数据库我建议直接用 PostgreSQL不用 SQLite。虽然项目初期 SQLite 最省事但账单类业务涉及事务和并发写入PostgreSQL 的select_for_update和约束处理都比 SQLite 更可靠。迁移时机越早越好等线上堆了一堆数据再换每天都在谈“迁移脚本”.如果你只是交作业或者做技术验证SQLite 也不是不能用但只要考虑上线别在数据库选型上将就。账务数据一旦错一笔后面所有统计都会被人质疑。最后再分享一点做这套系统的心得我自己前后做过三个版本最深的体会是房屋租赁系统做到最后拼的不是某个高级技术而是对业务规则的理解。房源、租约、账单、权限这些听起来都很普通可真要把它们的关系设计到“运营人员愿意每天用”需要在细节上较真。比如账单要不要支持部分支付、逾期减免要不要留痕、租约签了以后能不能允许修改租金这些决策比多写几百行代码重要得多。如果让我从头再来一次我会先把房东和租客最关心的两个流程画出来从发布房源到签订合同从生成账单到完成支付。然后把这两个流程里所有可能出现的分支状态写清楚再做表结构。页面端反而不用急Django Admin 足够撑起前中期运营需求等业务模式验证清楚了再单独写前台界面也来得及。最后一个小建议项目里的账单生成、预约冲突、支付回调这三个函数一定要写成纯函数或者独立服务不要让它们散落在视图里。因为这三个地方是以后改动最频繁、最容易出大问题的位置。把它们收敛好整个系统的天花板就已经不低了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑