☰
用Flask+Vue搭建酒店在线预订系统:从架构设计到部署避坑实战
2026/9/28 6:00:45 网站建设 项目流程

我前段时间帮一家小型酒店做了一套在线预订系统,技术栈选了 Python Flask 做后端,Vue 做前端。很多人一听到 Flask 就觉得它只适合写玩具项目,但酒店预订这种业务规则清楚、界面交互不复杂的场景,Flask 反而比重型框架更合适。再加上 Vue 的组件化开发,前后端分离之后,前端只管渲染页面,后端只管输出 JSON,联调效率一下子拉起来了。这篇文章不打算写那种从零开始的教学文档,而是把我实际搭建这套系统时踩过的坑、拍板过的设计、写过的核心代码,都整理出来。无论是刚学完 Flask 想练手,还是准备用自己的项目找工作,这套思路都能直接用。

整套系统覆盖了用户注册登录、房型列表、房间搜索、在线下单、后台管理这些标准功能。开发过程中最重要的是先把数据模型想清楚,再定接口,最后才是写页面。这个顺序如果反了,后面几乎每改一个字段都要两端一起动,非常痛苦。下面我从技术选型、数据库设计、后端接口、前端页面、部署避坑几个方面,完整拆解一遍实现过程。

1. 整体架构与技术选型

1.1 为什么选 Flask:轻量后端足够撑起酒店预订场景

先讲后端。酒店在线预订系统的核心其实就是几个接口:查房、下单、改单、取消、登录、注册,外加一个后台管理接口。这种业务量级,Flask 的单进程开发服务器在本地跑得飞快,生产环境加上 Gunicorn 或者 Waitress,一个小型酒店完全够用。相比 Django 的“全家桶”,Flask 不会强制你使用固定的目录结构和 ORM 方式,SQLAlchemy 可以单独使用,蓝图可以根据模块自由拆分,学习成本低很多。

我之前也考虑过用 Spring Boot,但那个环境要求对 Java 开发者更友好,对一个 Python 技术栈的团队来说,从建工程到配 Maven、写实体类,光是前期准备就要多花好几天。Flask 的好处在于,你甚至不需要额外装桌面工具,写一个app.py就能把接口跑起来。配合pip install flask flask-sqlalchemy flask-cors flask-jwt-extended,十几分钟就能把项目骨架搭出来。

当然,选择 Flask 也有代价。它的会话管理、表单校验、权限控制都需要自己组装,不像 Django 那样开箱即用。但正因为这样,你反而能把每一块逻辑都看得清清楚楚,出了问题定位非常快。对学习者和中小型项目来说,这种“可控感”比省那几小时配置时间更有价值。

1.2 为什么选 Vue:前后端分离让界面和业务逻辑解耦

前端选择 Vue,最直接的原因是组件化。酒店页面里有很多重复逻辑,比如房型卡片、日期选择器、订单状态标签,如果全部堆在 HTML 里,代码会越写越乱。Vue 把每一个部分拆成独立组件,组件内部维护自己的数据和事件,页面只负责组装,这种思维和 Flask 的蓝图非常搭:后端按功能模块拆蓝图,前端按业务模块拆组件,两边一一对应。

我采用的是 Vue 3 + Vite 的组合,如果你熟悉 Vue 2,思路也完全一样。Vite 在开发环境启动非常快,热更新几乎是秒级,调试体验比旧版的 Webpack 舒服太多。配合 vue-router 做页面路由,pinia 或者简单的 ref 状态管理就能完成跨组件通信。项目规模到不了重度状态管理的级别,硬上一套复杂框架反而让新手看不懂。

有人会问,为什么不直接用服务端渲染 Jinja 模板?如果只是做一个静态展示页,Jinja 确实更方便。但这套系统有大量交互:用户要选日期、查价格、填入住人、看订单状态,每次操作如果都刷新整个页面,体验非常差。Vue 接管之后,前端只用请求 API,拿到 JSON 再渲染,后端不需要管任何页面结构,后续要改成小程序或移动端,API 还能继续复用。

