☰
开箱即用酒店管理系统实战:Django+Vue3+小程序三端实时房态
2026/9/26 12:12:48 网站建设 项目流程

简介:这是一款开箱即用的酒店管理系统,面向酒店运营者、中小型酒店管理者及计算机相关专业学习者,涵盖后台管理、官方网站与微信小程序三大版块,可一站式解决客房预订、订单流转、订餐管理与支付退款等运营需求。系统支持实时房间动态展示与信息推送,帮助管理者快速掌握房态并改善对客服务;微信小程序端则打通在线下单、订餐、支付与退款流程,提升了客户操作便捷度。资源共包含1477个文件,以JavaScript、WXML/WXSS、JSON等前后端代码文件为主,辅以图片、样式与配置文件,整体约51.88MB,适合直接部署或二次开发学习。目前已有59人浏览学习,对想要快速搭建数字化酒店平台、研究小程序支付与订单闭环的开发者来说,是一份结构完整、可直接落地的参考资料。

1. 开箱即用的酒店管理系统:先跑通后台、网站、小程序三端闭环

一家 30 间房的民宿或精品酒店,买一套主流酒店管理系统(商用 PMS)年费上万,自己从零写又没时间。常见做法是拿一套“开箱即用的酒店管理系统”起步:后台管房态和订单,网站做展示与在线预订,微信小程序给住客查订单、订餐、收通知。这个标题指的就是这类项目。真正决定项目值不值得投入的,不是界面好不好看,而是房间动态实时性和订单状态一致性这两件事。适合中小酒店自运维、接活团队交付,也适合拿来做酒店管理系统毕业设计的底子,往智慧酒店管理系统方向演进也顺路。下面按我跑通类似项目的落地路径展开,照做能少走很多弯路。

2. 把三端跑起来:技术选型与最小启动命令

2.1 三端技术栈为什么是 Django + Vue3 + 原生小程序

开门见山放结论。后台管理端用 Vue3 + Element Plus + Pinia + Vite,接口层用 Python Django + Django REST Framework,门户网站用 Vue3(要 SEO 就换 Nuxt3),微信小程序用原生语法,实时通道用 Django Channels + Redis。这个组合不是最炫的,但对“开箱即用”这个目标最友好:

  • Django 自带 Admin 后台,房型、餐品、公告这类低频维护可以直接开箱用,不用每个表都写页面;
  • DRF 的 ModelViewSet 能在半小时内把核心资源全部暴露成 REST API;
  • Element Plus 的表格、表单、标签页直接对应订单管理、房态管理这些中后台场景;
  • 小程序用原生,避开 uni-app 那层编译黑匣子,出问题能直接定位到微信开发者工具的控制台。

替代方案不是没有,常见的是 Node.js,或者直接套一套 vue3 后台管理系统模板再配 Java 后端。Java 体系适合大型连锁,但对中小酒店太重;Node 做实时推送很顺,但后台管理、权限、ORM 都要自己拼。Django 是这几个需求里配套最齐的。

如果团队已经以 Vue 为绝对主力,小程序端换 uni-app 开发也能跑,代价是多一层编译,遇到渲染差异时排查成本更高。技术选型没有标准答案,先跑通三端闭环最重要,选什么都行,唯一能吃的后悔药就是别把架构搞复杂。

端推荐技术栈替代方案选择理由
后台管理Vue3 + Element Plus + PiniaReact + Ant DesignElement Plus 对订单列表、房态网格这类中后台场景开箱即用
门户网站Vue3(要 SEO 用 Nuxt3)Django 模板渲染与后台共用组件与 API 规范,访客端实时性要求低
微信小程序原生 WXML/WXSS/JSuni-app免去编译层黑匣子,联调时排错路径最短
后端 APIDjango + DRF + ChannelsNode.js ExpressAdmin/ORM/迁移/权限一套齐,Channels 天然支持房间分组推送

如果你只是做内部 demo,后台可以直接用 Django Admin 顶着,它连登录都自带。但正式让前台用,还是要 Vue3 写一套,Django Admin 的表格交互对酒店前台来说太反人类。

