首页 / 资讯中心 / 文章详情

Flask+SQLite构建旅行信息聚合助手:从零到一实战

Flask+SQLite构建旅行信息聚合助手:从零到一实战 ★ FEATURED ARTICLE
打开手机翻找一周后的航班信息、酒店确认邮件、景点预约码时我常常要切换五六个 App 和邮件会话。机票在航旅软件里酒店在邮箱里餐厅预约在某些小程序里等到真正出门那天还要重新把这些信息捞出来再核对一遍。这种体验反复出现几次之后我决定自己动手做一个“旅行信息聚合助手”把分散的航班、酒店、门票、备忘录集中到一个页面里按日期排序一键搜索还能导出成一份纯文本汇总。本文会完整拆解这个项目的设计思路与实现过程从技术选型、数据结构设计到 Flask 后端、SQLite 存储、简单前端页面再到部署与排查方案。项目定位是“轻量、可自用、能跑在本地”适合正在学习 Python Web 开发的新手也适合想快速搭建个人信息管理工具的后端开发者。通过本文你会掌握一个基于 Flask SQLite 的完整 Web 应用是怎样从零搭建并落地的。1. 背景与核心概念1.1 我为什么需要这样一个小工具旅行资料的碎片化是一个普遍问题。一次短途旅行至少会涉及航班、酒店、当地交通、景点门票、餐厅预约和随身备忘。这些信息往往来自不同渠道格式也不统一有的是一封带附件的邮件有的是 App 里的行程卡片有的是截图有的甚至只是聊天记录里的一句话。把这些信息集中到一个地方听起来简单但实际操作并不轻松。通用笔记软件可以做记录但新增一条行程要手动填很多字段关键信息仍然要靠搜索才能找到电子表格可以结构化但录入成本高而且在地铁站、机场这种场景下打开编辑体验很差。我需要的是“低成本录入 按时间排序 快速检索 导出共享”四位一体的工具最好能在一分钟内完成一条信息的记录并且在手机上用浏览器就能访问。这个需求用成熟产品也能部分满足但真正动手写后我发现一个单用户本地工具并不需要复杂的架构。它本质上就是“一张带分类的多行表格 一个查询页面 一个文本导出接口”。当我把它抽象到这个程度时实现难度就大幅下降了。1.2 方案选型为什么是 Flask SQLite在技术选型上我优先考虑的是“学习成本低、运行时依赖少、迁移方便”。后端选择了 Flask。它足够轻量核心逻辑可以用一个文件装下路由写法直观模板也顺手。相比 Django它不会引入大量约定和模块很适合这种规模的项目。数据层选择了 SQLite。Python 标准库自带 sqlite3不需要单独安装数据库服务数据保存在一个本地 .db 文件里。旅行信息属于低频写入、高频查询的数据完全在 SQLite 的性能范围内。更重要的是SQLite 的查询能力比直接在 JSON 文件里遍历强得多后续要按日期、分类、关键词过滤时SQL 写起来非常自然。前端没有用现代前端框架而是 Flask 自带的 Jinja2 模板加上原生表单。对于一个只服务自己或少量设备访问的工具来说这种方案足够可靠也方便在无网络环境下运行。1.3 核心功能边界项目只聚焦下面几个能力新增一条旅行信息字段包含分类、标题、地点、日期、时间、备注。按日期和时间排序展示全部记录。支持关键词搜索匹配标题、地点、备注、分类。支持删除错误录入的信息。导出 Markdown 格式的旅行行程汇总。这个边界刻意保持得很小。把功能范围缩小意味着可以更快完成第一版并且能保证每个功能都是直接解决问题的而不是为了“大而全”增加选择负担。2. 环境准备与版本说明2.1 运行环境本文示例以 Python 3 环境为基础。建议使用 Python 3.8 及以上版本因为 Flask 2.x 对旧版本 Python 有版本要求越接近新版越省心。操作系统不限Windows、macOS、Linux 都可以运行命令上只有创建虚拟环境时的激活方式不同。Web 框架方面示例安装的是 Flask 的 2.x 系列也就是执行 pip install flask 时获取的当前稳定版本。如果你原本就装了 Flask只要版本不是过于陈旧本文示例代码基本都能跑通。SQLite 不需要单独安装。Python 内置的 sqlite3 模块已经足够验证是否可用可以执行python -c import sqlite3; print(sqlite3.sqlite_version)如果这行命令能正常输出版本号说明 SQLite 环境没有问题。2.2 版本差异提醒不同的 Python 版本之间最明显的差异不在代码逻辑而在虚拟环境命令和 pip 的行为。示例代码里用到的类型转换、f-string、sqlite3 参数化查询这些特性在 Python 3.8 及以上都稳定可用。如果你的项目最终要部署到云主机或公司服务器请以服务器上的 Python 版本为准。示例依赖本身非常少除了 Flask 之外没有其他第三方库所以版本冲突的概率很低。如果遇到问题优先检查 Flask 版本和 Python 版本是否匹配。2.3 准备虚拟环境建议为项目创建独立虚拟环境避免污染全局 Python 环境。我通常在项目根目录执行下面这几步mkdir trip-desk cd trip-desk python -m venv venv激活虚拟环境Windowscmd 或 PowerShellvenv\Scripts\activatemacOS / Linuxsource venv/bin/activate激活后命令行提示符前会出现(venv)说明已经进入虚拟环境。随后安装依赖pip install flask安装完成后可以写一行命令确认 Flask 版本pip show flask注意如果你的 Python 命令是python3请把上面命令中的python换成python3不同操作系统对 python 命令的映射不一样。3. 核心设计思路3.1 数据结构设计先把业务数据抽象出来。一条旅行信息我最终拆成下面几个字段字段名含义示例category分类如航班、酒店、门票flighttitle标题一句话描述这条信息北京→上海 MU5101location地点机场/酒店/场馆名首都机场T2trip_date日期主排序字段2025-05-01trip_time时间同一天内的排序字段08:30detail备注存放取票码、订单号值机柜台C区id 字段由数据库自增生成created_at 记录录入时间。这个表结构极其简单但能覆盖日常旅行记录中 90% 以上的场景。在设计时有几个考虑trip_date和trip_time使用 TEXT 类型存储不转成时间戳。好处是可读性强、SQL 查询时既能用 BETWEEN 范围筛选也能用字符串匹配坏处是排序时要求格式统一所以我在表单里强制使用input typedate和input typetime浏览器生成的格式一定是YYYY-MM-DD和HH:MM。detail 字段承担备注职责虽然没有单独做结构化字段但对这个项目来说一行文本已经够用。分类没有做成独立的关联表而是直接写字符串。分类数量少且固定没必要引入外键。3.2 后端路由设计Flask 应用里只需要四个路由GET /首页展示所有信息支持?q搜索参数。POST /add接受表单提交新增一条记录。POST /delete/int:trip_id删除指定记录。GET /export导出 Markdown 文件。这四个路由覆盖了“录入、查看、检索、删除、导出”全部操作。保持路由精简可以避免认知负担。3.3 为什么必须使用参数化 SQL这个项目虽然只是个人工具但写数据库操作时直接拼接字符串依然危险。如果用户输入的标题是; DROP TABLE trips; --拼接 SQL 会直接删除整张表这就是经典的 SQL 注入。正确的做法是使用参数化查询把值以?占位符的形式传给 execute 方法由 sqlite3 负责安全转义。这样做并不复杂却能从根本上防止注入问题。个人项目很容易忽略这一点但作为开发习惯应该从第一个项目开始就建立。4. 完整实战从零搭建旅行信息聚合助手下面开始实现。最终项目文件结构如下trip-desk/ ├── venv/ # 虚拟环境示意 ├── app.py # Flask 入口与路由 ├── models.py # SQLite 数据访问层 ├── templates/ │ └── index.html # 页面模板 ├── requirements.txt # 依赖清单 └── data/ └── trips.db # SQLite 数据文件运行后生成先创建项目目录mkdir -p trip-desk/templates trip-desk/data4.1 编写 models.py 数据访问层把数据库操作独立到一个文件中可以让 app.py 只关心请求处理职责更清晰。先写数据库连接和初始化函数# 文件路径trip-desk/models.py import sqlite3 import os DB_PATH os.path.join(os.path.dirname(__file__), data, trips.db) def get_connection(): # 确保 data 目录存在 os.makedirs(os.path.dirname(DB_PATH), exist_okTrue) conn sqlite3.connect(DB_PATH) # 让查询结果像字典一样按字段名访问 conn.row_factory sqlite3.Row return conn def init_db(): conn get_connection() conn.execute( CREATE TABLE IF NOT EXISTS trips ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL, title TEXT NOT NULL, location TEXT DEFAULT , trip_date TEXT NOT NULL, trip_time TEXT DEFAULT , detail TEXT DEFAULT , created_at TEXT DEFAULT CURRENT_TIMESTAMP ) ) conn.commit() conn.close()conn.row_factory sqlite3.Row这行代码容易被忽略但非常重要。设置之后查询出来的每一行可以直接用row[title]这样的方式取值方便后续在模板中渲染。然后是新增记录和查询函数# 文件路径trip-desk/models.py 追加内容 def add_trip(category, title, location, trip_date, trip_time, detail): conn get_connection() conn.execute( INSERT INTO trips (category, title, location, trip_date, trip_time, detail) VALUES (?, ?, ?, ?, ?, ?) , (category, title, location, trip_date, trip_time, detail), ) conn.commit() conn.close() def search_trips(keyword): conn get_connection() if keyword: like f%{keyword}% rows conn.execute( SELECT * FROM trips WHERE title LIKE ? OR location LIKE ? OR detail LIKE ? OR category LIKE ? ORDER BY trip_date, trip_time , (like, like, like, like), ).fetchall() else: rows conn.execute( SELECT * FROM trips ORDER BY trip_date, trip_time ).fetchall() conn.close() return [dict(row) for row in rows]search_trips接收一个关键词如果不为空就在标题、地点、备注、分类四个字段上做模糊匹配如果为空就返回全部记录并按日期排序。注意这里使用了?占位符避免 SQL 注入。删除和导出函数如下# 文件路径trip-desk/models.py 追加内容 def delete_trip(trip_id): conn get_connection() conn.execute(DELETE FROM trips WHERE id ?, (trip_id,)) conn.commit() conn.close() def export_markdown(): trips search_trips() lines [# 我的旅行信息汇总, ] current_date None for trip in trips: if trip[trip_date] ! current_date: current_date trip[trip_date] lines.append(f## {current_date}) lines.append() time_part trip[trip_time] or --:-- location_part trip[location] or detail_part trip[detail] or lines.append(f- [{trip[category]}] {trip[title]} {time_part} {location_part} {detail_part}.rstrip()) return \n.join(lines)导出函数把记录按日期分组生成一份带二级标题的 Markdown 文本。手写 Markdown 的好处是完全可控不会有模板引擎额外换行或转义带来的格式问题。4.2 编写 app.py Flask 入口进入核心的 Flask 文件# 文件路径trip-desk/app.py from flask import Flask, request, render_template, redirect, url_for, Response import models app Flask(__name__) app.route(/) def index(): keyword request.args.get(q, ).strip() trips models.search_trips(keyword) return render_template(index.html, tripstrips, searchkeyword) app.route(/add, methods[POST]) def add(): category request.form.get(category, ).strip() title request.form.get(title, ).strip() location request.form.get(location, ).strip() trip_date request.form.get(trip_date, ).strip() trip_time request.form.get(trip_time, ).strip() detail request.form.get(detail, ).strip() if title and trip_date: models.add_trip(category, title, location, trip_date, trip_time, detail) return redirect(url_for(index)) app.route(/delete/int:trip_id, methods[POST]) def delete(trip_id): models.delete_trip(trip_id) return redirect(url_for(index)) app.route(/export) def export(): content models.export_markdown() return Response( content, mimetypetext/markdown, headers{Content-Disposition: attachment; filenametrips.md}, ) if __name__ __main__: models.init_db() app.run(host127.0.0.1, port5000, debugTrue)逐段解释index路由从request.args中读取搜索关键词传给search_trips然后渲染模板。这里用.strip()去掉首尾空格避免用户搜索时不小心多打一个空格导致查不到结果。add路由从request.form中获取表单字段插入数据库后重定向回首页。title和trip_date是必填项所以做了一次简单校验。delete路由接收 URL 中的 ID删除对应记录。注意它只接受 POST 请求这是工程上的优点删除属于修改操作不应该通过 GET 链接触发否则容易被爬虫或预加载无意访问。export路由把 Markdown 文本封装成Response对象通过Content-Disposition头让浏览器下载。models.init_db()在应用启动时执行保证数据表存在。4.3 编写 templates/index.html 页面模板负责渲染首页、搜索框、添加表单和数据表格!-- 文件路径trip-desk/templates/index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleTripDesk 旅行信息聚合助手/title style body { font-family: Microsoft YaHei, sans-serif; max-width: 960px; margin: 24px auto; padding: 0 16px; color: #333; } h1 { font-size: 24px; } form { margin-bottom: 16px; } input, select, button { padding: 6px 10px; margin: 4px 4px 4px 0; font-size: 14px; } table { width: 100%; border-collapse: collapse; margin-top: 12px; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 14px; } th { background: #f5f5f5; } .search-form { background: #f9f9f9; padding: 12px; border-radius: 6px; } .empty { color: #888; margin: 24px 0; } a { color: #2a7ae2; text-decoration: none; } /style /head body h1TripDesk 旅行信息聚合助手/h1 form classsearch-form action/ methodget input typetext nameq value{{ search }} placeholder搜索航班、酒店、地点、备注 button typesubmit搜索/button a href/清空/a /form form action/add methodpost select namecategory option valueflight航班/option option valuehotel酒店/option option valueticket门票/option option valuememo备忘录/option option valueother其他/option /select input typetext nametitle placeholder标题必填 required input typetext namelocation placeholder地点 input typedate nametrip_date required input typetime nametrip_time input typetext namedetail placeholder订单号 / 备注 button typesubmit添加/button /form h2旅行信息列表/h2 {% if trips %} table thead tr th日期/th th时间/th th分类/th th标题/th th地点/th th备注/th th操作/th /tr /thead tbody {% for trip in trips %} tr td{{ trip.trip_date }}/td td{{ trip.trip_time or -- }}/td td{{ trip.category }}/td td{{ trip.title }}/td td{{ trip.location }}/td td{{ trip.detail }}/td td form action/delete/{{ trip.id }} methodpost button typesubmit删除/button /form /td /tr {% endfor %} /tbody /table {% else %} p classempty暂无数据添加一条旅行信息试试。/p {% endif %} pa href/export下载 Markdown 汇总/a/p /body /html模板利用 Jinja2 的{{ }}语法输出变量。{% if trips %}判断是否有数据避免空表格出现。显示时间时用了{{ trip.trip_time or -- }}如果时间为空就显示占位符。之所以把样式内联写在style标签里是为了让初学者可以只靠两个文件把项目跑起来。如果需要长期维护可以把 CSS 单独拆到 static 目录但在这个阶段内联更便于理解。4.4 添加依赖清单在项目根目录创建 requirements.txt# 文件路径trip-desk/requirements.txt flask2.2这样其他人拉取项目后可以直接执行pip install -r requirements.txt4.5 运行与验证启动应用python app.py正常启动后控制台会显示类似下面的信息* Running on http://127.0.0.1:5000在浏览器打开http://127.0.0.1:5000可以看到页面。现在做一组验证在表单选择分类“航班”标题填“北京→上海 MU5101”地点填“首都机场T2”日期选择今天时间填 08:30备注填“值机柜台C区”点击添加。列表中出现刚添加的记录日期、时间、分类、标题、地点、备注各列都正确显示。在搜索框输入“上海”列表只显示包含“上海”的记录。点击该记录后面的“删除”按钮记录消失。再次添加两条带不同日期的信息然后点击“下载 Markdown 汇总”浏览器下载trips.md文件。打开文件能看到按日期分组的文本。运行过程中如果遇到端口被占用可以把app.run的 port 参数换成其他端口比如5001。5. 实际使用场景演示5.1 一次短途旅行需要记录哪些信息假设下个月初要从北京到上海出差三天。传统做法是机票信息在航旅 App 中酒店订单在邮箱里高铁票在 12306 里行程安排散落在微信聊天记录里。使用这个工具我可以按顺序录入航班北京→上海 MU51015 月 10 日 08:30首都机场T2。酒店上海浦东丽呈酒店5 月 10 日入住地点在上海浦东新区。门票外滩观光隧道5 月 11 日 19:00需要取票码。备忘录晚上约老同学吃饭地点南京东路。航班上海→北京 MU51025 月 12 日 18:00虹桥机场T2。录入完成后首页默认按照日期排序。5 月 10 日的航班和酒店排在最前面5 月 11 日的门票次之返回航班排在最后。出门前看一眼列表所有信息都在同一页面不用再切换任何 App。5.2 搜索能力的价值当行程记录积累到几十条后“按日期浏览”这种方式的效率会下降这时候搜索框的作用就体现出来了。比如在机场当天想快速确认“取票码”直接输入“取票码”或酒店名字即可。因为搜索覆盖了标题、地点、备注、分类四个字段不管当时记录时把关键词放到了哪个字段都能被匹配到。这也是选择 SQLite 而不是纯 JSON 文件的核心原因。用 JSON 文件做这个功能需要自行遍历和匹配代码量更大边界情况更多用 SQL 一行 LIKE 查询就解决了。6. 常见问题与排查思路6.1 模板渲染报错jinja2.exceptions.UndefinedError问题现象访问首页时页面报错提示某个变量未定义。常见原因模板中使用了trip.detail或trip.location等字段但后端search_trips返回的数据里没有这些字段。这种情况通常是因为数据库表结构与模板不一致或者trips.db是旧版本创建的。解决思路先删除data目录下的trips.db文件重启应用让init_db()重新建表然后重新添加数据。如果项目以后要改字段结构可以再写一个简单的迁移脚本而不是直接删库。6.2 启动报错Address already in use问题现象执行python app.py后报错提示端口 5000 已被占用。常见原因之前有一个 Flask 进程没有退出或者系统上其他程序占用了 5000 端口。解决思路在命令行执行lsof -i :5000macOS / Linux或netstat -ano | findstr :5000Windows找到占用端口的 PID 后结束进程。更简单的方案是直接换端口运行。6.3 搜索中文查不到结果问题现象在搜索框输入中文列表始终显示为空。常见原因最常见的情况是终端编码问题导致输入的中文变成了乱码或者数据库中的数据本身因为编码问题存成了乱码。另一种可能是搜索字段太严格用户想搜“酒店”但记录里写的是“住宿”自然匹配不到。解决思路先确认浏览器页面能正常显示中文排除数据乱码问题。搜索本质上是精确的模糊匹配它等服务的是“记得原文关键词”的场景而不是语义搜索。如果确实需要联想式搜索可以后续扩展拼音首字母匹配或分词索引。对于当前版本建议录入时统一关键词风格比如酒店分类统一写“酒店”。6.4 删除不掉记录问题现象点击删除按钮后记录仍然存在。常见原因delete路由设置了methods[POST]如果直接访问/delete/3这样的链接会返回 405 Method Not Allowed。只有通过表单 POST 提交才能删除浏览器地址栏直接输入链接属于 GET 请求自然不行。解决思路确认页面上点击的是表单里的删除按钮。这个设计是刻意的不是在制造问题而是避免误删。6.5 导出文件的中文乱码问题现象下载的 Markdown 文件用文本编辑器打开时中文乱码。常见原因不同文本编辑器对无 BOM 文件的 UTF-8 编码识别策略不一致。比如 Windows 自带的记事本在某些环境下可能默认按 ANSI 解析。解决思路使用 VS Code、Sublime Text 等现代编辑器打开它们默认按 UTF-8 读取。如果一定要兼容旧版记事本可以在Response中加 UTF-8 BOM但这样文件在其他编辑器中又会显示特殊字符。个人建议保持纯 UTF-8 无 BOM。7. 最佳实践与工程建议7.1 表单校验不能只依赖前端模板里给标题和日期加了required属性这是浏览器层面的校验。但浏览器校验非常容易绕过直接构造 HTTP 请求就能提交空数据。后端add路由里同样用if title and trip_date做了校验这样才能保证写入数据库的数据是符合预期的。在实际项目中建议把校验抽象成一个独立函数集中处理所有字段的长度、格式、必填属性。比如日期格式可以先用正则验证是否是YYYY-MM-DD标题长度限制在 100 字符以内。7.2 删除操作要格外谨慎删除是危险操作尤其当数据量增长后误删将无法恢复。这里有两个改进方向使用软删除也就是为表增加一个is_deleted字段。删除时做 UPDATE 而不是 DELETE查询时过滤掉已删除记录。如果做硬删除可以增加一个“最近删除”页面里面保留回收站数据并提供恢复功能。对于当前项目来说删除按钮已经能解决误录问题。但如果要真正长期存放个人数据软删除更稳妥。7.3 数据库备份策略SQLite 的备份非常简单。由于数据全部存在一个trips.db文件中只需要把这个文件复制一份即可完成备份。我常用的命令cp data/trips.db data/trips_backup_$(date %Y%m%d).db如果系统上有sqlite3命令行工具也可以使用在线备份方式sqlite3 data/trips.db .backup data/trips_backup.db建议养成一个习惯每次调整表结构前先备份每次旅行结束后备份一次定期清理过期备份文件。7.4 日志与监控目前项目没有加日志开发模式下 Flask 会把请求打印在终端。如果后续部署到远程服务器建议至少做到两点用logger记录关键操作比如新增、删除、导出。不要把完整 SQL 语句打印到日志中避免个人信息泄露。旅行信息涉及订单号、中转地这些都是敏感信息。Flask 内置了标准库 logging可以在 app.py 中配置一个按天滚动的文件日志。对于个人项目简单记录操作时间、操作类型、结果状态即可。7.5 部署外网访问时的安全提醒本地运行的127.0.0.1只有本机可以访问。如果想让手机在局域网内访问可以把host改成0.0.0.0并通过app.run(host0.0.0.0, port5000)启动。但要注意0.0.0.0意味着局域网内所有设备都可以访问这个页面包括新增数据和删除数据的操作。如果只是自己用建议保持绑定127.0.0.1需要手机访问时再临时修改并尽快关闭。如果要暴露到公网至少需要做认证。最简单的做法是增加一个访问密码或者使用反向代理添加 Basic Auth。在没有任何认证机制之前不要直接暴露公网。8. 总结与学习路线这个项目从“旅行信息分散”这个真实痛点出发用 Flask SQLite 构建了一个轻量可用的本地工具。核心并不复杂就是一个数据表加四个路由但它把录入、排序、检索、导出、删除这几个动作串成了一条完整链路。通过这个案例你至少可以掌握以下几件事如何设计一张满足业务需求的简单数据表如何使用 Flask 处理 GET 和 POST 请求如何用 sqlite3 执行参数化查询如何用 Jinja2 模板渲染数据列表如何导出文本文件以及如何排查开发过程中最常见的几个问题。如果继续扩展可以按这个顺序进阶增加 CSRF 防护和简单的登录口令让工具可以安全地跑在局域网。增加自动提醒功能旅行当天在首页置顶展示当天所有行程。增加日历视图按月份展示行程分布。增加批量导入能力通过粘贴文本快速录入多条信息。把导出格式从 Markdown 扩展为 PDF 或 ics 文件方便同步到日历软件。这个项目的代码量不大结构也很容易理解适合作为 Python Web 开发的第一个独立作品。建议你不要直接复制粘贴完就结束而是亲手跑通之后再试着改一个小功能比如把“日期”改成“行程日期区间”或者增加一个“城市”字段。改动的过程才是真正理解的过程。如果你也被碎片化信息困扰不妨从这样一个极简版本开始缺什么功能就加什么功能它最终会成为真正适合你自己使用习惯的工具。
阅读完成 · 觉得有帮助?
咨询建站