做房屋租赁系统这类选题,十个里有八个是先画页面再想数据。房源列表长什么样、预约按钮放哪、支付页面跳什么,原型图一套一套,轮到了数据库和 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_length=100) city = models.CharField("城市", max_length=50, db_index=True) district = models.CharField("区县", max_length=50, blank=True) address = models.CharField("详细地址", max_length=255) area = models.DecimalField("建筑面积", max_digits=7, decimal_places=1) room = models.PositiveIntegerField("厅室数", default=1) listed_rent = models.DecimalField("挂牌月租", max_digits=8, decimal_places=2) deposit = models.DecimalField("押金", max_digits=8, decimal_places=2, default=0) landlord = models.ForeignKey( "accounts.User", on_delete=models.PROTECT, related_name="houses" ) status = models.CharField( "房源状态", choices=Status.choices, default=Status.DRAFT, max_length=16 ) created_at = models.DateTimeField(auto_now_add=True) 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_length=32, unique=True) house = models.ForeignKey("houses.House", on_delete=models.PROTECT) tenant = models.ForeignKey( "accounts.User", on_delete=models.PROTECT, related_name="lease_orders" ) start_date = models.DateField() end_date = models.DateField() deposit = models.DecimalField("押金", max_digits=8, decimal_places=2) monthly_rent = models.DecimalField("实际月租", max_digits=8, decimal_places=2) status = models.CharField(choices=Status.choices, max_length=16, default=Status.WAITING) signed_at = models.DateTimeField(blank=True, null=True) contract_snapshot = models.JSONField("合同快照", default=dict, blank=True)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( "用户类型", choices=Type.choices, max_length=16, default=Type.TENANT ) phone = models.CharField("手机号", max_length=20, blank=True, db_index=True) created_at = models.DateTimeField(auto_now_add=True)然后在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(name=name) def remove_groups(apps, schema_editor): Group = apps.get_model("auth", "Group") Group.objects.filter(name__in=GROUPS).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_to="houses/covers/%Y/%m/", blank=True) cover_thumb = models.ImageField("封面缩略图", upload_to="houses/thumbs/", blank=True)如果不想自己写缩略图逻辑,直接用 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( house=house, status__in=["pending", "confirmed"], ) .filter(visit_time__lt=end_time, visit_time__gt=start_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(pk=house_id) if not is_visit_slot_available(house, start_time, start_time + timedelta(hours=1)): raise ValueError("该时段已被预约") return Appointment.objects.create( house=house, tenant=tenant, visit_time=start_time, status="pending", remark=remark )这里我解释一下为什么要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", type=str, help="月份,格式 2025-06") def handle(self, *args, **options): month = options.get("month") or "" created, skipped = generate_bills_for_month(month) self.stdout.write(f"created: {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_delete=models.PROTECT) pay_no = models.CharField("支付单号", max_length=64) channel = models.CharField("支付渠道", max_length=32) amount = models.DecimalField("支付金额", max_digits=8, decimal_places=2) status = models.CharField("支付状态", max_length=16, default="pending") callback_times = models.PositiveIntegerField("回调次数", default=0) created_at = models.DateTimeField(auto_now_add=True) class Meta: constraints = [ models.UniqueConstraint(fields=["bill", "pay_no"], name="uniq_bill_pay_no") ]在回调视图里,利用这个唯一约束做保护。如果插入时发生完整性冲突,说明之前已经处理过,直接返回成功状态,不重复更新账单。账单状态的更新必须放在事务里,把待支付账单select_for_update锁住,确认应收金额等于回调金额后再改成已支付。
整个过程的顺序我建议是:先锁账单,再查支付记录,然后创建支付流水,最后更新账单状态、写支付成功时间。任何一步异常都回滚,这样对账时永远能从支付流水反推账单状态。
4.3 滞纳金要可计算,也要可减免
真实租赁里,租客晚交房租是常态,但系统不能因此把账搞死。滞纳金的计算规则要和租约挂钩,比如“超过账期结束日 3 天未支付,每日按应收金额的 0.1% 叠加”。这个规则建议放配置里,不要写死在视图里。
每天由定时任务扫描逾期账单,重新计算滞纳金并更新到账单字段。这里的关键是,减免动作必须留痕。我通常加一个 “调整金额” 字段,运营手动减免时只改调整金额,不直接修改原始应收,这样原始应收、调整金额、实付金额三个数一比较,谁在什么时间给谁免了多少,一清二楚。
| 场景 | 原始应收 | 调整金额 | 实付金额 | 账单状态 |
|---|---|---|---|---|
| 正常支付 | 3000 | 0 | 3000 | 已支付 |
| 运营减免100 | 3000 | -100 | 2900 | 已支付 |
| 逾期加收 | 3000 | 50 | 3050 | 已支付 |
| 部分支付 | 3000 | 0 | 1000 | 部分支付 |
我尤其推荐在账单状态里保留“部分支付”。因为现实中经常出现租客转了 80%,剩下 20% 还在等审批。只给“未支付”和“已支付”两个状态,财务人员迟早会在 Excel 里再建一套账。
5. 让房东、经纪、运营各司其职:后台权限和 Django Admin 定制
5.1 租客不应该能进 Django Admin
Django Admin 虽然好用,但它默认把所有is_staff=True的用户都当成后台人员,而让租客也进后台是一件很危险的事。所以我一般给后台用户分两类:运营人员和业务角色。租客只通过前台门户操作,不拥有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(status="active") rented_house_count = active_orders.count() current_month_total = RentBill.objects.filter( status="paid", period_start__year=2025, period_start__month=6 ).aggregate(total=Sum("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_date="2025-04-01", end_date="2025-06-30") created, _ = generate_bills_for_month("2025-05") self.assertEqual(created, 1) self.assertEqual(order.bills.filter(period_start__month=5).count(), 1)这种测试写起来不复杂,但能保证以后你改成period_start字段类型、调整生成命令参数时不至于把整条收租链路弄坏。核心测试就是业务的保险绳。
6.2 DEBUG=False 之后最容易翻车的三个点
本地开发时DEBUG=True,问题都被隐藏了。真正要上线,我建议提前用这一行命令自我检查:
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 足够撑起前中期运营需求,等业务模式验证清楚了,再单独写前台界面也来得及。
最后一个小建议:项目里的账单生成、预约冲突、支付回调这三个函数,一定要写成纯函数或者独立服务,不要让它们散落在视图里。因为这三个地方是以后改动最频繁、最容易出大问题的位置。把它们收敛好,整个系统的天花板就已经不低了。