2.2 项目结构:后端、后台管理端、门户站、小程序四个子包

代码包根目录一般长这样:

hotel-management/ ├── backend/ # Django 项目:API + Admin + Channels │ ├── apps/ │ │ ├── rooms/ # 房型 / 房间 / 房态日志 │ │ ├── orders/ # 订单中心 │ │ └── catering/ # 订餐管理 │ ├── config/ # settings / urls / asgi │ └── manage.py ├── admin-web/ # Vue3 后台管理端 ├── portal-web/ # 门户网站 └── mini-program/ # 微信小程序原生代码

这个结构把四个子项目拆开,每个都能独立启动、独立部署。后端是最核心的,三个端都只跟后端 API 通信,端与端之间不直接依赖,这样后面加一个公众号 H5 或者抖音小程序,只需要新增一个前端壳。

后端依赖最小集建议这样锁:

Django>=4.2,<5.0 djangorestframework channels>=4.0 channels-redis>=4.1 django-cors-headers redis>=5.0 psycopg2-binary

Django 4.2 是当前支持周期较长的稳定线,Channels 4.x 配合 Daphne 跑 ASGI。开发阶段用 SQLite 起步,生产环境换 PostgreSQL,代码层不用改,只改 DATABASES 配置。

2.3 三分钟启动序列:迁移、跑后端、起前端

拿到项目后不要先看代码,先把服务拉起来,确认环境是通的:

cd backend python -m venv venv source venv/bin/activate pip install -r requirements.txt cp .env.example .env # 按本机改数据库与 Redis 地址 python manage.py migrate python manage.py createsuperuser python manage.py runserver 0.0.0.0:8000

参数说明:runserver 绑 0.0.0.0 是为了让局域网里的手机小程序能访问到本机;如果只在电脑上调试,绑 127.0.0.1 即可。.env 里重点改 DJANGO_SECRET_KEY、DATABASE_URL、REDIS_URL 三项,其他保持默认。后端起来之前,先确认本机 Redis 在跑,否则后面 WebSocket 一推就报错。

后端起来后,另开两个终端起前端:

cd admin-web npm install npm run dev # 默认占 5173 端口,Vite 把 /api 和 /ws 前缀转发到 8000 cd mini-program # 用微信开发者工具导入目录,AppID 可以先用自己的测试号 # 本地开发把 request 合法域名校验关掉

这里有一个很多人一开始会忽略的点:后台管理端的数据不是直接请求 http://localhost:8000,而是通过 Vite 的转发配置到 Django,所以 admin-web 的 vite.config 里要把 /api 和 /ws 前缀都处理掉。小程序没有转发这一层,需要在小程序代码里把 baseURL 直接指向局域网 IP,比如 http://192.168.1.10:8000/api,并勾选“不校验合法域名”。

提示:起服务后发现页面能开但接口 404,先看前缀是否一致。后端 API 统一走 /api/,WebSocket 统一走 /ws/,前端所有请求都以这两个前缀开头,能少掉一半联调问题。

3. 数据库建模:房间动态、订单中心、订餐管理怎么落表

3.1 房型与房间:一张 status 字段撑起实时房态图

实时房间动态说白了就是:房间的状态一变,所有在线的后台、前台、小程序客户端都要同步刷新。状态的源头是数据库里的 rooms 表。房间模型最少包含这些字段:

# backend/apps/rooms/models.py from django.db import models class RoomType(models.Model): name = models.CharField(max_length=50, verbose_name="房型名称") base_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="挂牌价") member_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="会员价") bed_type = models.CharField(max_length=20, verbose_name="床型") capacity = models.IntegerField(default=2, verbose_name="可住人数") area = models.IntegerField(default=25, verbose_name="面积(平方米)") class Room(models.Model): STATUS_CHOICES = [ ("available", "空闲"), ("occupied", "入住"), ("dirty", "脏房"), ("maintenance", "维修"), ("reserved", "预留"), ] room_number = models.CharField(max_length=10, unique=True, verbose_name="房间号") room_type = models.ForeignKey(RoomType, on_delete=models.PROTECT, verbose_name="房型") floor = models.IntegerField(verbose_name="楼层") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="available") updated_at = models.DateTimeField(auto_now=True)