1.3 模块拆解:从浏览到入住通知的完整链路

在设计系统之前,我先画了一遍用户操作流程。用户从首页进入,看到酒店介绍,接着进入房型列表,选择入住日期和退房日期,系统自动过滤不可住房间。用户点进某个房型详情,确认价格和剩余房量,然后填写入住人信息和联系电话,提交订单,系统返回支付状态,随后进入订单列表等待管理员确认。

整个链路里,前端需要 5 个页面:首页、房型列表、房型详情、订单提交、我的订单;后端需要 4 个模块:用户模块、房型模块、订单模块、评价模块。管理员角色可以额外看到一个后台页面,用来修改房型信息、处理订单状态。这些模块之间没有复杂依赖,按业务边界切开之后,每一块都可以独立开发、独立测试。

我在项目里用蓝图来组织后端代码。项目目录大致是这样:

hotel-backend/ ├── app.py # 应用入口 ├── config.py # 配置信息 ├── models.py # 数据库模型 ├── api/ │ ├── auth.py # 登录注册 │ ├── room.py # 房型查询 │ ├── order.py # 订单操作 │ └── admin.py # 后台管理

前端目录对应为:

hotel-front/ ├── src/ │ ├── views/ │ │ ├── Home.vue │ │ ├── RoomList.vue │ │ ├── RoomDetail.vue │ │ ├── OrderConfirm.vue │ │ └── MyOrders.vue │ ├── components/ │ │ ├── NavBar.vue │ │ ├── RoomCard.vue │ │ └── DatePicker.vue │ ├── api/ │ │ └── request.js │ └── router/ │ └── index.js

这种双端结构的好处是,哪边出问题一眼就能看到。前端报错去看 views 和 api 层,后端报错去看 api 和 models 层,不用在整个项目里漫无目的地搜索。

2. 数据库与后端 API 的核心实现

2.1 四张核心表:用户、房型、订单、评价

酒店预订系统的数据库设计,我的经验是“把订单表留足扩展字段”。很多初学者喜欢给每一个信息都单独建表,比如联系人表、支付表、发票表,结果查询一次订单要连五张表,效率反而低。我这里只建了四张核心表:用户表、房型表、订单表、评价表。

用户表存登录信息,包括 id、username、password_hash、phone、role。role 用来区分普通用户和管理员,取值为user或admin。房型表存房间基础信息,包括 id、name、description、price、image_url、total_count、remain_count、max_people、bed_type。这里字段虽然多,但不要用 JSON 字段去塞复杂属性,否则筛选时非常难写 SQL。订单表则是连接用户和房型的关键表,字段包含 id、user_id、room_id、check_in_date、check_out_date、guest_name、guest_phone、total_price、status、create_time。

订单状态我部署了四种:pending待支付、confirmed已确认、cancelled已取消、completed已完成。这个状态机非常简单,但几乎所有逻辑判断都依赖它。比如取消订单时,要检查状态是不是pending或confirmed,否则不允许取消;确认入住时,要把状态从confirmed改成completed。状态字段如果设计成字符串,写起来直观,但记得在代码里定义常量类,避免魔法值散落各处。

评价表单独拆出来,是因为评价和订单是多对一关系,一个订单只能评价一次,但一个用户可以有多个订单。菜品和酒店不同,酒店订单通常跟房型绑定,因此评价时先把订单 ID 写入评价表,再在页面里展示用户对该房型的评分,查询起来很顺手。

关于数据库选型,本地开发我直接用 SQLite,生产环境切换到 MySQL。SQLAlchemy 在这两种数据库间的切换,基本上只需要改一行DATABASE_URL,前提是不要写数据库独有的语法,比如 MySQL 的ON DUPLICATE KEY UPDATE在 SQLite 就用不了,这种坑一定要提前规避。

2.2 用 Flask-RESTful 定义接口规范

