ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从零搭建公益服务地图系统:FastAPI+Leaflet实现LBS点位查询与附近搜索

从零搭建公益服务地图系统:FastAPI+Leaflet实现LBS点位查询与附近搜索 最近关于韩国年轻人的一条热搜引发了不小讨论人均 GDP 已超过 3 万美元的韩国却有大量年轻人挤进一款被民间称为“乞丐地图”的应用去寻找免费餐食、救助站、平价超市等生活服务点位。先不谈这个称呼是否犀利也不去深究社会层面的成因单从产品和技术视角看这类地图应用的设计思路其实非常典型把分散在各地的公共服务信息汇集成一张可交互的 LBS 地图。这类应用的正式名称通常是“福利地图”“生活支援地图”或“公益服务地图”。它要解决的核心痛点是信息不对称——真正有需要的人不知道该去哪里求助、哪里有免费餐、哪个救助站还开门而提供服务的机构又缺乏高效触达用户的渠道。把两者在地图上连接起来就是这类产品存在的价值。本文就围绕这个场景从零搭建一个可运行的公益服务地图系统。技术栈采用 Python FastAPI SQLite Leaflet后端负责服务点数据存储和查询接口前端基于 OpenStreetMap 瓦片渲染点位并支持分类筛选和附近搜索。整套代码量不大但覆盖了一个 LBS 项目的完整链路。无论是入门地理信息开发还是想为社区做一套便民地图都可以把它作为起点。1. 现象背景为什么“乞丐地图”会被年轻用户挤爆1.1 “乞丐地图”到底是怎样一款产品“乞丐地图”并不是一个官方名称而是韩国网络上对一类福利信息地图应用的民间称呼。这类应用的核心功能大同小异把免费供餐点、临时救助站、平价超市、免费热水、应急充电、求职帮扶、廉价住房等生活服务信息按照位置标注在地图上用户打开后可以像查餐厅一样按距离、按分类查找资源。它之所以被年轻用户关注表面原因是“有用”在生活成本高企的环境下谁能快速找到一顿免费饭、一个可以临时过夜的地方直接关系到生活压力。再加上这种应用通常基于手机地图操作把原本需要靠熟人打听、挨家问询才能获取的信息变成了“打开即见”的数据信息获取门槛被大幅降低。因此即便宏观数据并不差仍有相当数量的年轻人选择使用这类工具本质上是一种对现实压力的务实响应。1.2 这类产品的核心价值从产品角度拆解“乞丐地图”类应用的价值可以概括为三层。第一层是对用户的价值降低信息不对称。过去免费餐食地点往往靠口口相传新人很难知道哪里有资源地图应用把分散的信息结构化、可视化用户省去了大量搜索成本。第二层是对服务机构的价值提高公益资源利用率。很多供给端机构并非不想服务更多人而是缺少对外宣传渠道。地图应用相当于一个统一发布平台让公益资源能被更精准地触达到有需要的人。第三层是对技术开发者的价值它是一个非常完整的 LBSLocation Based Service基于位置的服务学习案例。你会接触到地理坐标模型、距离计算、地图渲染、前后端联调、数据更新机制等一系列真实工程问题这些能力在智慧城市、物流配送、门店选址等方向都能复用。1.3 从现象到工程本文要做什么我们不打算把文章写成社会观察报告而是把“公益地图”当成一个技术项目来落地。接下来会实现这些功能服务点数据的存储与基础管理按服务分类筛选点位根据用户当前位置查询附近点位并按距离排序在地图上渲染点位点击查看详情提供一个可演示、可二次开发的最小完整系统。这样一套系统既可以用于社区便民地图也可以改造成灾害救援物资点地图、老年食堂地图、公共厕所地图、AED 急救设备地图等通用性很强。2. 系统需求分析与技术选型2.1 功能需求拆解先列出最小可行版本的功能清单避免一开始就做得过重。核心功能服务点查询返回全部点位或在某个分类范围内返回点位附近搜索传入用户经纬度返回指定半径内的点位并按距离排序点位新增支持通过 API 提交新服务点地图展示前端以标记点形式展示点击弹窗显示地址、营业时间、备注等。非核心但建议预留的功能数据审核状态区分“已核实”和“未核实”点位联系方式与营业时间字段方便用户提前确认批量导入管理员可以用脚本一次性导入初始数据。2.2 技术栈选择后端选择 Python FastAPI理由很简单代码量少、自带接口文档、天然支持异步、对新手友好。数据库使用 SQLite零配置单文件存储非常适合演示和小规模部署。等数据量大了再迁移到 PostgreSQL/PostGIS 也不难。前端选择 Leaflet。它是一款轻量级开源地图 JavaScript 库不依赖商业地图厂商 SDK使用 OpenStreetMap 瓦片即可展示地图上手成本低。相比传需要申请开发者密钥的地图服务Leaflet 这套组合特别适合学习阶段使用。整体技术栈层次技术选型说明后端Python 3.9 / FastAPI提供 REST API数据库SQLite单文件存储服务点数据前端HTML / JavaScript / Leaflet地图展示与交互地图瓦片OpenStreetMap开源地图数据2.3 系统整体架构系统采用经典的前后端分离结构交互流程如下浏览器Leaflet 地图页面 │ HTTP/JSON ▼ FastAPI 服务/api/points、/api/points/nearby │ SQL ▼ SQLite 数据库service_points 表前端页面从后端接口拉取服务点数据通过 Leaflet 把经纬度渲染成地图标记用户切换分类时前端重新请求带参接口附近搜索则由后端完成经纬度距离计算并返回排序结果。整个链路很清晰也便于分别调试。3. 环境准备与项目初始化3.1 环境依赖本文示例以 Python 3.9 以上版本为例需要提前安装好 Python 和 pip。版本需要根据你的项目实际情况调整重点演示配置思路。后端依赖只需要两个fastapi uvicorn安装命令pip install fastapi uvicorn前端不需要额外安装依赖Leaflet 通过 CDN 引入即可因此只需要一个现代浏览器。3.2 创建项目结构建议按下面结构组织代码逻辑清晰后续扩展也方便welfare-map/ ├── backend/ │ ├── main.py # FastAPI 应用入口 │ ├── database.py # SQLite 连接与建表 │ ├── requirements.txt # Python 依赖 │ └── data/ │ └── points.json # 初始演示数据 ├── frontend/ │ └── index.html # 地图页面 └── scripts/ └── import_points.py # 批量导入脚本先在本地创建目录mkdir -p welfare-map/backend/data mkdir -p welfare-map/frontend mkdir -p welfare-map/scripts3.3 初始化数据库数据库模块负责创建连接和初始化表。这里把数据库连接单独抽到一个文件里方便其他模块复用。# 文件路径backend/database.py import sqlite3 DB_PATH welfare_map.db def get_conn(): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row return conn def init_db(): conn get_conn() conn.execute( CREATE TABLE IF NOT EXISTS service_points ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT NOT NULL, address TEXT NOT NULL, latitude REAL NOT NULL, longitude REAL NOT NULL, description TEXT DEFAULT , business_hours TEXT DEFAULT , contact TEXT DEFAULT , verified INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ) conn.commit() conn.close()这里有几个字段值得说明latitude和longitude是地理坐标核心字段后续所有距离计算都依赖它们category用于分类筛选建议统一维护一套分类枚举比如“免费餐食”“救助站”“平价超市”避免录入时出现同名不同字的情况verified是一个审核标记0 表示未核实1 表示已核实。公益信息出错会产生很坏的影响所以审核字段一定要预留。4. 后端 API 开发4.1 服务点查询接口后端主文件是 FastAPI 入口负责注册接口和启动应用。先实现最基础的全量查询和按分类查询。# 文件路径backend/main.py from typing import Optional from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from database import init_db, get_conn app FastAPI(title公益服务地图 API) # 允许前端跨域访问 app.add_middleware( CORSMiddleware, allow_origins[*], allow_methods[*], allow_headers[*], ) init_db() class PointCreate(BaseModel): name: str category: str address: str latitude: float longitude: float description: str business_hours: str contact: str app.get(/api/points) def list_points(category: Optional[str] None): 服务点列表支持按分类筛选 conn get_conn() if category: rows conn.execute( SELECT * FROM service_points WHERE category ?, (category,), ).fetchall() else: rows conn.execute(SELECT * FROM service_points).fetchall() conn.close() return [dict(row) for row in rows] app.post(/api/points) def create_point(point: PointCreate): 新增服务点 conn get_conn() cursor conn.execute( INSERT INTO service_points (name, category, address, latitude, longitude, description, business_hours, contact) VALUES (?, ?, ?, ?, ?, ?, ?, ?) , ( point.name, point.category, point.address, point.latitude, point.longitude, point.description, point.business_hours, point.contact, ), ) conn.commit() row conn.execute( SELECT * FROM service_points WHERE id ?, (cursor.lastrowid,), ).fetchone() conn.close() return dict(row)list_points接口的category参数是可选字符串传了就按分类过滤不传就返回全部。这样前端可以在“全部”和“具体分类”两种模式下复用同一个接口。4.2 附近点位搜索Haversine 距离计算附近搜索是 LBS 应用的重头戏。用户手机定位能拿到经纬度接下来要找出 3 公里内有哪些服务点。地球是一个近似球体在地球表面计算两点距离不能直接用平面直角坐标公式需要使用球面距离公式。常用的方案是 Haversine 公式误差一般在可接受范围内足够用于“附近点位”这类场景。# 文件路径backend/main.py追加在现有代码之后 import math EARTH_RADIUS 6371000 # 地球平均半径单位米 def haversine_distance(lat1, lng1, lat2, lng2): 计算两个经纬度点之间的球面距离单位米 phi1 math.radians(lat1) phi2 math.radians(lat2) delta_phi math.radians(lat2 - lat1) delta_lambda math.radians(lng2 - lng1) a ( math.sin(delta_phi / 2) ** 2 math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2 ) c 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) return EARTH_RADIUS * c app.get(/api/points/nearby) def nearby_points(lat: float, lng: float, radius: int 3000): 查询指定经纬度附近的所有服务点按距离升序排列 conn get_conn() rows conn.execute(SELECT * FROM service_points).fetchall() conn.close() results [] for row in rows: distance haversine_distance(lat, lng, row[latitude], row[longitude]) if distance radius: item dict(row) item[distance] round(distance, 1) results.append(item) results.sort(keylambda x: x[distance]) return results关于这个接口有三点补充radius默认是 3000 米前端可以按需调整返回结果里增加了distance字段前端可以直接展示“距离 850 米”这种遍历计算的方式在小数据量下完全够用但点位达到几万条以后建议改用空间索引或 PostGIS 的ST_DistanceSphere函数。4.3 启动服务并验证接口在backend目录下启动服务cd backend uvicorn main:app --reload --host 0.0.0.0 --port 8000启动成功后浏览器访问http://localhost:8000/docs就能看到 FastAPI 自动生成的接口文档这也是选择 FastAPI 的一个额外好处接口调试界面是自带送的。用 curl 验证一下全量查询curl http://localhost:8000/api/points假设还没有数据会返回空列表[]之后我们通过导入脚本写入演示数据再回来刷新就能看到结果。5. 前端地图页面开发5.1 引入 Leaflet 并初始化地图前端的核心是 Leaflet 地图渲染。我们在 HTML 页面中通过 CDN 引入 Leaflet 的 CSS 和 JS然后初始化一张以首尔为中心的地图。这里之所以默认中心点选在首尔是为了贴合文章开头的“韩国年轻人使用福利地图”这一场景实际项目中你可以根据服务覆盖区域自由调整。!DOCTYPE html !-- 文件路径frontend/index.html -- html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title公益服务地图/title link relstylesheet hrefhttps://unpkg.com/leaflet1.9.4/dist/leaflet.css / script srchttps://unpkg.com/leaflet1.9.4/dist/leaflet.js/script style html, body { margin: 0; padding: 0; height: 100%; font-family: Microsoft YaHei, sans-serif; } #map { height: calc(100% - 50px); } .toolbar { height: 50px; display: flex; align-items: center; gap: 10px; padding: 0 16px; background: #f5f5f5; } .toolbar select { padding: 6px 12px; border-radius: 4px; border: 1px solid #ccc; } .toolbar button { padding: 6px 14px; cursor: pointer; } /style /head body div classtoolbar label服务分类/label select idcategoryFilter option value全部/option option value免费餐食免费餐食/option option value救助站救助站/option option value平价超市平价超市/option option value免费热水免费热水/option option value应急充电应急充电/option /select button onclickloadPoints()刷新/button /div div idmap/div script const API_BASE http://localhost:8000; let map null; let layerGroup null; function initMap() { // 初始化地图默认中心为首尔市政厅附近 map L.map(map).setView([37.5665, 126.9780], 12); layerGroup L.layerGroup().addTo(map); L.tileLayer(https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png, { maxZoom: 19, attribution: copy; a hrefhttps://www.openstreetmap.org/copyrightOpenStreetMap/a contributors }).addTo(map); } /script /body /html5.2 点位渲染与弹窗展示绘图点位之前先用layerGroup统一管理所有标记点。这样每次刷新数据时只需要清空layerGroup再重新添加不需要反复操作地图实例避免标记点堆积。async function loadPoints() { const category document.getElementById(categoryFilter).value; const url category ? ${API_BASE}/api/points?category${encodeURIComponent(category)} : ${API_BASE}/api/points; const res await fetch(url); const points await res.json(); renderPoints(points); } function renderPoints(points) { layerGroup.clearLayers(); points.forEach(p { const popupContent div stylemin-width:180px; div stylefont-weight:bold;${p.name}/div div分类${p.category}/div div地址${p.address}/div div营业${p.business_hours || 待确认}/div ${p.description ? div备注${p.description}/div : } /div; L.marker([p.latitude, p.longitude]) .addTo(layerGroup) .bindPopup(popupContent); }); }这里有一个容易被忽略的细节用户点击标记后看到的弹窗内容是通过模板字符串动态拼接的。如果后端返回的数据里包含用户可控内容必须注意内容转义问题防止 XSS 注入。HTML 模板拼接前先做一下特殊字符的转义会更稳妥生产环境尤其要注意。5.3 启动前端并完成联调前端页面是一个静态 HTML直接双击用file://协议打开可能会遇到跨域或日志问题更推荐使用本地静态服务器。在项目根目录执行cd welfare-map/frontend python -m http.server 5500然后浏览器访问http://localhost:5500同时保持后端 8000 端口处于启动状态。打开浏览器控制台如果没有报错地图应该能正常加载并显示点位。如果在国内网络环境访问 OpenStreetMap 瓦片较慢可以替换为其他公共瓦片源或者在内网部署自建瓦片服务。地图底座和生产联调是两个阶段的问题学习阶段不需要过早纠结。6. 数据来源、导入与更新机制6.1 演示数据准备公益地图的数据是产品的生命线。我们先准备一批演示数据用于验证前后端功能。这里需要特别说明以下地名和坐标仅用于学习演示不是经过核实的真实服务点请勿用作真实导航。// 文件路径backend/data/points.json [ { name: 市厅站暖心食堂示例, category: 免费餐食, address: 首尔特别市中区示例路 1 号, latitude: 37.5547, longitude: 126.9707, business_hours: 11:00-13:00, description: 提供免费午餐每日限量 50 份 }, { name: 光化门社区互助站示例, category: 救助站, address: 首尔特别市钟路区示例路 2 号, latitude: 37.5728, longitude: 126.9769, business_hours: 09:00-18:00, description: 提供临时休息、热水和就业咨询 }, { name: 大学路暖心超市示例, category: 平价超市, address: 首尔特别市钟路区示例路 3 号, latitude: 37.5808, longitude: 127.0025, business_hours: 10:00-20:00, description: 出售低于市场价的临期日用品 } ]6.2 批量导入脚本手写 SQL 插入数据太繁琐可以写一个简单的 Python 脚本把 JSON 文件一次性导入数据库。脚本的核心逻辑很直接读取 JSON逐条插入service_points表最后打印导入条数。# 文件路径scripts/import_points.py import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / backend)) from database import get_conn def import_points(json_path: str): conn get_conn() with open(json_path, r, encodingutf-8) as f: points json.load(f) for p in points: conn.execute( INSERT INTO service_points (name, category, address, latitude, longitude, description, business_hours, contact) VALUES (?, ?, ?, ?, ?, ?, ?, ?) , ( p.get(name), p.get(category), p.get(address), p.get(latitude), p.get(longitude), p.get(description, ), p.get(business_hours, ), p.get(contact, ), ), ) conn.commit() conn.close() print(f导入完成共 {len(points)} 条服务点数据) if __name__ __main__: import_points(backend/data/points.json)执行导入cd welfare-map python scripts/import_points.py再次访问http://localhost:8000/api/points就能看到三条演示数据了。回到前端页面点击“刷新”地图上应该出现三个标记点点击标记可以查看详情。6.3 数据合规与更新机制数据来源是公益地图最容易踩坑的地方。需要明确几点优先采用人工核实、机构合作、官方发布渠道获取数据如果使用网络爬虫采集必须遵守目标网站的robots.txt和服务条款不能绕过访问控制不能对目标服务器造成压力涉及个人信息的点位例如某个临时休息点要谨慎脱敏避免泄露敏感信息公益信息的准确性和时效性非常重要建议所有点位都经过审核后上线并定期回访确认状态。在运营层面可以设计一个“用户标注过期信息”的功能让用户反馈某点位已关闭或迁址运营人员核对后更新状态。这类小功能对数据质量的长期维护非常关键。7. 常见问题与排查思路前后端联调阶段最容易出现下面这些问题。这里整理成一张排查表方便按图索骥问题现象常见原因解决思路地图区域空白瓦片不显示当前网络环境无法访问默认瓦片源替换为可访问的瓦片服务地址或部署内网瓦片服务前端请求接口报 CORS 错误后端未配置跨域在 FastAPI 中添加 CORSMiddleware并检查 allow_origins页面能打开但点位不渲染后端未启动或 API_BASE 配置错误确认 8000 端口服务启动检查接口返回是否为空中文显示乱码文件编码或响应头缺失 utf-8JSON 文件保存为 UTF-8 编码确保接口返回 JSON 格式附近搜索结果为空半径设置太小或演示数据距离太远调大 radius或用距离较近的测试点验证插入数据后列表重复重复执行导入脚本导入脚本增加幂等逻辑先清空再导入或做数据去重数据库表不存在未调用 init_db()确认导入依赖后执行了数据库初始化遇到问题建议按这个顺序排查先看后端接口是否正常返回再看前端控制台是否有报错最后检查数据内容本身。前后端分离项目里第一步永远是确认接口这一层是通的否则问题会全堆在前端。8. 最佳实践与工程建议8.1 接口性能优化当前附近搜索接口是全表遍历点位少的时候没问题但数据量上来后性能会明显下降。优化方向有三个对category字段建立索引加速筛选查询对经纬度做范围预过滤比如先限定在经纬度 ±0.05 度的矩形区域内再精确计算距离生产环境切换到 PostgreSQL 并启用 PostGIS 扩展用ST_DistanceSphere和 GiST 空间索引替代手写计算。8.2 数据安全与操作规范公益地图面向弱势群体数据安全和最小必要原则更重要。不采集、不展示与地图功能无关的个人隐私字段所有新增数据必须经过审核特别是涉及地址和营业时间的信息数据库操作遵循最小权限原则普通业务接口只应该拥有查询权限不应把管理员写入接口暴露到线上公网定期备份数据库防止误删或数据损坏。删除或更新数据时先确认条件再在测试环境验证如果系统需要用户登录和权限管理务必使用合理的密码加密方案禁止明文存储密码。8.3 前端渲染细节当点位数量超过数百个时逐个添加L.marker会导致页面卡顿。可以引入 Leaflet 插件做聚合展示例如Leaflet.markercluster把近距离的多个点位合并成一个气泡数字放大地图后再拆分。这样既保留了地图的直观性又保证了渲染性能。另外地图体验上的一个小建议附近列表里显示距离时建议单独渲染一个侧边栏列表用户点击列表项后地图自动定位到对应点位这比纯靠地图标记点交互效率高很多。8.4 从演示到生产的扩展方向这套代码是一个学习骨架要真正上线还有不少工作。后端从 FastAPI 到生产部署建议补充用户认证与权限控制、日志审计、监控告警、Docker 容器化、Nginx 反向代理和 HTTPS。前端可以扩展为小程序或移动端 H5毕竟“打开地图找附近服务”是一个天然移动场景。功能上也有很多可延伸的切入点给点位增加“已核实”图标、提供路线规划、展示服务点当前排队人数、接入志愿者报名和物资捐赠入口。这些方向和 LBS 技术并不冲突反而是同一套数据底座可以反复复用的增量价值。9. 总结与扩展方向本文从韩国“乞丐地图”这一现象切入完成了一个公益服务地图系统的完整搭建。回顾一下我们做了这些事设计了服务点数据模型基于 FastAPI 实现了点位查询、分类筛选、附近搜索接口用 Haversine 公式解决了球面距离计算问题前端基于 Leaflet 完成了地图初始化和点位渲染最后补充了数据导入、合规审核和常见问题排查。下一步可以继续深入的方向包括一是把附近搜索改造成基于空间索引的高性能实现学习 PostGIS 的使用二是给前端接入聚合标记和侧边栏列表提升点位密集场景的交互体验三是结合具体业务场景重新设计分类体系例如把某个城市实际的老年食堂、AED 设备、避灾场所数据接入进来让项目真正产生使用价值。这一类“把线下信息做成地图”的产品的门槛并不高真正的难点在于数据是否准确、更新是否及时、体验是否友好。技术本身是透明的是否能服务到真实用户取决于产品里沉淀了多少对使用者的理解。希望这份代码能成为你动手做公益项目或 LBS 练笔的一块跳板。如果这篇文章对你有帮助欢迎收藏备用。有疑问也欢迎在评论区讨论看到都会尽量回复。
返回列表