逻辑说明:status 用字符串枚举而不是布尔,因为真实酒店里“维修”“脏房”和“空闲”一样常见;unique=True 保证房号唯一,这是后续所有订单判断“这间房能不能订”的前提。updated_at 用来做最后变更时间,前端展示“几分钟前更新”直接取它。

参数说明:price 用 DecimalField 而不是 FloatField,房价涉及金额结算,FloatField 的小数误差会在月结时暴雷;capacity 和 area 留出来,是为了门户网站的筛选器“可住 2 人”“30 平米以上”能直接查。

关于“实时”,房间表本身不带实时能力,实时靠的是这张表的变更事件。做法是给 Room 加一个状态变更记录表:

class RoomStatusLog(models.Model): room = models.ForeignKey(Room, on_delete=models.CASCADE) from_status = models.CharField(max_length=20) to_status = models.CharField(max_length=20) operator = models.CharField(max_length=50) created_at = models.DateTimeField(auto_now_add=True)

每条状态变更落一条日志,房态图上方的时间轴、值班交接、纠纷追责全靠它。

3.2 订单中心:状态机字段放在一张表,还是拆预订与入住

中小酒店不用把预订单和入住单拆成两张表,一张 orders 表加状态字段就够了。常见做法是:

class Order(models.Model): STATUS_CHOICES = [ ("pending", "待支付"), ("paid", "已支付/待入住"), ("checked_in", "已入住"), ("checked_out", "已离店"), ("cancelled", "已取消"), ("refunded", "已退款"), ] CHANNEL_CHOICES = [("backend", "后台"), ("portal", "网站"), ("mini", "小程序")] order_no = models.CharField(max_length=32, unique=True) channel = models.CharField(max_length=20, choices=CHANNEL_CHOICES) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending") room = models.ForeignKey(Room, on_delete=models.PROTECT) guest_name = models.CharField(max_length=50) guest_phone = models.CharField(max_length=20) check_in_date = models.DateField() check_out_date = models.DateField() total_amount = models.DecimalField(max_digits=10, decimal_places=2) created_at = models.DateTimeField(auto_now_add=True)

关键在状态机:pending 只能流向 paid 或 cancelled;paid 才能流向 checked_in;checked_in 只能流向 checked_out。换房操作不是改 Order.room 字段那么简单,而是先建一张 room_change_log,再通过统一的“换房服务”去改房间状态和订单房间号,否则会出现“订单在 A 房、房间状态显示 B 房有人住”的脏数据。

订单与房间怎么联动:下单成功把房间置为 reserved 或直接 paid,check_in 时置 occupied,check_out 时先置 dirty(脏房),保洁打扫完再由前台改成 available。这个链条要放在一个事务里完成,不能“订单状态改了但房间状态没改”。

3.3 订餐模块:菜单、订餐单、明细三张表就够

订餐管理的场景很标准:住客在小程序上看菜单、下单,餐厅收单、出餐、送到房间,最后费用挂房账或单独支付。表结构如下:

class Meal(models.Model): name = models.CharField(max_length=100) category = models.CharField(max_length=20) # 早餐/正餐/饮品/夜宵 price = models.DecimalField(max_digits=8, decimal_places=2) is_available = models.BooleanField(default=True) class MealOrder(models.Model): STATUS_CHOICES = [("submitted", "已下单"), ("preparing", "制作中"), ("delivered", "已送达"), ("finished", "已完成")] order_no = models.CharField(max_length=32, unique=True) room = models.ForeignKey(Room, null=True, on_delete=models.SET_NULL) guest_name = models.CharField(max_length=50, blank=True) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="submitted") remark = models.CharField(max_length=200, blank=True) total_amount = models.DecimalField(max_digits=8, decimal_places=2) created_at = models.DateTimeField(auto_now_add=True) class MealOrderItem(models.Model): meal_order = models.ForeignKey(MealOrder, related_name="items", on_delete=models.CASCADE) meal = models.ForeignKey(Meal, on_delete=models.PROTECT) quantity = models.IntegerField(default=1) price = models.DecimalField(max_digits=8, decimal_places=2) # 下单时快照价