Flask 写接口有两种方式,一种是直接用@app.route,另一种是用 Flask-RESTful 的Resource。我推荐后者,尤其是接口变多之后,Resource 类天然支持把同一个路径的 GET、POST 方法放在一个类里管理,代码结构清爽很多。比如订单接口可以拆成:

from flask_restful import Api, Resource from flask import request, jsonify api = Api(app) class OrderResource(Resource): def get(self, order_id=None): """查询订单列表或单个订单""" pass def post(self): """创建订单""" pass def put(self, order_id): """修改订单状态""" pass

接口路径统一以/api开头,方便前端代理和后期部署。我实际的接口列表如下:

方法路径功能权限
POST/api/auth/register用户注册公开
POST/api/auth/login用户登录公开
GET/api/rooms获取房型列表公开
GET/api/rooms/{id}获取房型详情公开
POST/api/orders创建订单登录
GET/api/orders查看当前用户订单登录
PUT/api/orders/{id}/status更新订单状态登录/管理员
POST/api/comments添加评价登录

接口规范最需要强调的是返回格式。我这边统一封装成了{code: 0, message: "success", data: {...}}的结构。这种格式虽然多包了一层,但前端处理错误非常方便:判断code是否为 0,不是就直接弹出message。如果不做统一封装,前端每次都要从 HTTP 状态码和响应体里的各种字段猜后端意图,联调效率会低一半以上。

2.3 订房并发问题的处理:库存与事务

酒店预订和普通购物不同的地方在于,房间数量和日期强相关。同一间房,9 月 1 日有人住,不代表 9 月 2 日不能订。因此不能只靠一张房型表里的remain_count做判断,必须结合订单表里的日期区间来计算。

我实现了一个非常实用的校验方法:查询当前房型在指定日期区间内是否存在状态为pending或confirmed的订单,并且判断日期区间是否有重叠。区间重叠的判断条件是new_check_in < existing_check_out AND new_check_out > existing_check_in。这个公式是日期预订系统的核心,只要记住了它,就不会在边界日期上出错。

def is_room_available(room_id, check_in, check_out): conflict = Booking.query.filter( Booking.room_id == room_id, Booking.check_in < check_out, Booking.check_out > check_in, Booking.status.in_(['pending', 'confirmed']) ).first() return conflict is None

这里要提醒一点:时间的边界是“前者不含,后者含”。也就是说,如果用户预订的是 9 月 1 日入住、9 月 3 日退房,那么系统只占用 9 月 1 日到 9 月 2 日这一晚,9 月 3 日当天其他用户可以入住。这套逻辑在写前端日期控件时也要对应好,才能避免后端说没房、前端却显示能订的尴尬。

至于并发,真正的生产环境不能只用查询判断,因为两个请求同时进入时,可能都查到没有冲突,然后同时创建订单。最稳妥的方案是在创建一个订单的整个流程里,使用数据库事务加行锁。SQLAlchemy 里可以通过with db.session.begin_nested():配合数据库行锁实现,或者简单地把整个下单选房操作包在事务里:

@app.route('/api/orders', methods=['POST']) def create_order(): data = request.get_json() room = Room.query.filter_by(id=data['room_id']).first() # 加行锁,避免并发下单 room = Room.query.with_for_update().filter_by(id=data['room_id']).first() if not is_room_available(room.id, data['check_in'], data['check_out']): return jsonify(code=1, message='该日期区间已无房') order = Order(...) db.session.add(order) try: db.session.commit() except Exception: db.session.rollback() return jsonify(code=1, message='下单失败,请重试') return jsonify(code=0, data=order.to_dict())

2.4 JWT 登录鉴权,别把密码放前端

用户登录我不是用 Flask 自带的 session,而是使用了 JWT。原因是这套系统前后端完全分离,后续可能还要接小程序,session 绑定在特定服务器的 Cookie 上,跨端很难兼容。JWT 生成一个带有效期的 token,前端存到 localStorage,每次请求带上Authorization: Bearer <token>,后端通过装饰器校验身份即可。

