
基于旅游网站源码添加目的地分类与搜索功能
旅游网站的目的地列表往往最初只有一组固定内容。随着景点、城市或线路增加,访客需要按类别浏览,也需要通过关键词快速查找。给已有源码添加这两种能力,表面上是增加几个下拉框和输入框,实际还涉及数据结构、旧数据迁移、查询边界、分页以及输出安全。
本文用 Flask 与 SQLite 演示一套可运行的最小实现:目的地有分类,列表页支持分类筛选、关键词搜索和分页。示例适合已有 Python 后端、规模不大且使用关系型数据库的旅游网站;如果现有项目使用 Laravel、Django、Rails 或其他技术,应沿用其 ORM、路由和模板约定,迁移的是设计思路,而不是把示例文件直接塞进项目。大流量检索、多语言分词、地理位置搜索等需求,也不应仅靠本文的 SQLite 查询硬撑。
一、先明确数据与功能边界
先确认网站中“目的地”具体指什么:国家、城市、景区、酒店,还是可预订的旅行产品。这里将一个可展示的地点抽象为目的地,字段包括名称、描述和分类。分类示例为“海滨”“山岳”“城市”,每个目的地属于一个分类。若业务要求一个目的地同时属于多个分类,则应改用多对多关系,不能继续使用单个 category_id。
- 分类筛选:按分类 ID 精确匹配,不把用户输入的分类名称直接拼进 SQL。
- 关键词搜索:在目的地名称和描述中查找子串。它不是搜索引擎,不提供相关性排序、同义词、中文分词或拼写纠错。
- 分页:每页固定显示有限数量,避免一次读取全部目的地。
- 数据可信度:只展示已发布目的地是常见要求,生产项目应在查询条件中加入实际的发布状态等业务规则。
示例会限制搜索词长度、分类参数和页码,但这些限制各有用途:长度上限减少不必要的输入和查询工作,页码校验避免异常偏移;它们不是身份验证、授权或防止所有拒绝服务攻击的完整方案。SQL 参数绑定用于防止输入被当作 SQL 语法执行,但不会自动处理 HTML 输出,也不会消除通配符的搜索含义。
二、准备运行环境
以下示例按 Python 3.10 及以上版本编写。Flask 负责 HTTP 路由和模板渲染,SQLite 由 Python 标准库提供。创建项目目录后安装 Flask:
python -m venv .venv
# Linux 或 macOS
. .venv/bin/activate
# Windows PowerShell
# .venv\Scripts\Activate.ps1
pip install Flask
在项目根目录创建 app.py。为了让示例可以独立运行,代码包含建表、示例数据和列表路由。实际接入已有源码时,通常应把建表逻辑移交给项目的迁移工具,把数据库连接移交给项目已有的数据访问层。
三、建立分类与目的地表
两个表用外键表达分类关系。SQLite 默认并不保证外键约束总是开启,因此每次创建连接时执行 PRAGMA foreign_keys = ON。下面的初始化函数会在数据库为空时插入少量演示数据;重复运行不会重复插入这些示例记录。
import os
import sqlite3
from pathlib import Path
from flask import Flask, abort, render_template, request, url_for
app = Flask(__name__)
DATABASE = Path(__file__).with_name("tourism.sqlite3")
PER_PAGE = 12
MAX_QUERY_LENGTH = 100
def get_db():
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init_db():
with get_db() as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS destinations (
id INTEGER PRIMARY KEY,
category_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
published INTEGER NOT NULL DEFAULT 1
CHECK (published IN (0, 1)),
FOREIGN KEY (category_id) REFERENCES categories(id)
);
CREATE INDEX IF NOT EXISTS
idx_destinations_category_published
ON destinations(category_id, published);
""")
category_count = db.execute(
"SELECT COUNT(*) FROM categories"
).fetchone()[0]
if category_count == 0:
categories = [
("海滨", "coast"),
("山岳", "mountain"),
("城市", "city"),
]
db.executemany(
"INSERT INTO categories (name, slug) VALUES (?, ?)",
categories,
)
category_ids = {
row["slug"]: row["id"]
for row in db.execute(
"SELECT id, slug FROM categories"
).fetchall()
}
destinations = [
(category_ids["coast"], "青岛", "海滨城市,适合漫步与品尝海鲜。", 1),
(category_ids["coast"], "厦门", "拥有海岸风光与城市街区。", 1),
(category_ids["mountain"], "黄山", "以山地景观和徒步路线闻名。", 1),
(category_ids["city"], "成都", "适合体验城市生活与本地饮食。", 1),
(category_ids["city"], "待发布地点", "这条记录不会出现在列表中。", 0),
]
db.executemany(
"""INSERT INTO destinations
(category_id, name, description, published)
VALUES (?, ?, ?, ?)""",
destinations,
)
@app.cli.command("init-db")
def init_db_command():
init_db()
print("数据库已初始化。")
@app.get("/destinations")
def destinations():
init_db()
raw_query = request.args.get("q", "")
query = raw_query.strip()
if len(query) > MAX_QUERY_LENGTH:
abort(400, description="搜索词不能超过 100 个字符。")
raw_category = request.args.get("category", "")
category_id = None
if raw_category:
if not raw_category.isascii() or not raw_category.isdecimal():
abort(400, description="分类参数无效。")
category_id = int(raw_category)
if category_id <= 0:
abort(400, description="分类参数无效。")
raw_page = request.args.get("page", "1")
if not raw_page.isascii() or not raw_page.isdecimal():
abort(400, description="页码必须是正整数。")
page = int(raw_page)
if page < 1 or page > 100000:
abort(400, description="页码超出允许范围。")
conditions = ["d.published = 1"]
params = []
if category_id is not None:
conditions.append("d.category_id = ?")
params.append(category_id)
if query:
# 将用户输入中的 LIKE 通配符转为普通字符。
escaped_query = (
query.replace("!", "!!")
.replace("%", "!%")
.replace("_", "!_")
)
pattern = f"%{escaped_query}%"
conditions.append(
"(d.name LIKE ? ESCAPE '!' "
"OR d.description LIKE ? ESCAPE '!')"
)
params.extend([pattern, pattern])
where_sql = " AND ".join(conditions)
with get_db() as db:
categories = db.execute(
"SELECT id, name FROM categories ORDER BY name"
).fetchall()
if category_id is not None and not any(
row["id"] == category_id for row in categories
):
abort(404, description="找不到该分类。")
total = db.execute(
f"""SELECT COUNT(*)
FROM destinations AS d
WHERE {where_sql}""",
params,
).fetchone()[0]
total_pages = max(1, (total + PER_PAGE - 1) // PER_PAGE)
if page > total_pages:
abort(404, description="该页没有内容。")
offset = (page - 1) * PER_PAGE
items = db.execute(
f"""SELECT d.id, d.name, d.description, c.name AS category_name
FROM destinations AS d
JOIN categories AS c ON c.id = d.category_id
WHERE {where_sql}
ORDER BY d.name, d.id
LIMIT ? OFFSET ?""",
[*params, PER_PAGE, offset],
).fetchall()
return render_template(
"destinations.html",
items=items,
categories=categories,
selected_category=category_id,
query=query,
page=page,
total_pages=total_pages,
)
if __name__ == "__main__":
init_db()
app.run(debug=True)
注意,示例中的 SQL 通过字符串插值拼接的是服务端写死的查询片段,例如 WHERE 条件的结构;分类 ID、搜索词、分页限制和偏移量仍然通过 ? 参数传入。不要把用户输入直接插进 SQL 字符串。参数绑定解决的是 SQL 语法注入问题,不负责验证业务规则,也不负责输出转义。
LIKE 中的 % 和 _ 本身有通配含义。若不转义,用户搜索 % 会匹配大量内容,搜索 _ 也会匹配任意单个字符。示例采用 ! 作为 SQL 的转义字符,并先把输入中的 !、%、_ 转义,因此用户输入按普通文本处理。若业务希望用户能输入通配符,应明确这一行为并评估查询成本,不要把“参数绑定”误认为已经解决通配符问题。
示例通过固定的 published = 1 条件排除未发布数据。如果旧项目没有发布字段,应删除该条件;如果项目有软删除、租户隔离或权限过滤,则必须在计数查询和列表查询中一致地加入对应条件。仅在模板里隐藏未授权内容,不足以保护数据。
四、编写搜索与分类界面
在项目目录下创建 templates/destinations.html。表单使用 GET 方法,使筛选条件能出现在 URL 中,便于复制和分享。分类选项的值是数据库 ID,展示给人的名称与实际筛选值分离。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>目的地搜索</title>
</head>
<body>
<main>
<h1>查找目的地</h1>
<form method="get" action="{{ url_for('destinations') }}">
<label for="q">关键词</label>
<input
id="q"
name="q"
type="search"
maxlength="100"
value="{{ query }}"
placeholder="输入城市或描述关键词">
<label for="category">分类</label>
<select id="category" name="category">
<option value="">全部分类</option>
{% for category in categories %}
<option
value="{{ category.id }}"
{% if selected_category == category.id %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
<button type="submit">搜索</button>
<a href="{{ url_for('destinations') }}">清除条件</a>
</form>
{% if items %}
<ul>
{% for item in items %}
<li>
<h2>{{ item.name }}</h2>
<p>分类:{{ item.category_name }}</p>
<p>{{ item.description }}</p>
</li>
{% endfor %}
</ul>
{% else %}
<p>没有找到符合条件的目的地。</p>
{% endif %}
{% if total_pages > 1 %}
<nav aria-label="目的地分页">
{% if page > 1 %}
<a href="{{ url_for(
'destinations',
q=query,
category=selected_category,
page=page - 1
) }}">上一页</a>
{% endif %}
<span>第 {{ page }} / {{ total_pages }} 页</span>
{% if page < total_pages %}
<a href="{{ url_for(
'destinations',
q=query,
category=selected_category,
page=page + 1
) }}">下一页</a>
{% endif %}
</nav>
{% endif %}
</main>
</body>
</html>
Flask 默认使用 Jinja 模板,普通 HTML 模板中的变量会自动进行 HTML 转义。不要为了方便对用户可控内容使用 |safe,也不要先把输入转换成 HTML 再存数据库。模板转义能降低内容被解释为 HTML 或脚本的风险,但不替代输入校验、授权检查或内容审核。若项目关闭了自动转义,或改为在 JavaScript、CSS、URL 等不同上下文中输出数据,需要按相应上下文采取措施。
五、启动并验证
在项目根目录运行:
python app.py
浏览器访问 http://127.0.0.1:5000/destinations。也可以直接测试 URL:
/destinations?q=海:搜索名称或描述中包含“海”的已发布目的地。/destinations?category=1:按 ID 为 1 的分类筛选。分类 ID 由数据库决定,不应假定生产环境中始终固定为 1。/destinations?q=城市&category=3&page=1:同时使用关键词、分类和页码。/destinations?q=%25:搜索字面量百分号,不把它当作通配符。/destinations?page=0或/destinations?page=abc:返回参数错误;不存在的分类和超出结果范围的页码返回 404。
测试时不要只验证“正常关键词能搜到”。还应检查空搜索、前后空格、超过 100 个字符的输入、无效分类、无效页码、没有结果、搜索词包含引号或 SQL 片段、包含 %、_、! 的情形,以及包含类似 <script> 内容的目的地名称。最后一种情况应当作为普通文本显示,而不是执行脚本。
六、在现有源码中落地
如果已有目的地表,先检查字段和数据,再设计迁移,不要直接照着示例重建数据库。比较稳妥的改造顺序如下:
- 盘点字段与数据来源。确认目的地是否已有分类文字、是否允许空描述、有没有下架或软删除标记,以及已有数据的编码和重复情况。
- 确定分类关系。一个目的地只属于一个分类时,可以增加外键;若一个地点能属于多类,则创建关联表,例如
destination_categories(destination_id, category_id),并以复合主键或唯一约束防止重复关联。 - 先迁移、后切换代码。为旧记录分配有效分类,再添加非空约束。不同数据库对“增加非空列并设置默认值”的迁移行为不完全相同,应使用项目支持的迁移工具,并先在备份或测试库演练。
- 复用现有查询层。把筛选条件加入已有 Repository、Service 或 ORM 查询,不要在新路由中另开一套数据库连接方式。确保首页、列表接口、管理后台等相关入口都遵守发布状态和权限规则。
- 保持查询与分页一致。统计总数与取当前页必须使用相同筛选条件,否则页数和列表可能对不上。排序也要稳定;示例用名称和 ID 排序,避免名称相同的记录在分页时反复移动。
- 发布时考虑回滚。先部署兼容新旧字段的代码或完成数据回填,再切换读写逻辑。生产库变更前备份并验证恢复流程,不要把本地初始化函数当作生产迁移方案。
七、参数、安全与性能边界
搜索长度限制不是通用标准
示例将搜索词限制为 100 个 Unicode 字符,表单的 maxlength 仅改善浏览器端输入体验,服务端仍需独立校验。100 只是演示值,不是 Flask、SQLite 或 HTTP 协议规定的限制。实际值应结合产品需求、数据库类型和检索成本制定。Python 的 len 按 Unicode 码点计数,并不等同于用户看到的字素数量;若产品限制按字节数、字符簇或存储字段长度计量,需要另外实现。
页码上限不是完整的防护
示例限制页码不超过 100000,避免任意大的整数进入偏移计算,但大偏移量仍可能查询较慢。这个上限也是应用策略,不是数据库的硬性安全边界。数据量继续增长时,可考虑基于上一条记录排序键的游标分页;游标也要校验,并确保排序键稳定。对外 API 还应结合速率限制、超时和监控,不能仅凭页码上限宣称能抵御滥用。
LIKE 搜索的大小写和中文行为由数据库决定
SQLite 的 LIKE 对 ASCII 字符的大小写处理与对一般 Unicode 字符的处理并不完全相同;不同数据库、排序规则和扩展也可能有不同表现。示例不承诺跨语言的大小写折叠或中文分词。若需要拼音、分词、模糊纠错、同义词、权重排序或更复杂的语言处理,应使用适合的全文检索组件或搜索服务,并单独评估索引更新和数据同步。
索引不一定能加速包含式搜索
对 LIKE '%关键词%' 这种两端都有通配符的查询,普通 B-tree 索引通常难以直接加速。示例中的分类索引有助于分类筛选,但不代表关键词检索已具备良好扩展性。记录量小、搜索频率低时可以先测量;数据量扩大后,使用数据库全文索引或专门搜索系统。应以真实数据和执行计划判断,而不是只凭索引是否存在判断性能。
检查请求来源与接口权限
GET 搜索适合无副作用的公开查询,可分享链接;不要把它用于创建、修改或删除数据。若页面显示会员专属、未公开或按商户隔离的目的地,必须在服务器端验证访问权限,并把权限条件加入数据库查询。搜索框不是授权机制,隐藏分类选项也不是权限控制。
八、常见实现错误
- 用分类名称拼 SQL:名称可能重复、修改或包含特殊字符。用经过校验的 ID 或稳定的 slug 查找分类,再通过参数绑定参与查询。
- 只做浏览器端校验:请求可以绕过表单直接构造。服务端必须重新验证搜索词、分类和页码。
- 以为参数绑定会转义 HTML:SQL 参数与模板输出是不同安全上下文。SQL 使用绑定参数,HTML 使用模板自动转义,并避免不必要的安全标记。
- 计数和列表使用不同条件:这会导致页数错误,甚至让未发布数据影响分页。两条查询应共用相同条件与参数。
- 不处理空结果和越界页码:展示明确的无结果状态;越界时可返回 404,也可按产品规则重定向到最后一页,但行为应一致且经过测试。
- 把示例初始化逻辑用于生产:真实项目通常需要可追踪、可回滚的迁移,需处理并发部署、备份和已有数据,不应依赖“表为空才插入”的演示逻辑。
- 无限制返回所有记录:列表应分页或设置合理的结果上限。即使当前数据不多,增长后也可能造成响应变慢和内存占用上升。
结语
为旅游网站增加分类和搜索,核心不在界面控件,而在明确分类关系、让旧数据平滑迁移、在服务端校验请求,并保持筛选、计数和分页条件一致。本文的 Flask 与 SQLite 示例提供了一个能运行和验证的起点:SQL 使用绑定参数,LIKE 通配符按字面搜索,页面依赖模板转义,列表只返回已发布内容。接入现有项目时,应结合实际框架、数据库和访问控制调整,并通过测试与执行计划确认功能和性能符合业务边界。




