ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Flask的北京旅游信息管理平台开发复盘:从数据模型到部署实践

基于Flask的北京旅游信息管理平台开发复盘:从数据模型到部署实践 做外包这些年接到的管理系统需求五花八门但像“北京旅游信息管理平台”这种项目几乎每年都会碰到几次。表面看就是一个信息管理系统把北京主要景点的资料、游客评论、推荐路线管起来。实际上手之后才会发现真正费神的不是写接口而是选型、数据模型设计、部署适配这一连串看起来不起眼的决定。这篇文章我打算把整个项目从零到上线的过程完整复盘一遍包括为什么用 Flask 而不是 Django 或 FastAPI数据表怎么设计才不容易返工部署时会踩到哪些跟环境和服务器相关的坑以及上线后怎么扩展成带预订功能的产品。无论你是刚学 Flask 的初学者还是手头正好接到旅游类 CMS 订单的开发者按这个思路做都会省很多时间。1. 技术选型为什么这类管理系统我用 Flask 而不是 Django1.1 先看清这个项目的真实需求接到需求时客户给的原话往往很简单“做一个北京旅游信息管理平台能看景点、能看路线、能评论就行。”但把这句话翻译成技术语言你需要搞清楚背后几件事。第一平台的用户是谁。前台访客通常只是查景区介绍、看推荐路线、浏览评价偶尔注册账号后发表评论。后台管理员需要维护景区资料、上下架路线、审核评论。也就是说这是一个典型的 C 端展示加 B 端管理组合平台不是纯粹的数据报表系统。第二数据量有多大。北京旅游信息管理平台即使做得很全核心景点的数量也就在几百到几千这个量级路线几十条评论可能几万条。这个体量下任何主流的关系型数据库都能轻松扛住完全不需要一开始就引入复杂的搜索中间件、缓存集群。第三开发周期和团队维护成本。做这类项目的人通常是一个小团队甚至一个人。这意味着框架越重你写起来反而越束手束脚框架太轻所有东西都要自己搭又累得慌。Flask 的“微框架”定位在这里非常合适路由、模板、请求处理开箱即用ORM 接入 SQLAlchemy 后也够规范剩下的钱和精力留给业务本身。提示管理系统类项目最忌讳一上来就堆技术。先把页面清单、角色清单、数据字段清单列出来再谈框架选型自然就清晰了。1.2 Flask、Django 和 FastAPI 的取舍逻辑这个话题如果拿到论坛上讨论往往吵得不可开交但放到“北京旅游信息管理平台”这个具体场景里答案其实非常明确。我自己的习惯是先做一张对比表再下结论。对比维度FlaskDjangoFastAPI学习成本低核心简洁偏高概念多中等依赖类型标注理解自带功能少按需扩展全Admin、ORM、表单都有少主打 API 能力模板渲染Jinja2 很顺手自带模板引擎通常配合前端分离使用异步支持需要额外配置需要版本适配原生异步性能强适用场景中小型全栈网站后台复杂的大型系统纯 API 服务、高并发实时接口部署难度低灵活中结构固定低但配合反向代理更合理放到这个项目里来看Django 的自带 Admin 确实能省掉一部分后台页面但定制起来反而不如自己写 Flask 后台顺手。而且 Django 对模型层有一套相对重的约定后面想调整表结构时的束缚感很强。FastAPI 性能是好但如果项目需要服务端渲染页面而不是纯前后端分离FastAPI 在这方面就得靠额外挂模板绕了一圈还不如 Flask 直接。网上那些“Flask 与 FastAPI 比较”的文章大多是在比接口性能、比异步高并发。可旅游信息管理平台的瓶颈根本不在并发而在页面组织、表单处理、后台操作逻辑。Flask 在这一点上的优势是它不绑架你蓝图、SQLAlchemy、Jinja2 组合起来就是一套完整又轻巧的网站骨架。1.3 最终选型结论我选了 Flask 2.x Flask-SQLAlchemy Jinja2 Bootstrap数据库开发阶段用 SQLite正式部署换成 MySQL 8.0。Flask 的启动方式很简单pip install flask之后十几行代码就能跑起页面配合工厂模式后扩展性也足够。还有一点很重要项目标题里写明了“基于 flask”这本身也是一种约束。客户既然指定了技术栈说明他们之后可能有二次开发团队接手选一个生态稳定、大家都会的框架比追求炫技更重要。Flask 的源码少而清晰遇到问题看源码基本能解决这种确定性在交付时非常值钱。2. 从需求到表结构北京旅游信息管理平台的数据模型设计2.1 功能模块先拆分再谈建表很多新手拿到项目就开始写建表语句结果写到一半发现字段对不上需求反复改表非常伤。我习惯先用一张功能清单把页面和操作锁死。前台模块包含景点列表页、景点详情页、旅游路线列表页、旅游路线详情页、用户注册登录、发表评论、个人收藏。后台模块包含管理员登录、景点管理增删改查、上下架、路线管理、评论审核、基础数据统计。把这些模块列出来后数据表之间的关系就自然浮现了。核心表无非是这几张用户表、景点表、路线表、评论表以及一张多对多的景点路线关联表。如果还要做收藏就再加一张收藏表。不要一上来就设计几十张表旅游信息管理平台不是 ERP先满足核心流程等真有扩展需求时再迁移。注意建表之前花半天时间画清楚“页面 → 操作 → 数据字段”的对应关系后面写代码的速度会快很多。返工最多的一定不是写页面而是反复加字段。2.2 核心数据表是怎么设计的我以一个极简但完整的模型为例这段代码基本可以直接抄走。from datetime import datetime from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash db SQLAlchemy() # 景点和路线是多对多关系需要一张中间表 route_attractions db.Table( route_attractions, db.Column(route_id, db.Integer, db.ForeignKey(route.id), primary_keyTrue), db.Column(attraction_id, db.Integer, db.ForeignKey(attraction.id), primary_keyTrue) ) class User(db.Model): __tablename__ user id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(50), uniqueTrue, nullableFalse) password_hash db.Column(db.String(128), nullableFalse) is_admin db.Column(db.Boolean, defaultFalse, comment是否管理员) created_at db.Column(db.DateTime, defaultdatetime.now) def set_password(self, password): self.password_hash generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) class Attraction(db.Model): __tablename__ attraction id db.Column(db.Integer, primary_keyTrue) name db.Column(db.String(100), nullableFalse, indexTrue, comment景点名称) category db.Column(db.String(50), indexTrue, comment分类古迹/公园/博物馆等) address db.Column(db.String(200), comment地址) description db.Column(db.Text, comment景点介绍) cover_image db.Column(db.String(200), comment封面图路径) is_active db.Column(db.Boolean, defaultTrue, comment是否上架) created_at db.Column(db.DateTime, defaultdatetime.now) class Route(db.Model): __tablename__ route id db.Column(db.Integer, primary_keyTrue) title db.Column(db.String(100), nullableFalse, comment路线标题) days db.Column(db.Integer, default1, comment游玩天数) price db.Column(db.Numeric(10, 2), default0, comment参考价格) description db.Column(db.Text) is_active db.Column(db.Boolean, defaultTrue) attractions db.relationship(Attraction, secondaryroute_attractions, backrefroutes) class Comment(db.Model): __tablename__ comment id db.Column(db.Integer, primary_keyTrue) attraction_id db.Column(db.Integer, db.ForeignKey(attraction.id), indexTrue) user_id db.Column(db.Integer, db.ForeignKey(user.id)) content db.Column(db.Text, nullableFalse) rating db.Column(db.Integer, default5, comment评分1-5) status db.Column(db.Integer, default0, comment0待审核 1通过 2拒绝) created_at db.Column(db.DateTime, defaultdatetime.now)每个字段都有它的理由。Attraction.name加了索引是因为景点名称会出现在搜索和列表筛选中不加索引的话数据量上来后 LIKE 查询会慢。price用Numeric(10, 2)而不是浮点型是因为钱这种字段用浮点数做运算会产生精度问题。User.password_hash只存哈希值绝不存明文密码这是安全底线。评论表的status字段很关键它决定了审核机制怎么做。用户发表的评论默认状态是 0前台页面只查status 1的数据后台管理员可以在审核列表中看到待审核内容。用数字而不是布尔值是因为审核结果不只有“通过/不通过”两种后续还可能加入“自动审核”状态留出的余地正好。2.3 关联表和多对多关系的处理路线和景点是典型的多对多关系一条路线可以包含多个景点一个景点也可以出现在多条路线中。既然是多对多就不能直接在任意一张表里加外键字段必须拆出一张中间表。我上面的route_attractions就是干这个用的。db.relationship里设置了secondaryroute_attractions之后SQLAlchemy 会帮我们自动处理关联查询。比如想获取第一条路线的所有景点route Route.query.get(1) attractions route.attractions反过来想查某个景点出现在哪些路线里attraction Attraction.query.get(1) routes attraction.routes这种双向查询在页面展示时非常方便。景点详情页可以展示“包含该景点的推荐路线”路线详情页可以按顺序展示景点列表。在设计阶段把这种关联关系想清楚后面写视图函数就是一行查询的事。2.4 开发用 SQLite上线换 MySQLFlask-SQLAlchemy 的数据库连接串只需要在配置里改一行就能完成开发环境到生产环境的切换。# config.py import os class Config: SECRET_KEY os.environ.get(SECRET_KEY, dev-secret-key) SQLALCHEMY_DATABASE_URI os.environ.get( DATABASE_URL, sqlite:///tour.db ) SQLALCHEMY_TRACK_MODIFICATIONS False本地跑的时候用 SQLite 零配置直接一个文件存所有数据。生产环境部署前把DATABASE_URL设为mysqlpymysql://username:passwordhost/dbname?charsetutf8mb4然后用 Flask-Migrate 执行迁移就可以了。要注意的是 MySQL 的字符集一定要指定utf8mb4否则中文和 emoji 都可能出现乱码。3. 核心页面和接口如何一步步跑通3.1 用 Blueprint 拆分模块别把所有路由堆一个文件里Flask 最容易被忽视的优点就是蓝图机制。很多教程项目把路由全部写在app.py里页面少还好页面一多你就会发现找一条路由要翻几百行改一处功能还可能误伤别处。我习惯这样组织目录app/ __init__.py # 工厂函数初始化 Flask 扩展 models.py # 数据模型 views/ main.py # 前台页面路由 auth.py # 注册登录路由 admin.py # 后台管理路由 templates/ main/ # 景点列表、详情等模板 auth/ # 登录注册模板 admin/ # 后台管理模板 static/ css/ js/ uploads/ run.py # 启动入口 config.py # 配置在app/__init__.py里像这样注册蓝图from flask import Flask from .models import db from .views.main import main_bp from .views.auth import auth_bp from .views.admin import admin_bp def create_app(): app Flask(__name__) app.config.from_object(config.Config) db.init_app(app) app.register_blueprint(main_bp) app.register_blueprint(auth_bp, url_prefix/auth) app.register_blueprint(admin_bp, url_prefix/admin) return app这样做的直接好处是前台、后台、用户体系各管各的互相不干扰。后面如果想给后台加一个统计报表模块直接新开一个report.py蓝图注册进去就行完全不用改动原有代码。3.2 前台景点列表与详情页的实现前台是整个平台的门面也是游客最关心的部分。列表页要展示封面图、景点名、分类、简介和评分详情页要展示完整介绍、地图位置、评论列表和评分。关键是分页查询和时间消耗要控制好。# views/main.py from flask import Blueprint, render_template, request, abort from ..models import Attraction, Route, Comment from sqlalchemy import func main_bp Blueprint(main, __name__) main_bp.route(/) def index(): page request.args.get(page, 1, typeint) keyword request.args.get(keyword, ).strip() category request.args.get(category, ).strip() query Attraction.query.filter_by(is_activeTrue) if keyword: query query.filter(Attraction.name.contains(keyword)) if category: query query.filter(Attraction.category category) pagination query.paginate(pagepage, per_page12, error_outFalse) attractions pagination.items return render_template( main/index.html, attractionsattractions, paginationpagination, keywordkeyword, categorycategory ) main_bp.route(/attraction/int:attraction_id) def attraction_detail(attraction_id): attraction Attraction.query.filter_by( idattraction_id, is_activeTrue ).first_or_404() comments Comment.query.filter_by( attraction_idattraction_id, status1 ).order_by(Comment.created_at.desc()).all() avg_rating db.session.query( func.avg(Comment.rating) ).filter( Comment.attraction_id attraction_id, Comment.status 1 ).scalar() or 0 return render_template( main/detail.html, attractionattraction, commentscomments, avg_ratinground(float(avg_rating), 1) )这段代码里有个容易忽略的点filter_by(is_activeTrue)一定要加上。管理员在后台把某个景点下架后前台就应该立刻看不见但 URL 如果直接输景点 ID仍可能访问到详情。加上这个条件后下架景点访问会直接返回 404逻辑上更严谨。评分聚合用了func.avg直接在数据库里算平均值而不是把评论全部查出来后在 Python 里循环求和。数据量上万条后这种细节是性能的分水岭。3.3 评论功能与用户登录的关系游客要发表评论必须先登录。实现上并不复杂核心就是记住“评论跟人走”这件事。用户在表单里填完内容后后台要从登录状态中取用户 ID而不是让用户自己传用户 ID。这一点非常关键否则任何人都可以伪造别人的评论。# views/main.py 中发表评论的接口 from flask import request, redirect, url_for, flash from flask_login import login_required, current_user from ..models import Comment main_bp.route(/attraction/int:attraction_id/comment, methods[POST]) login_required def post_comment(attraction_id): content request.form.get(content, ).strip() rating request.form.get(rating, 5, typeint) if not content: flash(评论内容不能为空) return redirect(url_for(main.attraction_detail, attraction_idattraction_id)) rating max(1, min(5, rating)) comment Comment( attraction_idattraction_id, user_idcurrent_user.id, contentcontent, ratingrating, status0 ) db.session.add(comment) db.session.commit() flash(评论已提交等待管理员审核) return redirect(url_for(main.attraction_detail, attraction_idattraction_id))login_required来自 Flask-Login它会自动拦截未登录的用户并跳转到登录页。用户提交的评分在入库前又做了一次max/min限制防止通过篡改表单数据提交超出 1-5 范围的数值。外部输入默认不可信这个习惯一定得养成。3.4 后台管理和权限控制后台页面不需要漂亮的布局但要非常明确谁能进、谁能改、谁只能看。普通用户即使猜到后台 URL也不能让他进。我用的方法是在用户模型里加上is_admin字段再封装一个装饰器。from functools import wraps from flask import abort from flask_login import current_user def admin_required(f): wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or not current_user.is_admin: abort(403) return f(*args, **kwargs) return decorated_function然后后台所有视图函数都加一行admin_required。Flask-Login 的current_user.is_authenticated只表示“登录过”不能代表“是管理员”所以两个条件必须同时判断。后台景点管理页需要支持新增、编辑、删除、上下架。删除操作我建议做软删除也就是把is_active设为 False而不是物理 delete。原因很现实景点一旦被关联到历史路线或历史订单里物理删除会让前台页面出现悬空引用排查起来非常痛苦。4. 部署上线Flask 开发服务器别直接扛生产流量4.1 开发服务器和生产服务器不是一回事Flask 自带的开发服务器会在终端打印一行“WARNING: This is a development server”那句话不是吓唬人。开发服务器在处理并发请求时能力弱也没有专门针对生产环境的超时和优雅关闭处理。用它跑生产环境遇到流量稍微上来一点页面就会卡住。生产环境需要的是一个 WSGI 服务器。Linux 上首选gunicornWindows 上则用waitress。原因是 gunicorn 在 Windows 下兼容性很差而 waitress 是跨平台的。我部署过不少项目这个坑踩得特别实在。Linux 部署命令gunicorn -w 4 -b 127.0.0.1:8000 run:appWindows 部署命令waitress-serve --host127.0.0.1 --port8000 run:app-w 4表示启动 4 个 worker 进程可以更好地利用 CPU 多核。但要注意 worker 数目不是越大越好进程太多会导致内存占用高、数据库连接数过多。一个 2 核 4G 的云服务器配 4 个 worker 就很合适。注意run.py里通常要提供一个app对象供 WSGI 服务器加载不要把app.run()直接放在模块顶层执行否则生产环境也会启动开发服务器。4.2 Nginx 反向代理和静态文件分离生产环境一般不会让用户直接访问 8000 端口前面要加一层 Nginx 做反向代理。它的作用有两个一是把外部请求转发给 gunicorn二是不让 Flask 处理 CSS、JS、图片这些静态文件。Nginx 配置片段server { listen 80; server_name example.com; client_max_body_size 20m; location /static { alias /var/www/tour/app/static; expires 7d; } location /uploads { alias /var/www/tour/app/static/uploads; expires 30d; } 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-Proto $scheme; } }为什么要单独配置/uploads因为上传的景点封面图体积通常比前端静态资源大而且内容会动态变化不应该和静态资源混淆。expires 30d是给浏览器一个缓存时间减少重复请求。但要注意如果后面做了图片替换功能缓存时间设置太长会让用户看到旧图这一点要跟业务确认。client_max_body_size 20m是限制上传文件大小防止有人传一个巨大的文件把服务器磁盘塞满。我见到的很多 Flask 项目都忘了配这一项结果上线后被人传了几十 G 的垃圾文件。4.3 环境变量和配置拆分配置文件里写数据库密码是很多项目的通病。代码一旦传到 git 仓库密码就等于泄露了。我在这个项目里把敏感信息全部放到环境变量里本地开发用.env文件生产环境在 systemd 服务里加载。# .env 示例 SECRET_KEYyour-secret-key DATABASE_URLmysqlpymysql://tour_user:password127.0.0.1/tour_db?charsetutf8mb4Python 启动时加载import os from dotenv import load_dotenv load_dotenv()如果服务器上用 systemd 管理 gunicorn 进程可以在 service 文件里指定环境变量这样进程重启后配置也会自动加载。整个流程下来代码仓库里永远不会出现真实密码。4.4 上线前必须检查的清单app.debug False这个不解释太多开着 debug 模式等于把服务器内部信息暴露给用户。SECRET_KEY换成强随机值不要用默认值。数据库执行过迁移表结构没问题。静态文件目录权限正确上传目录可写。备份策略每天凌晨用mysqldump备份数据库保留最近 7 份。日志gunicorn 的错误日志单独写一个文件方便排查线上问题。gunicorn -w 4 -b 127.0.0.1:8000 \ --error-logfile /var/log/tour/error.log \ --access-logfile /var/log/tour/access.log \ run:app这些检查看着琐碎但任何一个遗漏都可能在上线后变成半夜被叫醒的理由。5. 上线之后的维护心得和扩展方向5.1 中文乱码和时区问题要提前处理北京旅游信息管理平台是中文系统字符集问题会非常突出。MySQL 建库时一定要指定CREATE DATABASE tour_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;如果已经建好了库也要检查表字符集。我接手过的一个项目页面显示正常但写入数据库后变成问号最后查出来是某张表的字符集还停留在 latin1改完建表语句才解决。Flask 侧连接串里加charsetutf8mb4只是第一步后台写数据时最好统一用datetime.now()上层的服务器时间。如果之后要接入国际化用户最好把所有时间字段统一存 UTC展示层再转本地时间。现阶段只做北京本地旅游平台直接存本地时间问题不大但框架上提前留好timezone参数会省掉未来不少事。5.2 查询优化从这几个方向先动手平台上线初期数据少性能问题看不出来。等评论和浏览记录积累到几万条后最容易出问题的位置其实是景区详情的评论列表和搜索页面。第一个优化点是给高频查询字段加索引。Attraction.name、Attraction.category、Comment.attraction_id、Comment.status这四个字段都是查询条件也该进索引。SQLAlchemy 的模型定义里已经写了部分indexTrue剩下的可以在数据库层面手动补。第二个优化点是警惕 N1 查询。在模板里循环景点时如果每个景点都额外查一次评论数那就是 N 次额外查询。正确做法是用一次聚合查询把景点 ID 对应的评论数全部查出来再在模板里做映射。数据量到十万级别时这种优化效果非常明显。第三个优化点是给列表页加个简单的缓存。Flask 里可以用 Flask-Caching 配合 Redis设置 5 分钟缓存from flask_caching import Cache cache Cache(app, config{CACHE_TYPE: redis, CACHE_DEFAULT_TIMEOUT: 300}) main_bp.route(/) cache.cached(timeout60) def index(): # 原有逻辑不变 ...但缓存有代价后台修改景点资料后前台可能要等缓存过期才能看到。这种场景下更稳妥的方式是后台每次修改数据后主动cache.clear()或者对不同的 URL 用不同的缓存 key。我个人的经验是数据量没到百万级别前先别急着上缓存把 SQL 写好更重要。5.3 从“信息管理”升级到“预订交易”的扩展路径“北京旅游信息管理平台”这个名字里的关键词是“管理”但如果客户接着提需求十有八九会往预订、门票、线路下单方向走。这个时候的扩展不是简单加一张订单表而是要动整个数据模型。建议的扩展顺序是先加一张booking_order表记录用户预订了哪条路线、出发日期、人数、支付状态。需要注意订单表关联的是“路线快照”而不是简单的路线 ID。因为路线价格和内容会变化如果用户在 1 月下单路线在 2 月改了价订单里存的应该是用户当时看到的价格实时去查路线表只会得到错误结果。所以订单表里需要冗余字段路线名称、单价、总价、下单时快照等。接着再加支付对账表。虽然这个阶段可能只是对接微信支付或支付宝的简单回调但如果一开始就把支付回调数据单独存一张表未来对账会省很多事。很多小项目做到后面乱就是因为支付、订单、退款全堆在一张表里。如果未来要做手机端可以再考虑把 Flask 的部分页面改造成 API 接口但这个阶段我不建议直接推倒重来。用 Flask 的蓝图继续维护现有的服务端渲染页面另外开一个api蓝图提供 JSON 接口两边共存过渡风险最小。5.4 维护阶段的经验沉淀这类信息管理平台真正开发时间可能只有一两周但维护时间可能长达一两年。所以在写代码阶段就当作“未来会有别人接手”来写能省非常多沟通成本。模型字段里写清楚comment注释路由函数命名清晰页面模板的分块命名直观这些看起来不重要的东西在半年后自己回头修 bug 时会变成救命稻草。另外我强烈建议每次上线前记录一份变更日志哪怕只是两三行文字也能帮客户搞清楚这次更新到底改了什么。在 git 提交信息里我也习惯遵循“类型 摘要”的格式比如feat: 增加评论审核状态筛选、fix: 修复景点分页丢失搜索关键词。这种习惯看似笨拙但当你同时维护四五个项目时检索历史记录的速度会完全不一样。提示客户往往不关心你用了什么设计模式但他们会关心平台能不能稳定跑、改功能快不快。代码结构的价值最终都会体现在“改需求”的速度上。最后说几句实在的做完这个北京旅游信息管理平台我最大的体会是Flask 这类轻框架真正的优势不是“性能强”而是“问题易定位”。源码少、依赖链路短、出 bug 时能快速判断是路由问题、数据库问题还是模板问题这种掌控感在做业务系统时特别重要。如果以后再接到类似项目我会在第一天就把数据库迁移工具接入而不是等项目上到生产环境后才补也会把评论审核功能提前做成可配置的因为客户十有八九中途会改变审核规则。希望这篇复盘能让你少走几趟冤枉路也欢迎在评论区聊聊你部署 Flask 项目时遇到过的坑。
返回列表