Flask-JWT-Extended 这个库用起来非常简单。登录成功后生成 token:

from flask_jwt_extended import create_access_token @app.route('/api/login', methods=['POST']) def login(): username = request.json.get('username') password = request.json.get('password') user = User.query.filter_by(username=username).first() if user and check_password_hash(user.password_hash, password): token = create_access_token(identity=user.id) return jsonify(code=0, data={'token': token, 'role': user.role}) return jsonify(code=1, message='用户名或密码错误')

然后在需要登录的接口上加@jwt_required(),通过get_jwt_identity()拿到用户 ID,再查数据库,就能知道当前用户是谁。注意 JWT 的过期时间不能设置太长,我这里设置了 12 小时,用户一天内不用反复登录,安全性也还能接受。

密码存储必须使用哈希,不能存明文。我用的werkzeug.security里的generate_password_hash和check_password_hash,简单可靠。如果有人跟你说把加密后的 Base64 字符串存进去就行,千万别信,那只是编码,不是加密。

3. Vue 前端从页面到交互的实现细节

3.1 环境准备与工程初始化

前端开发的第一步,先把 Node.js 装好。我用的是 Vue 3 + Vite,创建工程只需要一条命令:

npm create vite@latest hotel-front -- --template vue

进入目录后安装核心依赖:

cd hotel-front npm install npm install vue-router@4 axios element-plus

Element Plus 是 Vue 3 的组件库,日期选择器、表单、表格都是现成的,省去大量手写 CSS 的时间。不过组件库也不是越多越好,这里只用到其中一小部分,按需引入才能避免打包体积过大。

我在实际项目里发现,很多同学卡在环境配置上,尤其是 node-sass 和 Python 的兼容问题。如果用的是 Vite,建议直接使用内置 CSS 预处理器,不需要额外装 node-sass。另外,Node.js 版本不要乱用太新的,比如 20 以上的某些大版本,个别 CLI 插件可能没有适配,稳妥用 LTS 版本就好。

初始化完成后,第一件事是设计路由。因为一开始路由没规划好,后面往页面里加嵌套组件时就容易层层叠叠,思维混乱。我的路由定义很直白:

const routes = [ { path: '/', component: Home }, { path: '/rooms', component: RoomList }, { path: '/rooms/:id', component: RoomDetail }, { path: '/book/:roomId', component: OrderConfirm, meta: { requiresAuth: true } }, { path: '/orders', component: MyOrders, meta: { requiresAuth: true } } ]

requiresAuth字段用来做全局登录守卫。在router.beforeEach里检查 token 是否存在,没有就跳转登录页。这一步不能省略,因为前端的路由守卫只是用户体验层面的控制,真正防越权要靠后端的 JWT 校验。

3.2 页面规划与路由设计

首页承担的是“门面”任务,不能太复杂。我把它分成三个区域:顶部导航栏、中部酒店图文介绍、底部精选房型推荐。精选房型推荐的数据来自后端/api/rooms?limit=3,后端根据创建时间倒序返回前三条。这样首页既有内容,又不至于和房型列表页重复。

房型列表页是流量最集中的页面,也是交互最复杂的页面。顶部放一个日期搜索栏,用户选择入住日期、退房日期,以及入住人数。选择日期后,前端自动调用接口,并把日期参数传给后端,后端返回可选房型列表。这里的交互逻辑是一个连续的链路:日期变化 —— 触发重新请求 —— 列表组件更新 —— 滚动位置保持不变。如果没有用 Vue 的响应式数据,而是一股脑在created里拉一次数据,那用户搜索日期就完全失效了。

房型详情页则展示大图、户型描述、床型、价格,并把这些数据参数传入预订页。预订页和下单一页在订单确认页完成。订单确认页在进入时,需要带上roomId和选好的日期,否则用户可能跳过搜索,直接输入一个错误的日期组合。我通过 Vue Router 的query参数把日期传过来,同时在前端做一次日期合法性的二次校验,避免无效请求打到后端。

