资讯动态

FastAPI+Vue3全栈实战:小学生共享接送平台开发

发布时间:2026/9/9 14:15:57 来源:尧图企业网站定制
家里有两个在念小学的孩子每天下午三点半的放学铃声几乎成了我日程表上最准时的闹钟。和同事聊天才发现写字楼里至少有一半家长的下午四点都在“抢时间”——要么请假接娃要么麻烦老人跑一趟。后来我和几位朋友合计与其各接各的不如做一个“共享接送”平台让同小区、同学校、时间匹配的家长或顺路志愿者互相帮忙。于是就有了这个叫“逐光”的小学生接送帮共享平台。项目后端采用 Python 和 FastAPI 完成核心业务、匹配逻辑与实时通信前端使用 Vue3 全家桶覆盖家长端、接送员端和管理后台前后端通过 RESTful API 与 WebSocket 协作。这篇文章会把整套系统从需求分析、技术选型、数据库设计到核心模块实现、联调部署、踩坑复盘完整捋一遍适合正在做毕业设计、想入门 Python 全栈或者打算做社区共享类产品的开发者参考。1. 需求分析先把“共享接送”的业务闭环想明白1.1 用户角色与核心场景任何平台类项目第一步都不是写代码而是把人找齐。这个平台的用户一共三类发布需求的家长、提供服务的接送员、管理全站的后台管理员。家长侧的痛点非常具体孩子放学时间和家长下班时间存在“时差”老人又没办法天天跑接送员侧的需求更多元可能是同小区全职妈妈想顺手帮邻居也可能是专业托管机构想扩大客源管理员则关心平台是否安全、订单能否履约、纠纷能不能快速闭环。在这三个角色的基础上我把核心使用场景梳理成四条主线家长发布接送需求、系统匹配候选接送员、接送员接单后开启行程并更新位置、行程结束后双方互相评价。整个业务闭环最终都会落在这四个行为上。平台取名“逐光”寓意也很直接——孩子放学路上有人帮忙点亮一盏灯把“顺路”变成“互助”。我梳理场景时习惯画一张简单的泳道图把家长、接送员、管理员各自的动作和系统动作排列清楚。图中最关键的是“异常分支”孩子临时请假、接送员路上堵车、家长电话打不通这些在现实中高频出现的状况如果第一版不定义清楚后期接需求时很容易扯皮。所以我一开始就给订单定义了“取消”“超时”“纠纷”三类异常状态的处理入口宁可功能少一点也不能让用户卡在半路。1.2 MVP 功能范围与迭代取舍很多刚做项目的人一上来就想把功能铺满在线支付、语音通话、智能推荐全堆上去结果做了三个月连个能跑通的 demo 都没有。我的建议是严格遵循 MVP最小可行产品思路第一版只保留五件事账号注册登录、接送需求发布、订单流转、位置共享、评价评分。在线支付第一版直接砍掉原因是涉及资金托管和第三方支付资质成本远超技术本身语音通话也被砍掉因为在平台信任机制没建立之前家长更倾向先线上沟通再用电话确认。这个取舍带来了非常明显的好处核心链路缩短后前后端联调复杂度大大降低我在业余时间大概五周就把第一版完整跑通了。为方便跟踪需求我做了一张带优先级的功能清单用 P0/P1/P2 区分。P0 是主流程必须项P1 是体验优化项P2 是后续扩展项。第一版只排 P0 和少量 P1比如推送通知这类功能排到 P1因为它对订单状态感知有帮助但没有也不影响核心闭环。这个清单帮我挡住了很多临时加需求的冲动也让开发节奏变得很稳。2. 技术选型与开发环境准备2.1 后端为什么是 Python 和 FastAPI每次做项目最难回答的问题就是“为什么选这个框架”。我选 Python 主要看重三点一是生态里地理计算、数据处理、机器学习相关库非常成熟后续做智能匹配推荐可以直接复用二是社区资源多遇到问题搜索解决成本低三是类型提示和自动文档生成的体验很舒服对一个人开发的小团队来说省掉大量接口文档编写成本。具体框架在 Flask、Django 和 FastAPI 之间对比过。Flask 轻量但全靠自己拼装适合玩具项目Django 自带后台管理、ORM、认证很全面但重量感太强对共享平台这种中等规模业务有点像“杀鸡用牛刀”FastAPI 兼顾轻量和现代原生支持异步接口和 WebSocketPydantic 做参数校验再加上 Swagger 文档自动生成几乎是现在做这类业务的最优解。实际编码时 Python 版本用的 3.11依赖管理直接用 pip 和 requirements.txt没有引入 Poetry目的是降低环境迁移成本。2.2 前端为什么选 Vue3 全家桶前端技术栈一开始犹豫过要不要上 React但考虑到成员更熟悉 Vue而 Vue3 的 Composition API 对逻辑复用实在太友好最终确定 Vue3 Vite Pinia Element Plus 的组合。如果你从 Vue2 迁过来一定会有明显感知Vue2 的 Options API 把数据、方法、生命周期拆成一块一块组件一复杂就让人找不着北Vue3 的 Composition API 让相关逻辑聚合在一起配合script setup语法糖写起来几乎是按业务纵向组织代码。生命周期上的变化也必须提。Vue2 的destroyed变成了 Vue3 的unmountedbeforeDestroy变成beforeUnmountcreated阶段的内容基本可以在script setup顶层直接执行onMounted取代了老的mounted。这些改动不大但踩坑时特别容易疑惑尤其做地图初始化和 WebSocket 连接时生命周期用不对就会出现“地图白屏”或“页面销毁后连接还在”的问题。状态管理我选了 Pinia 而不是 Vuex。Pinia 去掉了 mutations 的概念同步修改 state 直接在 actions 里完成代码量直接少三分之一按模块拆分 store 也很简单user.js管用户信息order.js管订单message.js管实时消息各管各的互不干扰。对于我这边的项目规模Pinia 的 TypeScript 支持和开发体验都比 Vuex 好一个档次。2.3 从零搭建开发环境后端环境安装其实没什么玄学我在这台新电脑上的标准流程是先去官网下载 Python 3.11 安装包勾选“Add Python to PATH”然后打开终端创建虚拟环境并激活。强烈建议所有 Python 项目都用虚拟环境不要直接往全局 site-packages 里装依赖。依赖列表我用 requirements.txt 锁住版本后端核心依赖大致是这组fastapi0.109.0 uvicorn[standard]0.27.0 sqlalchemy2.0.25 pymysql1.1.0 cryptography42.0.2 python-jose[cryptography]3.3.0 passlib[bcrypt]1.7.4 python-multipart0.0.6 websockets12.0前端用npm create vitelatest初始化 Vue3 项目模板选vue然后手动补vue-router、pinia、axios、element-plus。这里有个细节Vite 要求 Node 版本至少 16我一开始用的旧版本 Node 直接报错后来用 nvm 切到 Node 18 才顺利。开发阶段前端跑在 5173 端口后端跑在 8000 端口跨域代理就在vite.config.js里配置把/api开头的请求转发到后端这样本地联调时浏览器不会出现跨域拦截。3. 数据库设计与后端核心模块实现3.1 核心表结构与索引设计数据库选了 MySQL 8.0理由是使用人数多、部署简单、资料好查。核心表一共四张用户表users、孩子信息表children、订单表orders、评价表evaluations。用户表存放手机号、昵称、角色、信用分订单表是系统核心除了关联发起人和接单人还会冗余存下起点终点文本地址因为地图坐标会被频繁转换把地址文本冗余下来能省很多联调成本。订单表结构大概是这样的字段类型说明idbigint主键demand_user_idbigint发起人家长IDserver_user_idbigint接单人接送员ID初始为空start_addressvarchar(200)起点文本地址end_addressvarchar(200)终点文本地址start_lat / start_lngdouble起点经纬度GCJ-02end_lat / end_lngdouble终点经纬度GCJ-02pickup_timedatetime期望接送时间statusvarchar(20)waiting/accepted/in_progress/completed/cancelledversionint乐观锁版本号created_atdatetime创建时间索引方面orders表把(demand_user_id, status)和(server_user_id, status)都建了联合索引因为列表查询基本都是“某个人某状态下的订单”这两个索引效果非常明显。children表代表孩子信息我用user_id做外键关联并且所有涉及孩子信息的接口都加了权限校验避免越权读取。3.2 用户认证与权限控制登录方案用最常见的 JWT服务端用python-jose生成 token密码用passlib的 bcrypt 加密。为什么不用 Django 那种 session因为前端是 Vue3 单页应用后续还要接小程序和 Apptoken 验证天然跨平台也不占用服务端内存。JWT 的坑在于过期时间不能设置太长我定位 2 小时接送平台属于低频强安全需求场景用户重新登录的成本可以接受。权限控制写了两个依赖函数get_current_user从请求头取 Bearer token解密后查出用户对象require_role再判断当前角色是否匹配。高权限接口只允许管理员访问比如用户审核、数据看板、纠纷处理。下面这段代码是目前生产环境在用的认证依赖# app/deps.py from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from jose import JWTError, jwt from sqlalchemy.orm import Session from .database import get_db from .models import User from .config import SECRET_KEY, ALGORITHM security HTTPBearer() def get_current_user( credentials: HTTPAuthorizationCredentials Depends(security), db: Session Depends(get_db), ) - User: token credentials.credentials try: payload jwt.decode(token, SECRET_KEY, algorithms[ALGORITHM]) user_id: int payload.get(sub) if user_id is None: raise HTTPException(status_codestatus.HTTP_401_UNAUTHORIZED, detail无效令牌) except JWTError: raise HTTPException(status_codestatus.HTTP_401_UNAUTHORIZED, detail无效令牌) user db.get(User, int(user_id)) if user is None: raise HTTPException(status_codestatus.HTTP_401_UNAUTHORIZED, detail用户不存在) return user def require_role(*roles: str): def checker(user: User Depends(get_current_user)) - User: if user.role not in roles: raise HTTPException(status_codestatus.HTTP_403_FORBIDDEN, detail无权限) return user return checker3.3 需求发布与顺路匹配算法家长发布需求时前端提交的是起终点文本、期望接送时间、孩子人数和备注。后端收到后不会立刻锁定订单而是先走一轮“候选匹配”把附近、时间空闲、信誉分达标的接送员找出来再推送到接送员端。匹配核心是距离计算我用哈弗辛公式Haversine因为地球表面是球面直接拿经纬度做欧氏距离误差很大。实际代码里封装了一个matching.py模块先根据起点经纬度查找 5 公里范围内的接送员再按顺路度排序。顺路度的实现逻辑是比较接送员当前位置到需求起点的距离与需求起点到需求终点的距离两个值越接近说明越顺路。这个逻辑虽然简单但实测在家长和接送员规模不大的区域效果已经比随机推荐好很多。# app/services/matching.py import math from sqlalchemy import select from ..models import User, Order def haversine(lat1: float, lng1: float, lat2: float, lng2: float) - float: R 6371.0 dlat math.radians(lat2 - lat1) dlng math.radians(lng2 - lng1) a math.sin(dlat / 2) ** 2 math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlng / 2) ** 2 return 2 * R * math.asin(math.sqrt(a)) async def match_guardians(order, db, radius_km: float 5.0): candidates db.execute( select(User).where( User.role guardian, User.credit_score 80, User.is_active True, ) ).scalars().all() scored [] for g in candidates: if g.id order.demand_user_id: continue dist haversine( order.start_lat, order.start_lng, g.last_lat, g.last_lng, ) if dist radius_km: scored.append((g, dist)) scored.sort(keylambda x: x[1]) return [g for g, _ in scored[:10]]3.4 订单状态机与 WebSocket 实时通知订单状态流转我一开始写得很松前后端经常对不上号。后来专门做了一张状态表把每一步的触发方、前置条件、后置动作全部定死。状态有五种等待接单、已被接单、进行中、已完成、已取消。等待接单状态下家长可以取消接送员接受后进入已接单接送员点击“开始行程”进入进行中到达目的地点“确认完成”进入已完成特殊情况由双方协商后走取消逻辑。这里有个踩坑经验取消订单是高频操作如果每次直接把状态改成cancelled订单历史就丢了。我的做法是加了一个cancelled_by字段记录取消发起方同时在取消接口里判断当前状态是否允许取消。比如订单进入in_progress后任何一方都不能直接取消必须走管理员介入这个约束撑住了平台早期的信任底线。实时位置推送和订单状态变更我用 WebSocket 实现。轮询接口每秒打一次服务端压力很大体验也差。FastAPI 原生支持 WebSocket配合前端的vueuse库使用体验很顺。服务端用一个内存字典保存user_id - WebSocket连接接送员位置变化时通过send_json推给家长端家长端看到的就是平滑移动的车辆位置。WebSocket 的坑主要在断线重连移动网络切换、浏览器休眠都会导致连接断开前端必须监听close事件并自动重连后端每 30 秒发一次心跳包这套机制上线后位置推送才真正稳定。4. 前端 Vue3 关键功能实现4.1 项目搭建与 axios 请求封装前端初始化我直接使用npm create vitelatest模板选vue然后手动补vue-router、pinia、axios、element-plus。很多教程喜欢让你把全家桶一次装完我的建议是按需引入开发到哪一步再用到哪个库这样心里清楚每个依赖为什么存在。axios 封装是前端工程必修课。主要做三件事设置基准地址、请求拦截器加 token、响应拦截器统一处理 401。开发环境的跨域问题靠 Vite proxy 解决部署环境的跨域问题靠 Nginx 反向代理解决不要在 axios 里写死后端 IP。下面这段是生产环境的请求封装// src/utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 15000, }) request.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( (response) response.data, (error) { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.response?.data?.detail || 请求失败) return Promise.reject(error) } ) export default request4.2 需求发布页地址输入与地图选址家长发布需求是平台使用频率最高的入口前端体验必须足够简单。我把它拆成三步填地址、选时间、填人数与备注。地址填写用高德地图的输入提示组件家长输入小区名时下拉出现候选地址选定后在页面底部渲染地图把坐标经纬度回传给表单。时间选择用 Element Plus 的el-date-picker限制只能选未来 24 小时内的时间。地图初始化和 Vue3 生命周期关系很密切。地图实例必须在onMounted里初始化因为要确保 DOM 已经渲染完成但高德地图 JS API 的加载是异步的所以我会先动态插入 script 标签再在回调里初始化地图。如果在setup顶层直接初始化十有八九会拿到 null 容器地图自然白屏。还有一点容易被忽略点击地图选点后一定要先用AMap.Geocoder逆地理编码把坐标转成可读地址再回填到输入框。直接展示经纬度对家长用户毫无意义。逆地理编码是异步请求需要在回调里更新表单否则会出现地址栏空白、地图坐标却已经选好的奇怪状态。4.3 订单列表与 Pinia 状态管理平台的“订单”对家长和接送员是两种完全不同的列表家长看到的是我发布的等待接单和进行中订单接送员看到的是附近可抢的单和自己接过的单。为了让状态在页面切换时不丢失我用 Pinia 建了一个useOrderStore把订单列表、当前查看订单、筛选条件都放在 store 里页面组件只负责渲染和交互。Pinia 相比 Vuex 最大的变化是去掉了 mutations同步修改 state 直接在同级 action 里完成代码量减少三分之一。多人协作时按模块拆分 store 也很简单user.js、order.js、message.js各管各的。订单状态字段我在前端也做了一层枚举映射接口返回accepted时界面显示为“已接单”并对应不同的按钮状态。这个映射文件建议单独维护不要写死在组件里。4.4 路由守卫与角色权限用户角色不同能访问的页面也不同。家长不需要进入接单大厅管理员只能进后台管理。我用 Vue Router 的meta字段标记每个页面需要的角色在beforeEach导航守卫里统一判断没登录就跳登录页登录了但角色不匹配就跳 403 页面。这段代码是路由守卫的核心逻辑// src/router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (!token to.meta.requiresAuth) { next({ path: /login, query: { redirect: to.fullPath } }) return } if (token to.meta.roles !to.meta.roles.includes(role)) { next(/403) return } next() })这里的细节是query.redirect登录成功后能自动跳回原本想访问的页面体验会好很多。还有一个小技巧路由表拆成三块公共路由、家长路由、管理员路由然后按需加载。管理员后台的组件体积很大如果全部进首屏包首屏加载会非常慢所以我把后台页面都设置成懒加载只有访问对应路由时才真正下载 JS 文件。5. 联调、部署与常见问题排查5.1 前后端联调中的跨域问题第一周联调时我以为把 axios 的 baseURL 改成http://localhost:8000就能通结果浏览器直接报跨域错误。原因很简单前端跑在 5173 端口后端跑在 8000 端口协议、域名、端口里端口不一致就会触发同源策略。开发阶段我在vite.config.js里配置 server.proxy把/api开头的请求全部转发到后端前端代码里只写/api不写完整后端地址。生产环境就不同了我用 Nginx 做统一入口静态资源由 Nginx 直接返回/api请求通过proxy_pass转发到后端服务。这样前后端同源不存在跨域问题还能顺带做 gzip 压缩和静态资源缓存。这里提示一下不要在前端代码里用环境变量硬编码不同环境的接口地址统一用/api前缀是最省心也最不容易出错的方案。5.2 一个订单被两个接送员同时接单的并发问题第一次压测时发现两个接送员同时抢一个订单后端两个请求几乎同时读到“等待中”状态然后都更新成“已接受”数据库里就出现了一个单被两个人接住的脏数据。这是典型的并发竞态条件。解决方案是给订单表加乐观锁字段version更新时带上版本号版本号不匹配就说明数据已被别人改过当前操作直接失败。代码实现上我不再先查后改而是直接在一条 update 语句里加where条件判断状态# app/services/order_accept.py from sqlalchemy import update from sqlalchemy.orm import Session from .models import Order def accept_order(db: Session, order_id: int, guardian_id: int) - bool: result db.execute( update(Order) .where(Order.id order_id, Order.status waiting) .values(statusaccepted, server_user_idguardian_id) ) db.commit() return result.rowcount 1result.rowcount 1表示只有一个请求真正更新成功另一个请求的行数为 0直接返回“已被抢单”提示。这个方法比锁表优雅得多业务代码不需要额外引入 Redis 分布式锁。5.3 坐标系不一致导致的地图偏移有家长反馈发布需求时选的点和最终接送员导航到达的点差了一百米。排查后发现浏览器定位拿到的经纬度是 WGS84 坐标系而高德地图用的是 GCJ-02 坐标系两者之间存在几十到几百米的偏移。解决方法是把 GPS 定位坐标通过高德的坐标转换工具转成 GCJ-02再传给后端保存后端统一使用 GCJ-02地图展示就不存在偏差。这里要强调一下凡是涉及地图业务的数据库表一定要在接口文档里明确标注坐标系前后端要保持一致。我吃过暗亏订单表里混入了两种坐标数据排查起来非常痛苦后来干脆写了一个数据清洗脚本把所有 WGS84 坐标批量转成 GCJ-02并加了字段备注。地图相关 bug 往往不是代码逻辑问题而是数据约定问题。5.4 移动端键盘弹出遮挡输入框家长发布页在手机浏览器上有个很让人头疼的问题点击输入框时键盘弹起来底部按钮被键盘挡得严严实实。试过position: fixed的按钮键盘弹起后依然错位。最后方案是底部按钮不再用 fixed而是放在文档流里配合 CSS100dvh动态视口高度布局。100dvh是动态视口高度单位会在键盘弹出和收起时自动更新比原来的100vh稳健很多。同时我还给输入框加了scroll-into-view的逻辑输入框获得焦点时自动滚动到可视区域确保用户在填写长表单时不会开着盲区输入。这套方案在 iOS 和 Android 上都测过体验基本一致。如果你还在用window.innerHeight监听键盘高度建议尽快切到 dvh 方案代码量少而且维护成本低。5.5 Docker Compose Nginx 部署上线服务器上我装的是 Ubuntu 22.04用 Docker Compose 管理三个容器mysql8、backend、frontend。MySQL 容器挂载数据卷防止容器重建后数据丢失后端镜像基于python:3.11-slim安装依赖后用uvicorn app.main:app --host 0.0.0.0 --port 8000启动前端镜像基于 Nginx把构建好的dist目录复制进镜像再挂载一份 Nginx 配置。Docker 化最大的好处是环境一致。本地跑得好好的服务部署到服务器上偶尔会因为 Python 版本、系统库版本不一致而起不来容器化把这些问题彻底消灭了。我常用的启动命令是docker compose up -d --build每次改动代码后重新构建一次。Nginx 核心配置大概长这样server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api { proxy_pass http://backend:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /ws { proxy_pass http://backend:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; } }部署后第一件事是检查 WebSocket 能不能通过 Nginx 连通。如果Upgrade头部没配好家长端和接送员端会一直处于连接失败状态位置推送完全失效。这个坑我调了半个晚上才找到原因。6. 项目复盘与几点实用建议6.1 做得对的地方MVP 范围控制和状态机设计现在回头看第一版能快速跑通MVP 范围控制起了决定作用。很多人做项目时不自觉地给自己加需求做着做着发现功能很多但没有一个稳定。我只围绕核心闭环切功能所有跟主流程无关的东西全部排在第二版这个习惯让开发节奏非常可控。订单状态机的设计也是比较满意的地方。提前把每个状态对应的操作和异常处理定清楚前后端在联调时几乎没出现“状态对不上”的扯皮。状态机代码虽然简单但它属于业务核心值得花时间把所有状态转换路径都写清楚。6.2 做得不够的地方消息中心和安全校验做得不够的地方也有。第一版消息中心只实现了 WebSocket 实时推送但没有做消息持久化一旦用户离线重新登录后就看不到历史消息。后来花了两天补了消息表和历史拉取接口其实在最初设计数据库时就应该考虑进去。安全校验也相对基础。虽然做了 JWT 和角色权限但接口层面对参数边界值校验不够严格比如发布需求时没限制孩子数量范围理论上用户传 999 也能创建成功。这类问题是安全隐患虽然后续补上了但前期开发时如果多用 Pydantic 的参数约束能省掉很多麻烦。6.3 给同类项目开发者的三点建议第一先把信任机制想清楚再做。共享类产品核心不是功能炫酷而是让“信任”变得可计算。信用分、评价体系、纠纷处理流程这些规则设计比技术实现更重要至少要留出整个项目 30% 的精力。第二地图业务一定把坐标系约定写在文档里。这个问题看起来很小排查起来却非常耗时前后端各执一词最后发现是数据标准不统一。尽早统一标准能省下大量联调时间。第三部署一定要容器化。Docker 不仅解决环境差异还能让回滚变得很简单镜像打上一个 tag出问题时切回旧版本就是一条命令的事。对一个个人开发的小项目来说这种稳定性保障非常值得投入。我个人做下来最大的体会是这类共享平台的核心并不是技术有多炫酷而是要让“信任”变得可计算。家长敢把孩子的接送托付给一个陌生人靠的是平台能完整记录每一次接送、每一次评价、每一次异常处理接送员愿意投入时间靠的是能清晰看到自己信用积累和被尊重的回报。代码层面的 JWT、状态机、WebSocket最终都只是在为这个信任系统提供骨架。如果你也想做类似项目建议把至少 30% 的精力放在规则设计和数据闭环上这会比单纯增加新功能更能决定项目的成败。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价