先说清楚,这个项目不是什么空中楼阁,就是一套能跑的民宿预订系统。后端用 Python + Django,小程序端是微信原生小程序,另外还做了一套兼容 PC 和手机浏览器的 Web 管理端。核心业务涵盖房源上架、房价日历、在线下单、支付回调和订单管理。整套做完之后,我自己最满意的一点是:所有端共用同一套 Django REST API,业务逻辑收口在后端,前端只做展示和交互。这样不管是用户在小程序里下单,还是管理员在 Web 后台改价,走的都是同一套校验和状态流转,不会出现“两边逻辑不一致”这种最头疼的问题。
这个项目的受众其实很明确:如果你想做一个真实的民宿/短租预订系统,或者刚学完 Django 基础想找一个完整项目练手,这篇总结应该能帮你少走不少弯路。里面不会只贴代码,更多是把“为什么这么设计”和“踩过哪些坑”讲清楚。
1. 项目整体设计与技术选型
1.1 为什么是 Django,而不是 Node 或 Spring
选型这种事没有绝对答案,关键看团队熟不熟和业务是否匹配。我当时选 Django 就三个原因:一是 Python 生态里处理 Web 开发,Django 的“全家桶”属性太省事了,自带 ORM、Admin 后台、迁移工具、认证体系,不用像 Flask 那样很多模块都要自己拼;二是 Django REST Framework(DRF)做 API 已经很成熟,序列化、权限、分页、视图集这些都有现成方案;三是民宿预订这个业务,订单和库存的强一致要求挺高,Django ORM 配合事务和行级锁能比较干净地实现。
对比来看,Node.js 的 Express/Nest 非阻塞模型写接口很快,但真要落复杂数据库事务和 Admin 后台,成本会高一些;Spring 当然强大,但起步重、学习曲线陡,对一个小团队做民宿系统来说有点杀鸡用牛刀。Django 恰好卡在“开箱即用”和“可控性强”中间。还有一点,Django 的迁移机制(migrations)在迭代房源字段、订单状态时非常舒服,改完模型跑一条命令就能同步表结构,这在项目上线后调整模型时能省大量手工 SQL。
1.2 小程序、Web、手机端三端如何共享一套后端
先说结论:三端只共享后端 API,前端完全独立。用户端主力是微信小程序,因为民宿预订这种低频、临时性需求,用户不太可能为了订一晚房专门下载 App,小程序用完即走正合适。Web 端这边我做了两个:一个是面向游客的 H5 浏览页,方便别人在朋友圈分享链接查看房源;另一个是管理员后台,兼容 PC 和手机浏览器,房东用手机也能登录后台处理订单和改房价。
三端共用一套 API 的关键是:所有身份校验都走 JWT,而不是 Session。小程序里没有传统 Cookie 概念,Web 端如果用 Session 就要处理跨域 Cookie 问题,很麻烦。JWT 有一个 Bearer Token,小程序端和 Web 端都把它放在请求头 Authorization 里,后端用 Django REST Framework 的认证类统一解析,一套逻辑服务三端。
管理后台我选了 Django Template + Bootstrap 5 来做,没有上 Vue/React。原因是管理后台不太需要复杂的前端交互,主要是表单、表格、状态按钮,模板渲染直接输出 HTML 足够,而且省去一套 Node 构建链路。手机端自适应靠 Bootstrap 的栅格和折叠菜单搞定。游客端的 H5 页面则单独做了响应式布局,本质上和小程序调同一套房源、订单接口。
1.3 功能模块与角色划分
整个系统分了三个角色:游客/用户、房东/管理员、超级管理员。游客只能浏览房源和查看详情;注册登录后的用户可以创建订单、发起支付、查看订单、取消订单、评价;房东或管理员能管理房源、设置每日价格、处理订单状态、查看经营数据。
具体功能模块我列一下,防止后面讲细节时没有上下文:
- 小程序端:首页推荐位、房源列表筛选、房源详情(图集/设施/价格日历)、下单页(选日期/选房间/算总价)、订单列表、订单详情、个人中心、微信支付。
- Web 管理端:登录/权限、房源管理(上下架、写介绍)、房价日历管理、订单管理(确认/入住/完成/取消)、评价管理、基础数据统计。
- 公共模块:微信登录绑定、用户地址/手机号、图片上传、API 版本管理、操作日志。
这个划分基本覆盖了民宿预订系统的核心闭环。我没有在一开始就去做营销优惠、积分系统这些花活,先把“房→价→单→支付→履约”这条主链路跑通,后面要加活动规则反而容易。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与关键实现
2.1 房源与日历库存的数据模型
民宿预订和酒店预订有个明显差别:民宿大多是“一房一价”,同一套房源不同日期、不同平台的房价都可能不一样。所以我没有按传统酒店那种“房型+房间数”建模,而是直接以“房源”为最小库存单位,再用独立的“库存日历表”去管每天的可用状态。
Django 模型我大致是这样写的(省略了部分字段):
python复制from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MinValueValidator
class House(models.Model):
"""房源"""
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='houses')
title = models.CharField(max_length=128, verbose_name='房源标题')
cover = models.ImageField(upload_to='house_covers/', verbose_name='封面图')
address = models.CharField(max_length=255, verbose_name='地址')
price = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='默认价格/晚')
status = models.CharField(max_length=16, choices=[
('draft', '下架'), ('online', '上架'), ('offline', '手动下架')
], default='draft')
created_at = models.DateTimeField(auto_now_add=True)
class HouseDate(models.Model):
"""某个房源在某个日期的库存与价格"""
house = models.ForeignKey(House, on_delete=models.CASCADE, related_name='dates')
date = models.DateField(verbose_name='日期')
price = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='当日价格')
stock = models.PositiveIntegerField(default=1, verbose_name='可订存量')
locked = models.BooleanField(default=False, verbose_name='已锁房')
class Meta:
unique_together = ('house', 'date') # 同一房源同一天只能有一条记录
indexes = [models.Index(fields=['house', 'date'])]
关键点是 HouseDate 表,它同时负责“库存”和“价格”两个职责。比如用户查询 7 月 10 日到 7 月 12 日的可订房源,核心 SQL 就是 HouseDate 表里这些日期 stock > 0 且 locked = False 的房源。
至于为什么不用“总房间数减订单数”这种方式:民宿的库存是“按日期一房一订”,如果简单用房源表的 stock 去减,那只要有人订了某个晚上,整晚的可用量就变了;但是一间民宿可能连续多晚被不同人预订,每个晚上的占用情况要和日期绑定。用独立的日历库存表,每一行代表“某房源某一天”,天然避免这个问题。
2.2 订单状态机与支付回调设计
订单系统最怕状态写乱。我一开始就定义好状态枚举,之后所有状态变更都走同一个接口,禁止前端随手把订单改成任意状态。
状态定义如下:
python复制class Order(models.Model):
STATUS_CHOICES = [
('pending', '待支付'),
('paid', '已支付待入住'),
('checked_in', '已入住'),
('completed', '已完成'),
('cancelled', '已取消'),
('refunding', '退款中'),
('refunded', '已退款'),
]
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='pending')
user = models.ForeignKey(User, on_delete=models.PROTECT, related_name='orders')
house = models.ForeignKey(House, on_delete=models.PROTECT, related_name='orders')
start_date = models.DateField()
end_date = models.DateField()
nights = models.PositiveIntegerField()
total_amount = models.DecimalField(max_digits=10, decimal_places=2)
out_trade_no = models.CharField(max_length=32, unique=True, verbose_name='商户订单号')
transaction_id = models.CharField(max_length=64, blank=True, verbose_name='微信支付单号')
created_at = models.DateTimeField(auto_now_add=True)
状态流转我控制在几个明确的方向上:待支付可以取消或支付成功变为已支付;已支付后管理员可以确认入住;入住后可以完成订单;退款只在已支付/已入住状态下允许,而且一旦进入退款中就不能再手动变回已支付。小程序端只能提交“创建订单”和“申请取消/退款”,管理员后台才能做“确认入住”“完成”这类后端操作。
微信支付回调这里有个很隐蔽的坑:回调可能因为网络问题重复推送,所以回调处理函数必须做幂等校验。我是在收到回调之后先查 transaction_id 是否已经处理过,处理过就直接返回成功,不再重复改订单状态。同时回调里验签、商户单号、金额这三个都要校验,尤其是金额,不能只验单号就改状态,一定要把回调金额和订单金额重新比对一遍,防止中间环节出问题。
python复制# 伪代码,支付回调处理
def wechat_pay_callback(request):
data = parse_and_verify(request.body) # 验签
order = Order.objects.select_for_update().get(out_trade_no=data['out_trade_no'])
if order.status == 'paid':
return success_response()
if order.total_amount != decimal_from_str(data['amount']):
log_error('金额不一致')
return fail_response()
order.status = 'paid'
order.transaction_id = data['transaction_id']
order.save()
lock_house_dates(order) # 锁定占用日期
return success_response()
2.3 并发下单:库存不超卖的关键
民宿一间房一天只能卖一单,宁可少卖也不能超卖。这里我用了“数据库锁为主,Redis 锁为辅”的双层策略。
数据库层的核心代码如下:
python复制from django.db import transaction
from django.db.models import F
@transaction.atomic
def create_order(user, house, start_date, end_date):
# 查出要锁定的所有日期
date_list = get_date_list(start_date, end_date)
dates = HouseDate.objects.select_for_update().filter(
house=house, date__in=date_list
)
# 校验
for d in dates:
if d.stock <= 0 or d.locked:
raise ServiceError('所选日期已满房')
# 扣库存
HouseDate.objects.filter(pk__in=[d.pk for d in dates]).update(stock=F('stock') - 1)
Order.objects.create(...)
select_for_update() 会把符合条件的 HouseDate 行锁住,直到当前事务结束。这样并发请求到达时,第二个事务会等第一个事务提交后才读取数据,看到的 stock 已经是扣减后的值,不会超卖。光有这一层还不够,我还在数据库层面加了唯一约束:同一房源同一天只能有一个待支付或者已支付的订单,不然两个事务同时读到 stock=1,都通过校验,然后都创建了订单,后续扣库存就可能出错。
Redis 锁我主要是用来应付“超大突发流量”的。虽然数据库锁已经解决一致性,但它在高并发下锁等待时间会比较长。Redis 锁用 SET key value NX EX 5 的方式,在创建订单前先抢锁,抢到锁才进数据库事务,抢不到直接返回“手速太快”。对民宿这种单量不会特别大的场景,其实数据库锁已经够用,但加了 Redis 锁之后,可以把一部分抢锁流量拦截在外面,数据库压力小很多。
3. 实操过程与核心环节落地
3.1 从零搭建 Django 项目
先说环境:我本地是 Python 3.10,服务器是宝塔拉起的 CentOS 环境。项目初始化命令就那么几条,但每一条背后都有讲究。
bash复制python3 -m venv venv
source venv/bin/activate
pip install django djangorestframework django-cors-headers pillow mysqlclient
django-admin startproject homestay
python manage.py startapp houses
python manage.py startapp orders
python manage.py startapp users
如果你的服务器没有装 mysqlclient,编译的时候很容易报错。那不是项目问题,是缺系统依赖,用宝塔装好 MySQL-python 相关依赖再装会省事很多。数据库我选了 MySQL,原因很简单:系统涉及订单、金额、库存,对事务和行级锁要求比较高,MySQL 的 InnoDB 能提供可靠的行锁。SQLite 也能跑,但并发一上来就锁表,不适合演示这个项目的并发控制逻辑。
settings.py 里我建议一开始就把几件事配好:
INSTALLED_APPS加上rest_framework、corsheaders、自己的几个 app。DATABASES改成 MySQL,引擎django.db.backends.mysql,数据库名、用户名、密码按自己的环境填。- 配置
LANGUAGE_CODE = 'zh-hans',TIME_ZONE = 'Asia/Shanghai',USE_TZ = True。 - 配置
CORS_ALLOWED_ORIGINS或开发环境临时用CORS_ALLOW_ALL_ORIGINS = True。
为什么要强调设时区?因为用户的入住日期是“日历日”,不是时间点。如果后端用 UTC 存日期,用户在上海订 7 月 1 日的房间,后端可能因为时区差把它变成 6 月 30 日,库存就直接错乱了。所以我全项目统一用 date 类型存日期字段,价格和库存也只跟日期相关,不跟具体时间点挂钩,这样时区影响最小。
3.2 小程序登录与 openid 绑定
小程序登录这件事,看起来简单,实际链路是:小程序端 wx.login() 拿到临时 code,把 code 发给后端,后端调用微信的 code2session 接口换 openid 和 session_key,再用 openid 去找用户,找到就登录,找不到就自动注册一个用户。最后后端签发 JWT 返回给小程序,后续所有请求带 JWT 就行。
核心代码片断:
python复制import requests
from django.conf import settings
def wechat_login(code):
resp = requests.get(
'https://api.weixin.qq.com/sns/jscode2session',
params={
'appid': settings.WX_APPID,
'secret': settings.WX_APPSECRET,
'js_code': code,
'grant_type': 'authorization_code',
},
timeout=5,
)
data = resp.json()
openid = data.get('openid')
if not openid:
raise AuthError('登录失败: ' + data.get('errmsg', ''))
user, _ = User.objects.get_or_create(username=openid)
# 这里可以用 openid 作为关联 key,生成 JWT
token = generate_jwt(user)
return token
要注意几个点:code 是一次性的,小程序端不能把 code 保存起来复用;如果后端调用微信接口超时了,小程序端要能捕获并提示用户重新 wx.login。另外微信接口返回的 session_key 不能直接返回给前端,也不需要自己解密用户信息,现在微信对用户手机号等敏感信息都走“手机号快速验证”接口,后端拿 code 换手机号之前必须保证用户已经完成登录态。
小程序请求封装我一般单独放在一个 request.js 里:
javascript复制function request(path, method = 'GET', data = {}) {
return new Promise((resolve, reject) => {
wx.request({
url: BASE_URL + path,
method,
data,
header: {
'Authorization': 'Bearer ' + wx.getStorageSync('token')
},
success: (res) => {
if (res.statusCode === 401) {
// token 失效,重新登录
wx.removeStorageSync('token');
loginAndRetry();
return;
}
resolve(res.data);
},
fail: reject
});
});
}
开发阶段要注意:小程序后台要勾选“不校验合法域名”,不然真机上 wx.request 请求本地 IP 或 IP 直连会被拦。上线之前一定要把后端域名配到微信公众平台的白名单,并且必须是 HTTPS。
3.3 Web 管理后台的自适应实现
管理后台没有用前后端分离,而是 Django 模板渲染,配合 Bootstrap 5 做响应式布局。操作者可能用 PC,也可能在手机上打开后台处理加急订单,所以表格和按钮得能“挤”到手机屏幕里。
最常用的处理方式:
html复制<div class="table-responsive">
<table class="table table-striped">
...
</table>
</div>
table-responsive 会自动在窄屏加横向滚动条,手机上虽然能操作但体验一般。后来我干脆把订单列表做成了卡片式布局:PC 宽屏显示表格,手机窄屏用 d-md-none 和 d-none d-md-block 切换卡片和表格。这样比单纯横向滚动舒服很多。
后台的账号体系和前台用户用同一张 User 表,但通过 is_staff 限制后台入口。房东/管理员在 Django Admin 里关联,权限用 group 管理,比如“店长”权限组可以操作房价和订单,但不能删除房源。Django Admin 本身就能给很多权限,但直接让老板用 Django Admin 界面不太友好,所以我还是写了一套自定义的管理页面,列表、表单都用自己的模板。
手机端适配还有一个容易被忽略的地方:页面上的时间选择器。PC 上我们用的是 flatpickr,手机上它也能触发原生日期选择,但最好限制日期范围,比如不能选过去的日期,退房日期必须大于入住日期。这个在前后端都要校验,前端只是体验,后端才是约束。
3.4 服务器部署:宝塔 + Nginx + gunicorn
部署我是用的宝塔面板,这个在中小项目里实在太常见了。大致流程:
- 在服务器装好宝塔,装 Nginx、MySQL 8.0、Redis,再装 Python 3.10 环境。
- 把项目代码传到服务器,用宝塔的“Python 项目管理器”新建项目,选择 Python 版本和入口文件。
- 用 gunicorn 跑 Django:
gunicorn homestay.wsgi:application -b 127.0.0.1:8000 --workers 3。 - 在 Nginx 里配置反向代理和静态文件。
Nginx 配置我贴一个常见模板:
nginx复制server {
listen 80;
server_name your-domain.com;
client_max_body_size 20m;
location /static/ {
alias /path/to/homestay/static/;
}
location /media/ {
alias /path/to/homestay/media/;
}
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
client_max_body_size 一定要设,不然管理员传图片一超过默认 1M 就被 Nginx 挡了。Django 里 DEBUG = False 后要执行 python manage.py collectstatic,把静态文件收集到指定目录,否则后台样式全丢。
在部署到正式环境之前,我还给 Django 配了 whitenoise,这样即使不用 Nginx 托管静态文件,Django 自己也能大概处理。不过生产环境还是推荐 Nginx 来处理静态文件,性能好很多。
数据库迁移上线时不要图省事直接 migrate --fake,要按顺序检查每个 app 的迁移历史。曾经有一次我因为手动改过数据库表,迁移到一半报错,最后把那个 app 的迁移记录删了重新跑,结果数据对不上,非常麻烦。所以从一开始就规范迁移,团队合作时尤其重要。
4. 常见问题与排查技巧实录
4.1 小程序登录失败:wx.login 拿到的 code 被复用
我遇到的第一个线上问题是:用户反馈有时候登录不上,后台日志里 code2session 返回 invalid code 或者 40029。排查下来发现是小程序端把 code 存到了本地缓存,用户第一次登录成功后 token 过期,代码又拿同一个废弃 code 去换 openid,微信那边当然不认。
正确做法是每次登录都重新调用 wx.login() 拿新 code,而且 code 有效期只有 5 分钟,使用一次就失效。后端如果发现 code2session 返回错误码,要立刻让前端重新走登录流程,不能把错误吞掉继续发请求。
4.2 跨域与 Cookie 导致的身份失效
Web 管理端和 API 同域部署的话,基本没有跨域问题;但小程序端和后端域名不同,所以开发阶段经常遇到跨域。因为我们用的是 JWT 放在请求头里,所以跨域主要是 CORS 的预检请求(OPTIONS)需要处理好。django-cors-headers 安装后,要在中间件里放到最外层,并且配置 CORS_ALLOW_HEADERS 里包含 Authorization,否则头部带 token 的请求会被浏览器拦掉。
如果项目早期用的是 Django Session 认证,小程序端没有 Cookie 存储机制,写起来很别扭。后来我全部切到 DRF 的 JWT,直接返回 token,小程序端存到 wx.setStorageSync,Web 端存到 localStorage,请求头手动加 Authorization,这个问题才算彻底理顺。
4.3 时区导致的库存日期偏移
时间问题是最隐蔽的。上线后出现过一次诡异情况:用户订 7 月 1 日入住,订单表里存的是 7 月 1 日,但库存被扣到了 7 月 2 日。查了很久发现是创建订单的时候用了 datetime.now(),这个会返回服务器本地时间,而不是配置的 Asia/Shanghai,加上 USE_TZ = True,导致某些日期计算出现偏差。
我的解决办法是:跟日期相关的字段全部用 DateField,在计算入住日期范围时,前端传日期字符串 'YYYY-MM-DD',后端直接用 datetime.strptime() 转 date 类型,不掺入 datetime 的时间部分。只有创建时间、更新时间这种字段才用 DateTimeField 和 timezone.now()。
4.4 并发下单导致超卖
这个问题在我们压力测试时暴露过。两个用户同时下单同一间房的同一晚,没有加锁的情况下,两个请求都读到 stock=1,然后都创建了订单。原因就是没做任何并发控制。
后来我在创建订单的事务里加了 select_for_update(),又给“未取消的订单 + 房源 + 日期范围”加了数据库约束线:同一个订单里的嵌套表 HouseDateOrder 对 (order, house, date) 建立唯一索引。这样即使高并发进来,数据库锁也会强制让一个事务先执行,后一个事务会发现库存已经被扣了,直接报“已满房”。
加锁之后性能确实会降一些,但对于民宿项目完全够用。真要上更高并发,可以再用 Redis 分布式锁或者把库存扣减做成“预占用”模式。
4.5 图片上传后访问 404
后台能传图,前台图片却裂了。查了一下是 MEDIA_URL 和 MEDIA_ROOT 没配,Django 开发时也没在 urls.py 里加 static 路由。开发环境加这两行就能看到图:
python复制from django.conf import settings
from django.conf.urls.static import static
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
但是生产环境 Nginx 配置不到位的话,/media/ 请求会打到 Django 后端,DEBUG=False 时 Django 不处理媒体文件,直接 404。所以上面 Nginx 配置里 location /media/ 那一段必须存在,并且 alias 目录要能读到上传的文件。另外,如果图片是传到云存储的,那就直接存绝对 URL,不用走本地 media。
4.6 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 小程序请求后端失败 | 未勾选“不校验合法域名”或域名没备案 | 开发工具勾选不校验;正式环境配置 HTTPS 域名并加到白名单 |
| 登录时报 invalid code | code 被复用或过期 | 每次登录重新 wx.login,后端不要缓存 code |
| 管理后台样式丢失 | DEBUG=False 后未 collectstatic | 执行 python manage.py collectstatic,Nginx 配置静态目录 |
| 图片上传 404 | MEDIA_ROOT/Nginx 没配对 | 配置 media 路径,Nginx location /media/ |
| 同一房源同一天被订两次 | 并发没有锁库存 | 用 select_for_update + unique_together 约束 |
| 支付回调重复处理 | 回调重复推送且未做幂等 | 按 transaction_id 查重,已处理的直接返回成功 |
| 跨域请求 OPTIONS 失败 | CORS 中间件未配置 Authorization 头 | 安装 django-cors-headers 并在 CORS_ALLOW_HEADERS 加 Authorization |
4.7 我踩过的一个小坑:支付回调里的金额类型
最后说一个容易犯错的细节:微信支付回调返回的金额单位是“分”,而且是字符串,Django 模型里的 DecimalField 默认单位是“元”。如果不做转换,可能直接把订单金额 199.00 元判断成回调金额 19900 分,金额校验永远对不上。我当时专门写了一个工具函数,统一把回调里的整数分转成 Decimal 的元,再跟订单金额比较。类似这种小问题,平时测试不容易发现,上线接入真实支付后才会冒出来,所以代码里一定要预留详细日志,方便排查。
我自己实际做下来最深的体会是:这套系统真正的难度不在“写接口”,而在“保证数据一致”——库存、订单状态、支付回调、并发控制,每一项都需要你提前想清楚边界条件。把状态机和并发锁处理明白,这个项目的核心价值就拿到了七成。后面如果要扩展,可以继续加促销活动、房东端小程序、数据报表这些模块,底子已经稳了。