3.3 客房搜索、日期联动和组件状态

这个日期搜索组件,是整个前端最值得花时间打磨的地方。需求是:日期范围变化后,刷新可用房间。我定义一个searchParams响应式对象,绑定日期选择组件:

<template> <el-date-picker v-model="searchParams.dates" type="daterange" range-separator="至" start-placeholder="入住日期" end-placeholder="退房日期" value-format="YYYY-MM-DD" /> </template> <script setup> import { reactive, watch } from 'vue' import { fetchRooms } from '../api/room' const searchParams = reactive({ dates: [], guests: 1 }) watch(() => [searchParams.dates, searchParams.guests], async () => { if (searchParams.dates && searchParams.dates.length === 2) { const rooms = await fetchRooms({ check_in: searchParams.dates[0], check_out: searchParams.dates[1], guests: searchParams.guests }) roomList.value = rooms } }) </script>

这里有一个经验:不要每次用户改动日期就立刻请求,而是用一个“查询”按钮触发请求。原因有两点,一是日期选择器在操作过程中,可能会连续触发多次 change 事件,导致请求轰炸;二是用户还没选完日期,后端就已经查了一次,浪费资源。所以我把所有搜索条件先存到searchParams,只有用户点击“搜索房间”时才统一调用接口。

3.4 Axios 封装与接口对接

Axios 是 Vue 项目里最常用的请求库。我习惯在src/api/request.js里做一次封装,统一处理 token 注入、错误拦截,以及响应体解包。封装的好处是,如果后端接口地址发生变化,只需要改一个文件,不需要满项目找axios.get。

import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } ElMessage.error('网络请求异常') return Promise.reject(error) } )

然后每个模块单独建文件,比如src/api/room.js里导出fetchRooms,fetchRoomDetail;src/api/order.js里导出createOrder,fetchMyOrders。页面组件只负责调用这些函数,不直接接触axios。这样做的直接收益是,前端代码可读性大幅提升,后来接手的人不需要翻遍所有 Vue 文件去查接口。

4. 联调、部署与避坑手册

4.1 开发环境跨域,一个代理就能解决

前后端分离开发时,Flask 默认跑在http://127.0.0.1:5000,Vite 默认跑在http://127.0.0.1:5173,端口不同就存在跨域问题。很多同学上来就加flask-cors,在后端放开所有跨域限制。这个方案在开发时能用,但生产环境这么干会有安全隐患。更规范的做法是让 Vite 前端通过代理访问后端接口。

在vite.config.js里配置:

export default { server: { proxy: { '/api': { target: 'http://127.0.0.1:5000', changeOrigin: true } } } }

这意味着前端请求/api/rooms的时候,Vite 开发服务器会把它转发到 Flask 的/api/rooms,浏览器端看到的还是同一个域名,不存在跨域。后端只需要写好接口,不需要处理CORS头。这个方案在生产部署时也成立,因为后续我会让 Flask 直接托管打包后的 Vue 静态文件。

如果确实需要临时开启 CORS 调试,比如前端部署在独立 CDN,后端单独用服务器,那也可以用flask-cors:

from flask_cors import CORS CORS(app)

但这里有一个注意点:CORS默认会允许所有来源,生产环境一定要指定origins白名单,否则任何网页都能调用你的接口,你的接口就会变成公开的代理服务,很容易被刷爆。

4.2 生产环境部署:Flask + Vue 静态文件合并

部署方案我选的是把 Vue 构建后的静态文件交给 Flask 托管,而不是再单独开一个 Nginx 服务静态文件。原因很简单,小项目少一个中间环节就少一个故障点。构建之前,在 Vue 工程里设置base为相对路径或者后端同域根路径,避免出现静态资源路径 404。

// vite.config.js export default { base: './' }

前端打包:

npm run build