注意 MealOrderItem.price 存的是下单那一刻的餐品价格而不是关联查询 Meal.price,这样以后菜单改价,历史订单金额不受影响。快照价是一种很常见但容易漏掉的细节。

订餐流的信息推送点是状态变化,尤其“制作中 -> 已送达”这一步,住客在小程序上能看到实时状态,这就是“实时信息推送”在订餐模块的落地形态。

3.4 用 DRF 把这套模型暴露成 API:统一响应与权限

模型建好之后,接口层直接用 DRF 的 ModelViewSet 出:

# backend/apps/rooms/apis.py from rest_framework import viewsets, serializers from .models import Room, RoomType class RoomSerializer(serializers.ModelSerializer): room_type_name = serializers.CharField(source="room_type.name", read_only=True) class Meta: model = Room fields = ["id", "room_number", "floor", "status", "room_type", "room_type_name"] class RoomViewSet(viewsets.ModelViewSet): queryset = Room.objects.select_related("room_type").all() serializer_class = RoomSerializer filterset_fields = ["status", "floor"]

配一个统一响应包装,让三个端不用各自处理错误结构:

def custom_response(data=None, message="ok", code=0): return {"code": code, "message": message, "data": data} class StandardViewSet(viewsets.ModelViewSet): def list(self, request, *args, **kwargs): page = self.paginate_queryset(self.get_queryset()) data = self.get_serializer(page, many=True).data if page else self.get_serializer(self.get_queryset(), many=True).data return Response(custom_response(data=data))

逻辑说明:三个端共用一个响应结构 {code, message, data},小程序端 wx.request 的封装只要判断 code 是否为 0,不用再解析 DRF 默认的 {count, results} 结构。安全边界上,写接口加 DRF 的 IsAuthenticated,读接口对门户网站放开,小程序用 token 鉴权,具体封装放在下一章。

4. 实时房间动态与消息推送:从数据库变更到三端实时刷新

4.1 请求封装与登录态:三端统一 token 规范

所有实时推送的前置条件是“知道你是谁”。小程序 wx.request 的最简封装如下:

// mini-program/utils/request.js const BASE_URL = 'http://192.168.1.10:8000/api' function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { const token = wx.getStorageSync('token') || '' wx.request({ url: BASE_URL + path, method, data, header: { Authorization: 'Bearer ' + token }, success(res) { if (res.data.code === 0) { resolve(res.data.data) } else if (res.statusCode === 401) { wx.removeStorageSync('token') wx.navigateTo({ url: '/pages/login/index' }) reject(res.data) } else { reject(res.data) } }, fail: reject }) }) } module.exports = { request, BASE_URL }

逻辑说明:每次请求自动带 token,401 统一踢回登录页,业务层只关心 data 字段。开发阶段 BASE_URL 写局域网 IP,上线前换成正式域名。

后端用 simplejwt 发 token,登录接口返回 access 和 refresh,小程序在收到 401 时先尝试用 refresh 换新 token 再重放原请求,这套“自动续期”对三个端是通用的。实时连接也一样:小程序里 wx.connectSocket 的 header 带上同一个 token,Django Channels 的 middleware 验完 token 才允许建立连接。

4.2 后端推送链路:Django Channels 把房间变更推给所有在线端

这一节就是标题里“后台有数据,前端实时刷”的核心链路。整条链路是:某个接口改了 Room.status → 触发 group_send → Redis 转发 → 所有连接了该 group 的前端收到消息。

# backend/config/asgi.py import os from django.core.asgi import get_asgi_application from channels.routing import ProtocolTypeRouter, URLRouter from django.urls import path from apps.rooms.consumers import RoomStatusConsumer os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") application = ProtocolTypeRouter({ "http": get_asgi_application(), "websocket": URLRouter([path("ws/room-status/", RoomStatusConsumer.as_asgi())]), })

consumers 端:

