简介:一套基于Flask和MySQL的旅游旅行网站及管理后台项目源码,面向计算机专业毕业设计、课程设计或希望入门Python Web开发的读者。项目围绕旅游景点展示、线路推荐、用户登录注册、后台数据管理等典型业务展开,结构清晰,适合作为二次开发基础。资源包为ZIP格式,共2871个文件,大小44MB;除了核心的py源码、SQL数据库脚本和HTML模板外,还包含CSS样式、JS交互文件、静态图片以及依赖清单等,覆盖了前后端开发涉及的主要文件类型。当前已有1588人学习下载,可作为毕业设计选题的参考范例。源码附有启动说明,修改config.py中的MySQL用户名密码、创建travel数据库并导入travel.sql,再安装requirements.txt中的依赖即可运行;通过完整代码可学习Flask框架的路由与视图、数据库交互、后台管理模块设计等关键技能。
1. python旅游旅行网站含管理后台源代码,基于flask+mysql:先想清楚再动手
“python旅游旅行网站含管理后台源代码,基于flask+mysql”这个方向,最近在检索里出现得很密集。背后诉求很统一:一个小型旅游信息站,前台能浏览景点、查看详情、搜关键词,后台能登录、维护景点数据、看用户和订单。用Flask做路由和页面渲染,MySQL存结构化数据,正好用最短路径把整条链路跑通。相比Django这种自带各种约定的重框架,Flask几乎不替你做决定,页面怎么拼、SQL怎么写、后台长什么样,都由自己掌控。对新手来说,踩坑少;对要交作业的人来说,代码逻辑也容易被讲清楚。本文按我实际搭这类站点的顺序展开:先选型与建目录,再设计数据库,接着写前台和后台,最后做运行检查和扩展。
2. 选型与项目骨架:为什么Flask+MySQL能撑起一个旅游网站
2.1 轻量框架和关系型数据库的配合点
旅游站的信息结构比较典型:景点表、用户表、订单表、评论表,页面大多是对这些表的查询和展示。数据之间的关系不复杂,但查询方式需要灵活组装。Flask的核心只有路由和模板渲染,剩下的视图逻辑由自己写,写清楚数据库查询后,页面数据自然就活了。
MySQL在这里承担的是“不丢数据、不乱数据”的职责。用关系型表结构维护景点分类、价格、上下架状态,比用文本文件或者内存字典靠谱得多。遇到并发下单或者后台同时修改景点信息,MySQL的事务和行锁也能兜住。
为什么不直接上Django?Django确实自带admin后台,但那个后台字段样式固定,改造成自己想要的形态反而要学不少额外机制。对多数只有十几张页面、几十个接口的旅游站来说,Flask把“轻量”两个字贯彻到底,出问题时定位代码也更直接。Spring Boot则更适合企业级服务治理,对这个体量是杀鸡用牛刀。
2.2 项目目录先分清楚,后面少改文件
很多从网上抄来的旅游网站源码,会把所有路由、数据库语句、模板路径全塞进一个app.py里。程序能跑,但改起需求来很痛苦。我习惯一开始就把目录拆开,至少让入口、配置、数据库封装和模板各归其位。
travel_project/ ├── app.py # Flask入口,注册路由 ├── config.py # MySQL连接参数和密钥 ├── db.py # 连接池封装,只提供 query / execute ├── init_db.py # 初始化数据库和演示数据 ├── requirements.txt # 依赖清单 ├── static/ │ ├── css/ │ │ └── style.css │ └── images/ └── templates/ ├── base.html ├── index.html ├── list.html ├── detail.html └── admin/ ├── login.html ├── spots.html ├── spot_form.html ├── users.html └── orders.htmlapp.py只保留Flask实例和路由;数据库操作全部走db.py,页面模板统一放templates,后台页面单独放在templates/admin子目录里。这样前台和后台的页面不会混在一起,后期做权限拦截也更顺手。
requirements.txt里至少要有flask、pymysql、dbutils这三个包。如果你用vscode做Python开发,新开项目后先选对Python解释器,再执行pip install -r requirements.txt,能减少很多“模块找不到”的误会。
2.3 直接写SQL还是上ORM
这个项目我会选择直接使用PyMySQL,不引入SQLAlchemy之类的ORM。旅游站的表最多五六张,查询模式也相对固定,直接写SQL反而更好排查。一个典型页面就一两句SQL,用参数占位符%s先占住用户输入的位置,既清楚又防止SQL注入。
还有一个不必太依赖ORM的理由是调试方便。后台报错时,直接把打印出来的SQL丢进MySQL客户端执行,马上知道是语法错还是数据错。用了ORM以后,排查问题多了一层映射转换,对初学者并不友好。
2.4 连接池参数:别让每个请求都重新握手MySQL
每个Flask请求如果都新建一次数据库连接,在高并发时会浪费不少时间在TCP握手和身份验证上。常见的做法是引入DBUtils提供的PooledDB连接池,预先创建几个连接,请求结束时归还而不是关闭。
# db.py import pymysql from dbutils.pooled_db import PooledDB pool = PooledDB( creator=pymysql, maxconnections=6, mincached=2, maxcached=4, blocking=True, host='127.0.0.1', port=3306, user='root', password='123456', # 换成你自己的MySQL密码 database='travel_db', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor ) def query(sql, args=None): conn = pool.connection() try: with conn.cursor() as cur: cur.execute(sql, args) return cur.fetchall() finally: conn.close() def execute(sql, args=None): conn = pool.connection() try: with conn.cursor() as cur: cur.execute(sql, args) conn.commit() finally: conn.close()maxconnections=6表示连接池最多维持6个连接;mincached=2表示启动时就准备好2个空闲连接;maxcached=4限制空闲连接数不必超过4个;blocking=True让请求在连接用尽时等待,而不是直接抛异常。这里的conn.close()不是真正断开数据库,而是把连接还给连接池,所以不用担心连接被频繁销毁。
这里特意把query和execute分开:query只负责查询并返回结果,execute负责增删改并提交事务。如果统一用一个函数,连fetchall都会在INSERT语句上白跑一遍,代码语义也会混乱。
2.5 先跑通一条查询再做页面
写完db.py以后,我最先做的不是写页面,而是在命令行跑一句最简单的查询,确认数据库连接没白做。直接在db.py末尾加两行测试代码,或者单独开一个python check.py脚本。
# check.py import db if __name__ == "__main__": result = db.query("SELECT VERSION() AS v") print("MySQL连接成功:", result[0]["v"])如果这里报ModuleNotFoundError: No module named 'pymysql',检查一下当前Python环境是否装过依赖;如果报Connection refused,先确认MySQL服务确实启动了。这一关过了,后面的页面开发才会顺畅。
3. 先把表建对再写页面:旅游站的数据库设计与初始化脚本
3.1 五张核心表怎么定字段
旅游站的数据库设计不需要太复杂,但该有的业务边界要清楚。我一般拆成五张表:景点表spots、用户表users、评论表comments、收藏表favorites、订单表orders。
景点表是核心,字段至少要覆盖名称、分类、城市、价格、封面图、描述、上下架状态。用status TINYINT DEFAULT 1控制上架和下架,比物理删除更安全。用户表除了账号密码,还要有role角色字段和status状态字段,用来区分管理员和普通用户。订单表需要存用户、景点、数量、金额和订单状态,方便后台做查询统计。
路线表这类信息在初期可以合并进景点描述里,不单独建表。保持表数量在五张以内,后台管理代码能省一大截,演示效果也不会打折扣。
3.2 建库建表SQL:字符集从第一句就定死
建库时最怕忘了指定字符集,导致后面中文乱码。MySQL连接字符集、数据库字符集、Python侧字符集必须保持统一,第一次建库就写成utf8mb4最省事。
CREATE DATABASE IF NOT EXISTS travel_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE travel_db; DROP TABLE IF EXISTS spots; CREATE TABLE spots ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, category VARCHAR(30) DEFAULT '', city VARCHAR(50) DEFAULT '', price DECIMAL(10,2) DEFAULT 0.00, cover VARCHAR(255) DEFAULT '', description TEXT, status TINYINT DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;price DEFAULT 0.00对应免费景点,比如免费开放的湖边景区就不用为价格填0发愁。created_at用TIMESTAMP DEFAULT CURRENT_TIMESTAMP自动填充创建时间,不需要Python端手动维护。status的默认值设为1,表示景点默认上架,后台只需要把1改成0就能下架。
订单表一般不用外键约束来关联用户和景点,而是在应用层用user_id和spot_id做查询关联。数据量不大时,这个设计写起来更直接,也不容易因为外键导致删除互相牵制。
3.3 init_db.py:一条命令建库建表并写入管理员
每次都手动到MySQL客户端执行SQL,既麻烦又容易漏步骤。常见的做法是写一个初始化脚本,把建库、建表、插入管理员账号的流程固化下来,以后想重置数据,跑一遍就回到初始状态。
# init_db.py import pymysql conn = pymysql.connect( host='127.0.0.1', port=3306, user='root', password='123456', # 改成你的MySQL密码 charset='utf8mb4' ) cur = conn.cursor() cur.execute(""" CREATE DATABASE IF NOT EXISTS travel_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci """) cur.execute("USE travel_db") # 建表前先清空旧表,保证脚本能重复执行 cur.execute("DROP TABLE IF EXISTS spots") cur.execute(""" CREATE TABLE spots ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, category VARCHAR(30) DEFAULT '', city VARCHAR(50) DEFAULT '', price DECIMAL(10,2) DEFAULT 0.00, cover VARCHAR(255) DEFAULT '', description TEXT, status TINYINT DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) # users、orders、comments、favorites 表结构按同样方式创建 demo_spots = [ ("故宫", "人文古迹", "北京", 60.00, "明清皇家宫殿,历史建筑群"), ("西湖", "自然风光", "杭州", 0.00, "湖光山色,环湖步道适合骑行"), ("成都大熊猫繁育研究基地", "亲子乐园", "成都", 55.00, "近距离观察大熊猫"), ("张家界国家森林公园", "自然风光", "张家界", 248.00, "砂岩峰林地貌,适合徒步"), ] cur.executemany( "INSERT INTO spots(name, category, city, price, description) VALUES(%s, %s, %s, %s, %s)", demo_spots ) # 管理后台账号,角色0表示管理员 cur.execute( "INSERT INTO users(username, password, role, status) VALUES(%s, %s, %s, %s)", ("admin", "admin123", 0, 1) ) conn.commit() cur.close() conn.close() print("数据库初始化完成")注意脚本开头先DROP TABLE再建表,是为了可重复执行。演示期间需要重置数据时,不会因为表已存在而报错。executemany适合批量插入多条景点记录,参数一次传入列表,循环由MySQL驱动完成。
这个脚本能跑通以后,你再去用Navicat for MySQL或者MySQL Workbench查看数据,就能看到一张整整齐齐的spots表。如果图形化工具连不上,多半是MySQL服务没启动或者密码不对,排查方向比代码问题更靠前。
3.4 字符集、索引和MySQL8的认证插件
数据库层面的字符集全用utf8mb4,页面模板里也要加<meta charset="utf-8">,两头都对了才不出现“???”。如果你用的MySQL版本比较新,默认认证插件是caching_sha2_password,而本机装的PyMySQL较旧,可能会报Authentication plugin ... cannot be loaded。解决方案首选升级PyMySQL到新版本;如果项目环境不好动,也可以在MySQL里执行ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '123456';。
索引方面,先给spots.name和spots.city建普通索引。数据量只有几百条时效果不明显,但order by和where的频率一上来,没索引的查询响应时间会明显变长。等站点有了几千条景点数据再回头补索引,代价更大。
4. 前台页面常见问题:路由、模板和搜索怎么调
4.1 三个核心路由:首页、列表、详情
前台页面的路由设计遵循一个最简原则:首页展示重点景点,列表页支持翻页,详情页展示单条完整信息。Flask里用@app.route装饰器把URL和处理函数绑在一起,视图函数返回render_template渲染模板。
# app.py from flask import Flask, render_template, request import db app = Flask(__name__) app.config["SECRET_KEY"] = "change-me-in-production" @app.route("/") def index(): spots = db.query( "SELECT * FROM spots WHERE status=1 ORDER BY id DESC LIMIT 8" ) return render_template("index.html", spots=spots) @app.route("/spots") def spot_list(): page_raw = request.args.get("page", "1") page = int(page_raw) if page_raw.isdigit() else 1 page = max(page, 1) size = 10 offset = (page - 1) * size total = db.query("SELECT COUNT(*) AS c FROM spots WHERE status=1")[0]["c"] spots = db.query( "SELECT * FROM spots WHERE status=1 ORDER BY id DESC LIMIT %s OFFSET %s", (size, offset) ) return render_template( "list.html", spots=spots, page=page, total=total, size=size ) @app.route("/spot/<int:spot_id>") def spot_detail(spot_id): rows = db.query("SELECT * FROM spots WHERE id=%s", (spot_id,)) if not rows: return "景点不存在", 404 return render_template("detail.html", spot=rows[0]) if __name__ == "__main__": app.run(debug=True, host="127.0.0.1", port=5000)这里spot_detail的URL传参用<int:spot_id>,Flask会自动做类型转换,天然挡住/spot/abc这类非法请求。list.html里要继续做分页,所以把total和size都传给了模板;前端自己算总页数。
首页只取前8条数据,不做分页,页面更轻盈;列表页才做完整分页。如果你需要搜索功能,spot_list函数后面还要再加一层关键词过滤,见4.3。
4.2 Jinja2模板:循环、判空和不写重复布局
Flask默认使用Jinja2模板。模板里用{% for %}循环列表数据,用{{ s.name }}输出字段值。渲染时把SQL查出来的字典列表传进来,Jinja会自动读取键名。一个典型的首页景点卡片模板如下。
{% extends "base.html" %} {% block content %} <div class="spot-grid"> {% for s in spots %} <a class="card" href="{{ url_for('spot_detail', spot_id=s.id) }}"> {% if s.cover %} <img src="{{ s.cover }}" alt="{{ s.name }}"> {% else %} <div class="placeholder">暂无图片</div> {% endif %} <h3>{{ s.name }}</h3> <p>{{ s.city }} · {{ s.category }}</p> <p>¥{{ s.price }}</p> </a> {% else %} <p>还没有录入景点数据,请先进入管理后台添加。</p> {% endfor %} </div> {% endblock %}extends "base.html"用来继承公共布局,把导航栏和页脚写在base模板里,子模板只需要填充content块。{% for %}配合{% else %}是个容易忽略的好功能:当列表为空时,直接显示提示语,不用专门写if判断。
模板里尽量用url_for()生成链接,不要手写/spot/3这样的硬编码路径。以后改了URL规则,模板不用跟着改。
4.3 搜索实现:关键词怎么拼SQL参数
搜索功能是前台最容易踩坑的地方。常见的实现是用LIKE模糊匹配,搜索词同时匹配景点名称、城市和描述。把关键词拼进SQL时要使用%s占位符,不能直接f"SELECT ... {kw}",否则等于把SQL注入漏洞暴露在公网上。
@app.route("/spots") def spot_list(): kw = request.args.get("kw", "").strip() page_raw = request.args.get("page", "1") page = int(page_raw) if page_raw.isdigit() else 1 page = max(page, 1) size = 10 offset = (page - 1) * size where = "status=1" params = [] if kw: where += " AND (name LIKE %s OR city LIKE %s OR description LIKE %s)" like = "%" + kw + "%" params = [like, like, like] total = db.query( f"SELECT COUNT(*) AS c FROM spots WHERE {where}", params )[0]["c"] spots = db.query( f"SELECT * FROM spots WHERE {where} ORDER BY id DESC LIMIT %s OFFSET %s", params + [size, offset] ) return render_template( "list.html", spots=spots, page=page, total=total, size=size, kw=kw )where字符串是由固定条件拼出来的,用户输入只通过%s进入参数列表,不会破坏SQL结构。这里f-string只往where变量里拼了固定值,与直接拼接用户输入不同。
分页时要注意一个常见问题:翻到第2页以后,模板里的分页链接如果只改了page参数,kw会被丢掉。分页链接要写成/spots?kw={{ kw }}&page=2这种形式,把搜索关键词一起带过去。我在模板里一般用一个pagination_url变量统一处理,减少漏参数的概率。
4.4 静态文件路径和模板路径的排查方向
前台页面的<link>和<script>标签,统一用url_for('static', filename='css/style.css')生成路径。这样Flask会从项目的static目录找文件,不管将来挂在哪个子路径下,路径都能对上。
如果页面能打开但CSS、图片全部失效,先看浏览器控制台请求地址是不是404。常见原因有两个:一是HTML里写了/css/style.css这种绝对路径,部署时一旦站点不在域名根目录就会断;二是忘记把图片文件放进static/images,而模板里的cover字段还是一个外链地址,本地访问不到外网图片时就会显示裂图。
旅游站的演示图片建议直接放本地,不依赖外链。封面字段在数据库里存相对文件名,用url_for('static', filename='images/' + s.cover)渲染,即使没有外网,首页也完整可看。
4.5 前台翻车记录:三个我遇到最多的现象
字符集不对导致页面出现乱码,现象是后台录的“西湖”到前台变成一串问号。原因往往是建库时没指定utf8mb4,或者Python连接参数漏了charset='utf8mb4'。解决方法是回到MySQL把库表字符集改成utf8mb4,Python侧参数补上,重启Flask再试。
模板改了半天没变化,原因是Flask默认启动在debug模式时,模板变更会自动重载,但浏览器缓存了静态文件。解决方法是强制刷新,或者在启动参数里明确debug=True后重启进程。
分页翻到第二页报错或者数据重复,原因是SQL里LIMIT和OFFSET写反。LIMIT size OFFSET offset表示取从偏移量开始的size条记录,顺序不能反,否则数据错位。
5. 管理后台从登录到CRUD:会话、权限和表单一次做完整
5.1 后台登录与会话保持
后台和前台最大的区别是身份校验。Flask用session保存登录状态,登录成功后写入用户ID,后续请求通过装饰器检查这个ID是否存在。密码校验在演示阶段可以先明文对比,但放到生产环境前,一定要改成哈希存储和校验。
from flask import session, redirect, url_for, request, render_template import functools @app.route("/admin/login", methods=["GET", "POST"]) def admin_login(): if session.get("admin_id"): return redirect(url_for("admin_index")) if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "").strip() rows = db.query( "SELECT id, username, role FROM users WHERE username=%s AND password=%s", (username, password) ) if rows: session["admin_id"] = rows[0]["id"] return redirect(url_for("admin_index")) return render_template("admin/login.html", error="用户名或密码错误") return render_template("admin/login.html") def admin_required(view): @functools.wraps(view) def wrapper(*args, **kwargs): if not session.get("admin_id"): return redirect(url_for("admin_login", next=request.path)) return view(*args, **kwargs) return wrapperadmin_required装饰器是保护后台页面的关键。它读取session["admin_id"],不存在就跳回登录页,同时把原始地址放进next参数,登录成功后可以再跳回去。functools.wraps保留被装饰函数的函数名和文档,Flask的路由映射不会因为包装而出错。
登录成功后进入后台首页,可以先显示几个统计数据,比如景点数、用户数、订单数。这些统计用三句SELECT COUNT(*)就能完成,不会给数据库带来压力。
5.2 景点新增和编辑复用同一个表单
后台维护景点信息,最省事的写法是新增和编辑共用一个spot_form.html模板。新增时表单是空的,编辑时先把当前记录查出来填充到表单里。这样少写一套页面,字段一致性也容易保证。
@app.route("/admin/spots/new", methods=["GET", "POST"]) @admin_required def admin_spot_new(): if request.method == "POST": _save_spot_from_form() return redirect(url_for("admin_spots")) return render_template("admin/spot_form.html", spot=None) @app.route("/admin/spots/<int:spot_id>/edit", methods=["GET", "POST"]) @admin_required def admin_spot_edit(spot_id): rows = db.query("SELECT * FROM spots WHERE id=%s", (spot_id,)) if not rows: return "景点不存在", 404 if request.method == "POST": _save_spot_from_form(spot_id) return redirect(url_for("admin_spots")) return render_template("admin/spot_form.html", spot=rows[0])这里把保存逻辑抽成_save_spot_from_form,复用提交表单的代码。函数内部先取表单字段,再做INSERT或UPDATE。
def _save_spot_from_form(spot_id=None): name = request.form.get("name", "").strip() city = request.form.get("city", "").strip() category = request.form.get("category", "").strip() price = request.form.get("price", "0").strip() price = float(price) if price.replace(".", "", 1).isdigit() else 0.0 cover = request.form.get("cover", "").strip() description = request.form.get("description", "").strip() if spot_id is None: db.execute( "INSERT INTO spots(name, city, category, price, cover, description) " "VALUES(%s, %s, %s, %s, %s, %s)", (name, city, category, price, cover, description) ) else: db.execute( "UPDATE spots SET name=%s, city=%s, category=%s, price=%s, " "cover=%s, description=%s WHERE id=%s", (name, city, category, price, cover, description, spot_id) )price字段单独处理,用户在表单里填空或填非数字时默认置为0。这比让程序抛异常友好得多。注意request.form.get取到的都是字符串,必须自己转换类型。这条血泪经验几乎每个后台页面都会遇到。
5.3 删除与软删除:给操作留后悔药
管理后台的删除按钮看起来简单,但直接执行DELETE FROM有风险。如果景点已被订单引用,物理删除会导致后台订单页面出现悬空数据。我一般给后台默认提供软删除:把spots.status改成0,页面不再展示,但记录还留在数据库里。
@app.route("/admin/spots/<int:spot_id>/disable", methods=["POST"]) @admin_required def admin_spot_disable(spot_id): db.execute("UPDATE spots SET status=0 WHERE id=%s", (spot_id,)) return redirect(url_for("admin_spots")) @app.route("/admin/spots/<int:spot_id>/enable", methods=["POST"]) @admin_required def admin_spot_enable(spot_id): db.execute("UPDATE spots SET status=1 WHERE id=%s", (spot_id,)) return redirect(url_for("admin_spots"))两个路由分别负责下架和重新上架,模板里根据spot.status显示不同的按钮。这样演示时你能证明“下架后前台不在展示,但数据没有丢”,比直接删掉更能体现工程意识。
如果真需要物理删除,我会限定在“还没有产生订单的景点”上,SQL里加一条NOT EXISTS子查询判断。否则宁可用软删除,这是最简单的后悔药。
5.4 用户管理和订单查询:JOIN一次搞定
用户管理页面用来查看注册用户和封禁异常账号。后台查询用户不用把密码查出来,只选ID、用户名、角色、状态、创建时间就够。封禁操作用UPDATE users SET status=0 WHERE id=%s,和景点下架逻辑一致。
订单页面相对复杂一点,因为订单表里存的是用户ID和景点ID,直接展示数字不直观。需要用LEFT JOIN把用户名和景点名称关联出来。
@app.route("/admin/orders") @admin_required def admin_orders(): rows = db.query( """ SELECT o.id, o.order_no, u.username, s.name AS spot_name, o.qty, o.amount, o.status, o.created_at FROM orders o LEFT JOIN users u ON o.user_id = u.id LEFT JOIN spots s ON o.spot_id = s.id ORDER BY o.id DESC LIMIT 100 """ ) return render_template("admin/orders.html", orders=rows)LEFT JOIN保证即使订单里的用户或景点被删除了,订单记录仍然能查出,只是对应字段为空。这在演示阶段能帮你少解释很多异常数据。
订单状态可以维护一个简单的状态码:1待支付、2已支付、3已完成、4已取消。后台表格里放一个下拉框,提交表单时执行UPDATE orders SET status=%s WHERE id=%s。状态码用数字存数据库,展示时在模板里映射成文字,比直接存中文更规范。
5.5 后台权限的边界
后台页面都加@admin_required装饰器以后,还要注意一件事:普通用户理论上也能访问/admin/users。如果系统里有普通注册用户,最好在装饰器里再检查session.get("admin_role")是否等于0。角色字段在登录时一并写入session,后续访问控制就不需要频繁查库。
我的习惯是,只要涉及管理后台,就假设每一个请求都可能来自非管理员。列表页、表单页、状态更新页全部加装饰器,一个路由都不能漏。漏掉任何一个,等于把后台大门敞开。
6. 运行检查与三条扩展路径:让演示现场别翻车
6.1 五分钟手动验证清单
我每次给这个项目做交付检查,不依赖自动化测试,直接按一条固定顺序过一遍。先确认MySQL服务在运行,然后执行python init_db.py重置数据,再执行python app.py启动Flask。浏览器打开首页看8条景点是否显示,搜索“杭州”能不能搜到西湖,点进详情页看描述是否正常。最后打开/admin/login登录后台,新增一个景点,再把它下架,刷新首页确认前台不再展示。
这套操作下来,如果每一步都正常,项目基本可以放心拿去演示。哪一步出问题,日志就能直接指到是哪层断掉。
6.2 三条值得继续做的扩展
第一个扩展是把图片字段从URL改成上传文件。用Flask的request.files接收图片,存储到static/uploads,文件名用werkzeug.utils.secure_filename处理,防止用户上传文件名带斜杠或特殊字符。
第二个扩展是搜索升级。现在用LIKE '%关键词%'在数据量大时会全表扫描。可以给spots表加全文索引,MySQL自带ngram解析器能支持中文分词,查询改成MATCH(name, city, description) AGAINST(%s)。
第三个扩展是订单状态机。当前订单只是一张表和一个状态数字,升级后可以记录每个状态变更的时间和操作人,做成一张order_logs表,让后台能看到订单从支付到完成的完整轨迹。
这些方向做哪一个都取决于交付时间。我的习惯是先把验证清单跑通,再考虑扩展;基础链路不完整时,再花哨的功能也补不回来,自己也会被返工折腾得失去耐心。希望这篇文章能帮你把Flask+MySQL的旅游站从“能跑”推到“经得起问”,也帮你在翻车时少走几段弯路。
本文还有配套的精品资源,点击获取