ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于Flask与uni-app的校园跑腿任务接单系统开发实战

基于Flask与uni-app的校园跑腿任务接单系统开发实战 这两年校园跑腿类的需求越来越多代取快递、食堂带饭、图书馆占座、打印资料随便一个场景都能攒出一批“懒人经济”。但大多数学校并没有一个统一的接单平台同学找人帮忙靠微信群接龙发一次消息被刷屏淹没接单的人也没有规则约束。所以当时决定自己写一个校园跑腿帮任务接单互助系统后端用 Python 的 Flask前端用 uni-app 做微信小程序。这个组合对于学生开发者来说非常合适Flask 轻、上手快、生态成熟uni-app 写一套代码能编译到微信小程序以后想上支付宝小程序或者做 App 也能复用再加上微信小程序本身就覆盖了几乎全部学生的使用场景传播成本低。这个项目适合两类人参考一类是想做毕业设计或者课程项目的在校生另一类是刚学完 Flask 和 Vue、想完整打通前后端的初级开发者。我下面会把从需求拆解到表结构设计、从接口实现到小程序页面联调、从并发接单的坑到部署上线的整个链路都梳理一遍里面有不少是我自己踩过坑之后总结出来的经验可以直接拿去做。1. 整体设计与思路拆解1.1 需求拆解跑腿系统到底要解决什么问题校园跑腿帮的本质是一个任务撮合平台核心参与方有三个发布任务的人、接单的人、管理员。发布者有需求接单者有时间和意愿平台负责把信息展示、状态流转、责任划分搞清楚这样就形成了一个简单的双边市场。先说完整功能清单我按模块拆用户模块微信小程序一键登录、昵称头像展示、信用分初始100、身份角色普通用户/管理员任务模块发布任务标题、描述、类型、悬赏金额、取件地点、送达地点、截止时间、任务大厅列表、按状态筛选接单模块任务详情、立即接单、确认完成、取消任务个人中心模块我发布的、我接单的、信用记录管理后台模块任务审核、用户封禁这个可以后续扩展任务状态机是核心中的核心一定要先定清楚。我用的状态码是0待接单、1进行中、2已完成、3已取消。这里要注意不应该出现“已接单”和“进行中”两个状态并存因为接单动作完成就代表任务已经被执行者锁定这时候发布者不能取消只能接单者主动取消这样能保护接单者的权益。实际跑起来之后你会发现一个潜在问题接单者把任务接下来但一直不去做任务就卡住了。所以可以加一个“超时自动取消”的定时任务比如接单后超过24小时未操作自动释放回待接单池这个用 Flask 的定时任务或者简单轮询都能实现。1.2 技术选型为什么是 Flask uni-app选型这件事我纠结过一阵。当时也考虑过 Django 和 Vue 的搭配但最终还是定了 Flask。原因很实际这个系统的业务逻辑不算复杂用 Django 有点杀鸡用牛刀Flask 的灵活度更高写起来也很直接接口函数返回 JSON 就完事。Flask 的扩展库虽然没有 Django 全家桶那么齐全但 SQLAlchemy、CORS、JWT 这些核心需求都有成熟方案完全够用。前端方面uni-app 的价值在于“多端复用”。虽然目标是微信小程序但 uni-app 项目在 HBuilderX 里也能跑 H5 页面调试方便以后学校想让安卓 iOS 用户不用微信也能用同一套代码直接打包成 App。它的生命周期和 Vue 基本一样做过 Vue 开发基本零成本迁移。需要提醒的是uni-app 编译到微信小程序时会有一些平台差异比如不能直接使用 DOM API、CSS 的支持也有限制这部分我后面会细说。至于数据库开发阶段用 SQLite 足够部署到正式服务器时换成 MySQL。SQLAlchemy 的 ORM 可以让你在切换数据库时只改连接字符串模型不用动这一点在项目初期很省心。1.3 系统架构与数据库设计系统采用前后端分离结构。小程序端通过 HTTP 请求访问后端的 RESTful API后端只负责数据处理和业务逻辑不做页面渲染。这么做的好处是以后如果需要管理后台网页可以直接复用同一套 API而且接口开发和页面开发可以并行推进。数据库表设计直接决定后期开发效率我设计了几张核心表用户表 users字段类型说明idint主键自增openidvarchar微信唯一标识登录后写入nicknamevarchar微信昵称avatar_urlvarchar头像地址phonevarchar手机号可选roletinyint0普通用户 1管理员creditint信用分created_atdatetime注册时间任务表 tasks字段类型说明idint主键自增publisher_idint发布者ID外键关联用户表accepter_idint接单者ID默认NULLtitlevarchar任务标题descriptiontext任务详细描述task_typetinyint1代取快递 2代买饭 3代拿外卖 4其他rewarddecimal悬赏金额pickup_locationvarchar取件地点delivery_locationvarchar送达地点statustinyint0待接单 1进行中 2已完成 3已取消deadlinedatetime截止时间create_timedatetime发布时间accept_timedatetime接单时间finish_timedatetime完成时间评价表 evaluations字段类型说明idint主键自增task_idint关联任务from_user_idint评价者to_user_idint被评价者ratingtinyint1-5星commentvarchar内容最初我还设计过订单表因为考虑到一个任务可能被多个人接单但后来想通了跑腿场景必须是“一单一接”接单成功后其他人不能再抢所以直接用 accepter_id 字段标记比单独建订单表更简洁。如果你以后想支持多人团购、拼单模式那就需要单独拆订单表了。2. 核心细节解析与实操要点2.1 后端 Flask API 设计与认证机制接口设计遵循一个原则URL 用资源命名HTTP 方法表达操作意图。比如/api/tasks用 GET 获取列表、POST 发布任务/api/tasks/id/accept用 POST 表示接单动作。不需要花哨的 RESTful 规范但要把语义做对。认证我用的是 JWT (JSON Web Token)而不是 Flask-Login 的 Session。原因很简单前后端分离 小程序端Session 要处理 Cookie 同步问题麻烦且容易遇到跨域限制JWT 无状态后端只负责签发 token小程序把 token 存在本地 Storage每次请求放到 Header 的 Authorization 字段即可。登录流程是这样的前端调用wx.login()获取临时 code前端把 code 发送到后端/api/auth/login后端拿着 code 调用微信接口https://api.weixin.qq.com/sns/jscode2session换取 openid 和 session_key后端在数据库查找或创建用户然后生成 JWT 返回给前端前端把 token 保存并在后续请求中携带这里有个坑微信接口的调用需要有正式的小程序 AppID 和 AppSecret开发阶段可以用微信公众平台的测试号也可以用 uni-app 的“一键登录”插件简化但自己手写登录逻辑才能深入理解原理。我在本地开发时用的是测试号申请 AppID 时需要选“小程序”然后配置 request 合法域名不然真机预览时请求会被拦截。生成 JWT 时我用 PyJWT 库payload 里放 user_id 和过期时间密钥存到环境变量不要把密钥硬编码到代码里。代码大概长这样import jwt import datetime def generate_token(user_id): payload { user_id: user_id, exp: datetime.datetime.utcnow() datetime.timedelta(days7) } token jwt.encode(payload, SECRET_KEY, algorithmHS256) return token校验 token 需要写一个装饰器login_required这个装饰器用于所有需要登录的接口。逻辑很简单从 Header 拿 Authorization去掉 “Bearer ” 前缀用同一密钥解码如果过期或其他错误就返回 401。我一开始犯过一个错在装饰器里直接返回 JSON 字符串前端拿到的状态码和响应结构不一致后来统一封装成api_response(code, msg, data)函数所有接口返回格式才统一。2.2 前端 uni-app 项目结构与请求封装uni-app 项目我是在 HBuilderX 里创建的框架选的 Vue 3 版本但核心逻辑和 Vue 2 差别不大。项目结构按页面和静态资源分├── pages │ ├── index/index # 任务大厅 │ ├── publish/publish # 发布任务 │ ├── taskDetail/taskDetail # 任务详情 │ ├── mine/mine # 个人中心 ├── static ├── utils │ ├── request.js # 请求封装 └── App.vue所有请求都走utils/request.js不直接在页面里写uni.request。这样有个好处当需要统一拦截 401 或统一加载状态时只需要改一个文件。封装的核心逻辑是// utils/request.js const BASE_URL https://yourdomain.com/api export function request(path, method GET, data {}) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL path, method: method, data: data, header: { Content-Type: application/json, Authorization: uni.getStorageSync(token) }, success(res) { if (res.statusCode 200) { if (res.data.code 0) { resolve(res.data.data) } else { uni.showToast({ title: res.data.msg, icon: none }) reject(res.data) } } else if (res.statusCode 401) { // 登录过期跳转登录页 uni.redirectTo({ url: /pages/login/login }) reject(res.data) } else { uni.showToast({ title: 服务器错误, icon: none }) reject(res.data) } }, fail(err) { uni.showToast({ title: 网络异常, icon: none }) reject(err) } }) }) }页面里就能很清爽地调用了例如import { request } from /utils/request.js getTaskList().then(res { this.tasks res })一个小技巧uni.getStorageSync(token)如果你在 request 封装里每次去读token 过期时就能统一处理不需要每个页面单独判断。另外微信小程序的并发请求上限是 10 个如果页面里同时发了大量请求比如图片视频加载建议用 uni.request 的队列封装做一下并发限制不过校园跑腿这种轻量场景不触发也可以。2.3 微信小程序登录的细节与角色控制小程序登录是很多新手容易绕晕的地方。核心点在于后端不能直接拿到用户身份必须通过 code 换 openid。每个用户在同一个微信小程序下的 openid 是唯一的这就是你的账号体系根节点。登录接口返回的 token 中我还会附加is_admin字段方便前端做管理员界面时根据角色渲染不同的按钮。但后端的角色判断永远不能只依赖前端传参管理员接口必须用admin_required装饰器再次校验防止有人自己改请求参数。举一个实际例子普通用户默认 role0manager 页面用role1判断但后端的封禁接口必须检查当前登录用户的 role 是否为 1否则任何人都可以调用这个接口把自己提权。写登录接口时我踩过一个典型的坑wx.login生成的 code 有效期只有 5 分钟而且只能用一次。有一次并行请求了多个接口每个接口都先去调登录结果第二个请求拿到过期 code接口报错。解决办法是前端登录态持久化只在首次启动或者 token 过期时调用一次登录其余请求携带 token 即可。2.4 任务状态机与并发接单处理接单是这个系统最容易出并发问题的环节。设想一个场景任务 A 挂到大厅两个用户同时点击“接单”如果不对数据库状态做约束两个请求都先读到 status0然后都更新成功就会造成一个任务被两个人接的脏数据也就是“超卖”。后端的接单接口我第一版是这样写的task Task.query.filter_by(idtask_id, status0).first() if task is None: return error(任务不存在或已被接走) task.accepter_id current_user.id task.status 1 db.session.commit()这个写法在高并发下是有问题的两个请求同时拿到 task其中一个提交后另一个再提交后一个的更新会覆盖前一个导致 accepter_id 变成后一个用户。正确做法是使用条件更新让数据库自己保证原子性result Task.query.filter_by(idtask_id, status0).update({ accepter_id: current_user.id, status: 1, accept_time: datetime.datetime.now() }) db.session.commit() if result 0: return error(手慢了任务已经被接走)filter_by(id..., status0).update()会在数据库层面执行“只有状态为 0 时才更新”的原子操作result返回受影响的行数。如果结果是 0说明状态已经变了直接返回失败。这是我在实习时学到的大并发下库存扣减的思路放在这里一样适用。状态流转还要注意一点发布者不能接自己的任务这个判断要在接单接口里做如果task.publisher_id current_user.id直接返回错误。3. 实操过程与核心环节实现3.1 环境准备从零搭起 Flask 后端开发环境我用的是 Python 3.10建议用虚拟环境隔离依赖避免和其他项目冲突。终端操作如下mkdir campus-runner cd campus-runner python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate pip install flask flask-sqlalchemy flask-cors pyjwt requestsflask-cors是必须装的因为微信小程序真机环境虽然不校验跨域但开发阶段如果你用 H5 调试或者用浏览器测试接口没有 CORS 配置就会看到经典的 CORS 报错。在 app 里这样启用from flask import Flask from flask_cors import CORS app Flask(__name__) CORS(app) # 默认允许所有来源跨域不过生产环境建议只允许特定域名用CORS(app, origins[https://yourdomain.com])方式更严格。我当时图省事用了默认配置后来被安全测试的同学提醒了好在只是个人项目要是放到公网还是要认真配置白名单。3.2 数据库建模与 Flask-SQLAlchemy 实现模型用 SQLAlchemy 定义。下面是 Task 模型的示例注意在截至时间字段上要加个索引因为列表页经常按时间排序索引能避免全表扫描from flask_sqlalchemy import SQLAlchemy from datetime import datetime db SQLAlchemy() class Task(db.Model): __tablename__ tasks id db.Column(db.Integer, primary_keyTrue) publisher_id db.Column(db.Integer, nullableFalse) accepter_id db.Column(db.Integer, nullableTrue) title db.Column(db.String(100), nullableFalse) description db.Column(db.Text, nullableTrue) task_type db.Column(db.SmallInteger, nullableFalse, default0) reward db.Column(db.Numeric(8, 2), nullableFalse, default0) pickup_location db.Column(db.String(200), nullableFalse) delivery_location db.Column(db.String(200), nullableFalse) status db.Column(db.SmallInteger, nullableFalse, default0) deadline db.Column(db.DateTime, nullableTrue) create_time db.Column(db.DateTime, defaultdatetime.now) accept_time db.Column(db.DateTime, nullableTrue) finish_time db.Column(db.DateTime, nullableTrue) __table_args__ ( db.Index(ix_task_status_create_time, status, create_time), )模型建好后在初始化函数里调用db.create_all()建表。如果后续修改了模型字段直接用db.drop_all()db.create_all()重新建表但这会清数据只适合开发阶段。正式环境建议用 Flask-Migrate 做迁移管理。3.3 任务发布与接单接口实现发布任务接口前端传 JSON后端解析并校验必填字段。这里有一个我实际遇到的校验陷阱前端传入的 reward 是数字类型但 JSON 解析后如果前端没用Number转换可能是字符串数据库会报错。所以接口里一定要做类型转换app.route(/api/tasks, methods[POST]) login_required def create_task(): data request.get_json() title data.get(title) title title.strip() if title else if not title or len(title) 2: return api_response(1, 任务标题至少2个字符) try: reward float(data.get(reward, 0)) reward round(reward * 100) / 100 # 保留两位小数 except (TypeError, ValueError): return api_response(1, 悬赏金额格式错误) task Task( publisher_idcurrent_user.id, titletitle, descriptiondata.get(description, ), task_typeint(data.get(task_type, 0)), rewardreward, pickup_locationdata.get(pickup_location, ).strip(), delivery_locationdata.get(delivery_location, ).strip(), deadlineparse_datetime(data.get(deadline)), status0 ) db.session.add(task) db.session.commit() return api_response(0, 发布成功, {task_id: task.id})接单接口就是上面提到的条件更新app.route(/api/tasks/int:task_id/accept, methods[POST]) login_required def accept_task(task_id): task db.session.get(Task, task_id) if not task: return api_response(1, 任务不存在) if task.publisher_id current_user.id: return api_response(1, 不能接自己发布的任务) if task.status ! 0: return api_response(1, 当前状态不可接单) result Task.query.filter_by(idtask_id, status0).update({ accepter_id: current_user.id, status: 1, accept_time: datetime.now() }) db.session.commit() if result 0: return api_response(1, 手慢了任务已经被接走) return api_response(0, 接单成功)3.4 前端页面开发从任务大厅到发布表单任务大厅页面主要是列表展示用onShow生命周期加载数据因为当用户从详情页返回列表时任务状态可能已经变了所以不能用onLoad只加载一次。列表项我用了卡片式设计展示标题、悬赏金额、地点、状态标签。点击卡片跳转详情页并传 task id。这里要注意uni.navigateTo的 URL 长度有限制传一个数字 ID 没问题不要传整个对象。发布页面是表单控件最多的一个页面。这里我用到了textarea输入描述picker选择任务类型picker的modetime选择截止时间。发布按钮点击后先做前端校验再调用request(/api/tasks, POST, formData)。有一个体验细节如果是余额支付而不是积分应该在发布时显示预计金额并加上“确认发布后不可修改请核实信息”的提示减少无效任务。接单按钮在详情页里逻辑是点击后弹出确认框再调接口。接单成功后更新页面状态并把按钮置灰。状态变更的 UI 反馈很关键不然用户点了没反应会重复点击造成两个请求都到了后端。3.5 部署上线从本地到公网访问开发完成后要部署到服务器。我的服务器是 Linux Nginx Gunicorn。Flask 自带的开发服务器只能用在开发和调试不能直接部署公网。Gunicorn 是 Python 下的 WSGI 服务器可以启动多个 worker 进程。部署步骤大致如下把项目代码传到服务器安装 Python 依赖安装 Gunicornpip install gunicorn启动服务gunicorn -w 4 -b 0.0.0.0:5000 app:appNginx 配置反向代理把域名转发到 5000 端口配置 HTTPS 证书我用的是免费证书在微信公众平台后台配置 request 合法域名一个关键点微信小程序生产环境必须使用 HTTPS并且要在小程序后台把域名加入白名单。如果没加真机调试时会报request:fail或url not in domain list。开发时可以在开发者工具里勾选“不校验合法域名”但发布版本不行必须配置真实域名。另外数据库要用 MySQL 而不是 SQLite因为 SQLite 在并发写入时会有锁等待跑量之后性能明显下降。SQLAlchemy 连接 MySQL 要装pymysql连接字符串类似mysqlpymysql://user:passwordlocalhost:3306/campus_runner?charsetutf8mb4部署后别忘了设置app.config[SQLALCHEMY_ENGINE_OPTIONS]里的pool_size和pool_recycle释放空闲连接避免长时间运行后数据库连接断掉。4. 常见问题与排查技巧实录4.1 微信小程序 request 合法域名和局域网调试问题开发阶段最折磨人的就是请求网络失败。早期我在本机跑 Flask小程序用开发者工具访问http://localhost:5000是可以的需要在开发者工具里勾选“不校验合法域名”。但一旦要在手机真机上预览localhost就成了手机自己必须换成电脑的局域网 IP比如http://192.168.1.105:5000。同时手机和电脑要连同一个 WiFiWindows 防火墙要放行 5000 端口。这个方法只适合临时调试。因为微信真机预览请求的是 HTTP 协议有些 Android 机型会默认禁止 HTTP 明文请求还需要打开调试模式但实际上最干净的方案还是尽早部署到 HTTPS 服务器用真实的域名调试。4.2 Flask 跨域请求报错的排查我在用浏览器调试 H5 页面时遇到过典型的跨域报错CORS policy: No Access-Control-Allow-Origin header。明明已经装了 Flask-CORS还是会报错。原因是app.route的方法里如果抛出了异常Flask 默认返回的响应不会带 CORS 头浏览器就会拦截。解决办法是配置 global CORS 并加上send_wrapper来处理异常响应或者统一捕获异常。更简单的方案在CORS(app)基础上把app.errorhandler(Exception)里也返回带 CORS 头的响应。我最后是给所有响应对象添加了Access-Control-Allow-Origin: *才彻底解决虽然粗暴但开发阶段很好用。4.3 并发接单场景的数据库锁冲突上面提到的条件更新方法能解决并发超卖但还有一个陷阱在事务中执行条件更新时如果操作不当会触发数据库死锁。比如result Task.query.filter_by(idtask_id, status0).update(...) db.session.commit()这看起来没问题。但如果你在同一个请求里先查询了该任务task db.session.get(Task, task_id) ... db.session.commit()查询操作拿的是共享锁而更新需要排它锁在并发情况下数据库会等待。解决方式在查询后用with_for_update()锁定行或者直接不用查询只做条件更新。我的经验是简单业务场景直接使用条件更新即可不要去先查后改逻辑越直接越安全。4.4 文件上传任务配图与头像跑腿任务最好有图片凭证比如代取快递拍的货架照片、送达后的交接图。小程序上传文件要用wx.uploadFile这个方法不是走request.js封装的因为它是 multipart 上传。后端需要额外工程app.route(/api/upload, methods[POST]) login_required def upload_file(): file request.files.get(file) if not file: return api_response(1, 未选择文件) filename secure_filename(file.filename) save_path os.path.join(app.config[UPLOAD_FOLDER], filename) file.save(save_path) return api_response(0, 上传成功, {url: /static/uploads/ filename})注意secure_filename要导入不然有路径穿越风险。正式环境建议把文件传到云存储比如阿里云 OSS 或腾讯云 COS避免本地磁盘无限增长。上传图片时建议前端先压缩用uni.compressImage把大图压到 500KB 内再上传不然数据库和服务器压力都很大。4.5 登录态过期与 token 刷新的小坑因为我的 token 有效期设了 7 天用户在小程序里短时间基本不会过期但还是会遇到一种情况用户长时间挂在小程序后台再打开时 token 过期。此时所有请求都会 401前端统一跳转登录页。这个处理看起来合理但用户体验不好。改进方案微信小程序每次启动时会触发onLaunch在那里静默调用wx.login换取新 token同时用旧 token 调一个“刷新”接口。但要注意刷新接口要校验旧 token 是否过期过期时长不超过 1 小时才允许换新这是安全策略。我在项目里用了一个更简单的方案token 有效期设为 30 天并且每次请求成功时在本地刷新存储时间到期前 3 天自动重新登录这样基本不会打断使用。4.6 真机调试时定位问题的技巧小程序真机调试比浏览器调试困难因为看不到 network 面板。我的经验是在request.js的成功和失败回调里统一打印日志例如console.log(request, path, -, res.data)这样真机调试时打开 vConsole就能看到控制台输出。还有一个技巧用微信开发者工具的“真机调试 2.0”功能可以在电脑上看到真机的手机屏幕和 Console 日志非常方便排查问题。如果遇到偶发性的网络超时先检查后端是不是有大量耗时操作比如同步调用外部 HTTP 请求用线程池优化一下。5. 项目的可扩展方向与实际经验这个系统跑通之后我周围很多同学也提了新需求。比如接单者位置共享通过wx.getLocation获取经纬度在前端显示配送轨迹可以做成“拿货中”“配送中”的子状态支付闭环目前悬赏金额是虚拟积分若对接微信支付需要开通商户号并处理异步回调逻辑会复杂很多消息订阅微信小程序的订阅消息可以推送任务进度提醒需要后端调用订阅消息接口信用与申诉机制被评价差评的用户进入公示或限制接单需要有管理后台审核我在实际运营中还发现一个特别值得优化的点任务大厅的排序策略。一开始按发布时间倒序结果悬赏高的任务永远排在前面低悬赏的代拿外卖任务没人接。改成“按悬赏金额排序 距离加权”之后接单效率明显提升。你可以用简单的得分公式score (reward * 0.7) (distance / 1000 * 0.3)或者直接用经纬度计算距离再结合悬赏排序。踩过几次坑之后我的体会是这种校园跑腿项目的难点并不在技术而在业务规则的严谨性和边界情况的处理。比如接单条件更新、发布者不能接自己任务、超时释放任务、信用分等级制度这些都直接影响真实用户能不能用得放心。把状态机画清楚把条件更新用熟一个“小而全”的项目就能跑得很稳。最后再分享一个小技巧开发微信小程序时尽量把 API 的返回结构统一成{code, msg, data}所有异常都走同一个 JSON 格式。这样不管是后端调试还是前端联调都能尽早暴露问题而不是一会儿返回字符串、一会儿返回 JSON前端解析逻辑写到怀疑人生。这个结构后面加日志、做监控也方便算是非常值得一开始就定好的约定。
返回列表