# backend/apps/rooms/consumers.py import json from channels.generic.websocket import AsyncWebsocketConsumer class RoomStatusConsumer(AsyncWebsocketConsumer): async def connect(self): self.group_name = "room_status" await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() async def disconnect(self, close_code): await self.channel_layer.group_discard(self.group_name, self.channel_name) async def room_status_changed(self, event): await self.send(text_data=json.dumps({ "type": "room_status_changed", "room_id": event["room_id"], "status": event["status"], "updated_at": event["updated_at"], }))

触发点在业务接口里:

from asgiref.sync import async_to_sync from channels.layers import get_channel_layer channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( "room_status", {"type": "room_status_changed", "room_id": room.id, "status": room.status, "updated_at": str(room.updated_at)} )

逻辑说明:consumer 的 room_status_changed 方法名对应 event 里的 type 字段,Channels 会根据 type 字符串去找同名方法。group_name 在示例里是固定的“room_status”,如果以后按楼层分组推送,改成 f"room_status_floor_{room.floor}" 即可。

settings 里要配 channel layer:

CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": {"hosts": [("127.0.0.1", 6379)]}, } }

参数说明:Redis 是必须的,生产别用 InMemoryChannelLayer,多进程下会丢消息。Redis 挂掉时 Channels 推送直接抛异常,所以 Redis 要单独监控。

4.3 后台与小程序两个前端的 WebSocket 写法与心跳重连

后台管理端用 Vue3 写房态图,连接和断线重连是一个反复踩坑的点。最简单的可靠写法:

// admin-web/src/composables/useRoomSocket.js let socket = null let heartbeatTimer = null export function connectRoomSocket(onMessage) { if (socket && socket.readyState === WebSocket.OPEN) return const protocol = location.protocol === 'https:' ? 'wss' : 'ws' socket = new WebSocket(`${protocol}://${location.host}/ws/room-status/`) socket.onopen = () => { heartbeatTimer = setInterval(() => socket.send('ping'), 30000) } socket.onmessage = (e) => { if (e.data !== 'pong') onMessage(JSON.parse(e.data)) } socket.onclose = () => { clearInterval(heartbeatTimer) socket = null setTimeout(() => connectRoomSocket(onMessage), 3000) } }

心跳包的作用是让 Nginx 和 Redis 知道连接还活着,30 秒一次,服务端收到 ping 回 pong,客户端忽略 pong 即可。断线 3 秒后自动重连,但要注意 onclose 里 setTimeout 不要叠加重连,否则网络抖动会连环重连。

小程序端写法类似但用的是 wx 的 API:

// mini-program/utils/socket.js let socketTask = null function connectRoomSocket(onMessage) { if (socketTask) return socketTask = wx.connectSocket({ url: 'ws://192.168.1.10:8000/ws/room-status/', header: { Authorization: 'Bearer ' + wx.getStorageSync('token') } }) socketTask.onMessage((res) => { const data = JSON.parse(res.data) if (data.type === 'room_status_changed') onMessage(data) }) socketTask.onClose(() => { socketTask = null setTimeout(() => connectRoomSocket(onMessage), 3000) }) }

这里有个小程序特有问题:wx.connectSocket 不支持像浏览器里那样直接查 readyState,所以用 socketTask 是否为空判断是否已连接,onClose 里置空再重连。

4.4 微信订阅消息与门户轮询:一个合规推送,一个离线兜底

WebSocket 只能在用户“正在打开页面”时推送。用户在房间外、小程序被切到后台时,微信官方给的能力是订阅消息。注意它的规则:必须由用户点击行为触发 wx.requestSubscribeMessage,每次授权只能推一条消息,推送模板需要在小程序后台申请。

// 用户点击“确认订餐”后触发 wx.requestSubscribeMessage({ tmplIds: ['餐品送达通知的模板ID'], success(res) { if (res['餐品送达通知的模板ID'] === 'accept') { request('/api/mini/subscribe-authorized/', 'POST', { template_id: '餐品送达通知的模板ID', result: 'accept' }) } } })

后端推送时用 access_token 调用 subscribeMessage.send,模板里的字段要和用户在订单里填的房间号、餐品名对应。这是一个血泪经验密集区:很多团队把订阅消息当成免费短信来用,结果发现用户不点按钮就永远拿不到订阅授权。合规的玩法是,在用户最可能想收到通知的场景去触发授权,比如订餐支付成功后紧接着弹订阅授权,而不是在首页弹。