打包产物默认在dist目录。我在 Flask 程序里加一段静态文件托管逻辑:

from flask import send_from_directory DIST_DIR = os.path.join(os.path.dirname(__file__), 'dist') @app.route('/', defaults={'path': ''}) @app.route('/<path:path>') def serve_frontend(path): if path and os.path.exists(os.path.join(DIST_DIR, path)): return send_from_directory(DIST_DIR, path) return send_from_directory(DIST_DIR, 'index.html')

这里最关键的是让所有前端路由都返回index.html,否则在地址栏直接输入/orders时,前端路由能正常工作,但 Flask 不知道这个路径对应哪个静态文件,就会返回 404。加了serve_frontend这个兜底之后,Vue Router 的 history 模式才能正常使用。

生产环境跑 Flask,不能用自带的app.run(),那个是单进程开发服务器,性能和稳定性都不够。我用的是 Waitress,一个纯 Python 的 WSGI 容器,在 Windows 和 Linux 上都能用:

pip install waitress waitress-serve --host 0.0.0.0 --port 5000 app:app

如果你在 Linux 服务器上,也可以换 Gunicorn,不过它不支持 Windows。项目如果部署在云服务器,记得在防火墙或者安全组里放行对应端口,否则外网访问不到,这个看起来很低级的问题,我见过不止一个人因此卡了一下午。

4.3 高频问题排查表:从 404 到中文乱码

最后整理一份高频问题排查表,这些都是我在实际开发过程中遇到的真实问题,直接对照解决。

问题现象可能原因处理方法
前端请求接口返回 404代理没生效,或 Flask 路由路径不对检查vite.config.js的 proxy,先直接访问后端接口确认路由
接口返回 500,控制台无详细错误Flask 调试模式未开启开发时设置app.run(debug=True),让完整错误信息返回
保存中文数据后乱码数据库连接没设置 UTF-8SQLite 大概率没事,MySQL 连接串加charset=utf8mb4
前端部署后刷新页面 404Vue Router history 模式没有 fallback把 Flask 里的兜底路由写好,所有非 API 路径返回 index.html
下单日期边界总出错区间判断写反使用check_in < existing_out AND check_out > existing_in判断冲突
多个用户同时订同一间房没有加事务和行锁查询后使用with_for_update()锁定行记录
登录后刷新页面,登录状态丢失token 没有带在请求头里检查 Axios 拦截器,是否从 localStorage 读取 token
Element Plus 样式不生效没在 main.js 完整引入组件库样式引入element-plus/dist/index.css

如果你遇到的是请求返回 401,但 token 明明存在,大概率是 token 过期了,这时应该跳回登录页让用户重新登录。JWT 的过期时间如果不确定,可以在 Flask 里设置JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=12),然后从前端检查 token 的exp字段,提前给用户提示,而不是等到接口报错才被动处理。

还有一个容易被忽视的坑是数据库文件路径。Flask 里如果写了相对路径的 SQLite 地址,项目启动目录不同,创建的数据库文件位置就不一样。最好用base_dir拼接绝对路径:

BASE_DIR = os.path.abspath(os.path.dirname(__file__)) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(BASE_DIR, 'hotel.db')

这个坑我在第一次部署到服务器时踩过一次。本地跑是hotel.db,服务器启动目录在别的文件夹,结果服务一直提示表不存在,查了半天才发现是数据库文件根本没生成到一致的位置。

到这一步,整个酒店在线预订系统的核心功能已经完整跑通。我个人做这个项目的最大体会是,不要把技术选型看得太重,真正决定项目难度的永远是业务边界和数据关系。只要把订单、房型、用户这三者的关系想清楚,后端接口和前端页面就都只是套模板的活。如果你也在做类似的预订系统,我建议先把日期冲突和并发下单这两块代码写好,其他模块就算写得糙一点,后续也容易补。踩过几次坑之后再回头看,当初最担心的跨域、部署问题,其实都是十几行配置的事。

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

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

立即咨询