基于旅游网站源码添加目的地分类与搜索功能

百科问答7小时前发布 jdkjadmin
0 00
基于旅游网站源码添加目的地分类与搜索功能

基于旅游网站源码添加目的地分类与搜索功能

旅游网站的目的地列表往往最初只有一组固定内容。随着景点、城市或线路增加,访客需要按类别浏览,也需要通过关键词快速查找。给已有源码添加这两种能力,表面上是增加几个下拉框和输入框,实际还涉及数据结构、旧数据迁移、查询边界、分页以及输出安全。

本文用 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> 内容的目的地名称。最后一种情况应当作为普通文本显示,而不是执行脚本。

六、在现有源码中落地

如果已有目的地表,先检查字段和数据,再设计迁移,不要直接照着示例重建数据库。比较稳妥的改造顺序如下:

  1. 盘点字段与数据来源。确认目的地是否已有分类文字、是否允许空描述、有没有下架或软删除标记,以及已有数据的编码和重复情况。
  2. 确定分类关系。一个目的地只属于一个分类时,可以增加外键;若一个地点能属于多类,则创建关联表,例如 destination_categories(destination_id, category_id),并以复合主键或唯一约束防止重复关联。
  3. 先迁移、后切换代码。为旧记录分配有效分类,再添加非空约束。不同数据库对“增加非空列并设置默认值”的迁移行为不完全相同,应使用项目支持的迁移工具,并先在备份或测试库演练。
  4. 复用现有查询层。把筛选条件加入已有 Repository、Service 或 ORM 查询,不要在新路由中另开一套数据库连接方式。确保首页、列表接口、管理后台等相关入口都遵守发布状态和权限规则。
  5. 保持查询与分页一致。统计总数与取当前页必须使用相同筛选条件,否则页数和列表可能对不上。排序也要稳定;示例用名称和 ID 排序,避免名称相同的记录在分页时反复移动。
  6. 发布时考虑回滚。先部署兼容新旧字段的代码或完成数据回填,再切换读写逻辑。生产库变更前备份并验证恢复流程,不要把本地初始化函数当作生产迁移方案。

七、参数、安全与性能边界

搜索长度限制不是通用标准

示例将搜索词限制为 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 通配符按字面搜索,页面依赖模板转义,列表只返回已发布内容。接入现有项目时,应结合实际框架、数据库和访问控制调整,并通过测试与执行计划确认功能和性能符合业务边界。

© 版权声明

相关文章