门户网站的访客量大、实时性要求低,没必要给每个访客开 WebSocket。最常见做法是 15 秒轮询一次房态接口:

setInterval(async () => { const res = await fetch('/api/rooms/?status=available') // 更新页面上剩余可订房间数 }, 15000)

轮询间隔的权衡:5 秒刷新太频繁,数据库压力大;30 秒以上又会让“只剩 3 间”这种文案显得迟钝。15 秒是预览页和门户站比较平衡的值。后台管理端不要用轮询,必须走 WebSocket,因为前台改房态的频次高,轮询会明显滞后。

5. 常见问题排查:三端联调最容易翻车的五个位置

5.1 WebSocket 一推就断:Nginx 没带 Upgrade 头

现象:本地起 Django 和前端,WebSocket 一切正常;部署到服务器后,前端 console 报 WebSocket connection failed,连接建立后几秒内就关闭。

原因:Nginx 把 80/443 端口的流量转发给 Django 时,默认不转发 Upgrade 和 Connection 头,WebSocket 握手失败。REST 接口正常,只有带 Upgrade 的请求会挂。

解决:在 Nginx 配置里给 WebSocket 路径单独加转发头:

location /ws/ { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; }

proxy_read_timeout 也要调,默认 60 秒,如果服务端和客户端之间 60 秒没有数据,Nginx 会主动断开,再好的心跳也扛不住这个超时。长连接服务建议至少 3600 秒。如果用 docker-compose,proxy_pass 里的地址要换成后端服务名。

5.2 小程序真机连不上本地服务:域名、HTTPS 与局域网 IP 的三角关系

现象:开发者工具里接口一切正常,预览到手机上就全部 request fail。

原因:三个条件同时满足才能连通。第一,微信要求正式环境必须是 HTTPS 和 wss,且域名已备案并配置在小程序后台的 request 合法域名里;第二,本地真机调试时“不校验合法域名”开关只在调试模式时生效,关掉调试立刻断;第三,手机和电脑要在同一个局域网,BASE_URL 写的是电脑的局域网 IP,不是 localhost。

解决:开发阶段手机开启调试模式,BASE_URL 改成本机局域网 IP;真机预览用手机浏览器访问 http://局域网IP:8000/api/health/ 确认能通。上线前把 BASE_URL 换成正式 HTTPS 域名,小程序后台添加 request 合法域名和 socket 合法域名。

另外后端要配好跨域,Django 里加 django-cors-headers,CORS_ALLOWED_ORIGINS 把门户站域名和后台管理端域名都写进去。小程序端没有 CORS 概念,但从浏览器发出的请求都必须过这一关。

5.3 并发订房产生重复订单:房间状态更新缺了一把锁

现象:门户站“秒杀”场景下,两个用户几乎同时预订同一间房,两边都下单成功,数据库里出现两笔 paid 订单指向同一房间。

原因:先查 Room.status 再创建 Order 这两步之间有间隙,两个请求都读到 available,然后各自建单。

解决:创建订单时用 select_for_update 锁行,并且把“校验状态 + 改状态 + 建订单”放进一个事务:

from django.db import transaction @transaction.atomic def create_order(room_id, guest_name, check_in, check_out): room = Room.objects.select_for_update().get(pk=room_id) if room.status not in ("available", "dirty"): raise ValueError("房间已被占用") room.status = "reserved" room.save() return Order.objects.create( room=room, guest_name=guest_name, check_in_date=check_in, check_out_date=check_out, status="paid", channel="portal", )

注意 select_for_update 在事务里才生效,开事务原子块是必须的;数据库也要用支持行锁的引擎,SQLite 的锁粒度是库级别的,开发够用,压测和生产必须换 PostgreSQL。

5.4 换房后房态卡死:状态流转没走统一出口

现象:前台把客人从 301 换到 302,订单显示在 302,但 301 的状态还是 occupied,302 还是 dirty,两间房都不可卖。

原因:换房时直接改了 Order.room 字段,没有同时改两张房间表的状态。这个问题的隐蔽性在于,单步操作在界面上都成功,只有对账时才会暴露。

解决:换房必须通过一个统一的服务函数执行,不能在前端拼两个接口调用:

def change_room(order, new_room): old_room = order.room old_room.status = "dirty" old_room.save() new_room.status = "occupied" new_room.save() order.room = new_room order.save() RoomStatusLog.objects.create(room=old_room, to_status="dirty", operator="frontdesk") RoomStatusLog.objects.create(room=new_room, to_status="occupied", operator="frontdesk")

这个函数也要包在事务里。所有涉及房态变化的操作,check_in、check_out、换房、维修,都必须走这种统一出口,避免“绕过状态机”的前端拼接口写法。

5.5 房价算错:时区配置让入住晚数变成负数

现象:订单显示住了一晚上,但金额按两晚收;或者客人凌晨 1 点入住,离店日期计算错。

原因:Django 默认 TIME_ZONE = 'UTC',如果系统时间用 UTC,而入住日期是本地时间算出来的,日期边界会偏移;如果只设置 TIME_ZONE 不设置 USE_TZ,Django 又会用本地时间做 naive datetime,两种配置混着用最容易出错。

解决:结算按日期而不是按小时计算,服务端统一把“入住晚数”定义成 check_out_date - check_in_date 的天数差值,不依赖当前时刻。settings 里明确时区:

TIME_ZONE = "Asia/Shanghai" USE_TZ = True

注意 USE_TZ=True 时,DateTimeField 存的是带时区的 UTC 时间,前端展示要转回上海时间;如果整个团队都不熟悉时区转换,最保守的做法是 USE_TZ=False,让数据库直接存本地时间,代价是将来做跨时区业务还要再改。我一般建议订单结算相关的日期字段一律用 DateField,不存 DateTimeField,能从根上避开一半问题。

6. 从“能跑”到“能扛”:并发压测脚本与容器化收尾

验证一个酒店管理系统不是看页面是否流畅,而是看并发下房态是否一致。下面这个脚本模拟 20 个用户同时抢同一间房,跑完检查订单数,超过 1 笔就是有问题的:

# scripts/test_concurrent_order.py import threading import requests BASE = "http://127.0.0.1:8000/api" TOKEN = "你的测试token" def grab_room(): headers = {"Authorization": f"Bearer {TOKEN}"} payload = {"room_id": 101, "check_in": "2025-06-01", "check_out": "2025-06-03"} try: r = requests.post(f"{BASE}/orders/", json=payload, headers=headers, timeout=5) print(r.status_code, r.json()) except ValueError: print(r.status_code, r.text) threads = [threading.Thread(target=grab_room) for _ in range(20)] for t in threads: t.start() for t in threads: t.join() # 跑完后查询订单接口,room_id=101 且未取消的订单应该只有 1 笔

跑脚本前先确认 Django 的 debug 模式关掉,否则异常会在终端刷屏而不是返回 JSON。并发压测出了重复订单,优先检查第 5.3 节的 select_for_update 是否真的包在了事务里,以及数据库引擎是不是不支持行锁。脚本需要 requests 库,记得先 pip install requests。

最后一件事是部署。开箱即用的项目最常见的失败是“本地秒开,线上 500”。建议直接上 docker-compose,四个服务:Nginx、Django(daphne)、Redis、PostgreSQL。Django 容器里跑两条命令,一条 migrate,一条 daphne -b 0.0.0.0 -p 8000 config.asgi:application,Nginx 同时托管 Vue3 打包后的静态文件和转发 /api、/ws/ 这两个前缀。

docker-compose up -d --build

我自己的习惯是把上面这些验证做成一个 shell 脚本,每次改完数据库模型先跑迁移、再跑压测、最后看 Redis 里有没有积压的 channel 消息。曾经有一次线上订餐推送丢单,查到最后是 Redis 没配持久化,重启全丢,从那以后我再也不敢不检查 Redis 的 maxmemory 和持久化策略。这个方案的边界也很清楚:它适合中小体量酒店,房间数几百间以内、并发下单不高的场景,再往后要上专业的商用 PMS 系统。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询