最近刚好把手头这套跨校区班车乘车预约系统从头到尾做完了,前端用 uniapp 开发、打包成微信小程序上线,后端用 Python 提供接口,数据库走 MySQL,从需求确认到正式运营大概花了三周时间。这篇文章不聊虚的,直接把整个项目从设计思路、数据模型、接口实现到小程序适配、部署上线,以及我实际踩过的坑,完整梳理一遍。如果你也准备做类似的预约类小程序,或者正在纠结 Python 后端怎么配 uniapp 前端,这篇文章应该能帮你省掉不少试错时间。
先交代一下项目背景。学校有三个校区,每天往返校区的师生很多,班车固定时间发车,以前靠线下签字或者微信群接龙,经常出现超员、漏人、统计混乱的问题。这套系统的核心就是解决“谁预约了哪一趟班车、还剩多少座位、临时取消怎么办”这三个问题。整体架构非常直白:uniapp 构建前端页面,微信小程序作为用户入口,Python 写后端接口,MySQL 存业务数据。下面按实际开发顺序展开。
1. 需求拆解与整体设计思路
1.1 跨校区班车这件事,真正难在哪
表面上看,班车预约就是一个简单的“选班次、点预约、确认出行”流程,但真做起来会发现几个容易被忽略的难点。
第一是“班次”不是简单的一条记录,它需要关联路线、发车时间、座位数、运行日期。跨校区班车通常每天有好几个固定时间点,周末和节假日还可能停运,所以设计时不能只写一个“几点发车”的字段,而要把班次理解成“某条路线在某天的某个时间点的一趟车”,这决定了后面数据表的拆分方式。
第二是预约的冲突控制。一个班次可能只有 50 个座位,同一时间有几百个人在点预约,如果后端不做并发保护,很容易出现“超卖”问题,也就是最终预约成功的人数超过了实际座位数。这个我在 2.2 节里会专门讲,是整套系统的核心。
第三是取消和占座的状态管理。用户预约成功后可能临时有事取消,取消之后座位要释放给后面排队的人;管理员也可能临时加车、停运。这些状态变化如果不用一个明确的状态机去约束,代码很快就会写得乱七八糟。
第四是身份识别。校内班车一般只对教职工和学生开放,所以不能做成完全匿名预约,必须和微信登录绑定,拿到用户的 openid 和手机号,后续管理员才能知道“谁预约了”。
把这些需求列清楚之后,整个系统的功能模块就非常明确了:用户登录、班次查询、座位预约、取消预约、我的行程、管理员后台。前端四个页面,后端六七个接口,复杂度其实是可控的。
1.2 技术选型:为什么是 Python + uniapp + 微信小程序
技术选型是很多刚起步的人最容易纠结的地方,我直接说结论。
后端选 Python,理由很简单:开发效率高,生态成熟,招聘也好找人。具体框架我用的是 Flask,因为它对这类中小型项目的自由度更高,路由写起来直白,配合 SQLAlchemy 做 ORM 操作数据库非常顺手。当然你用 Django 或 FastAPI 也完全可以,核心逻辑不会变。我用 Flask 的习惯是目录尽量简单:app.py、models.py、views.py,一个项目不要太早引入复杂分层,否则前期推进会很慢。
前端选 uniapp,最大的优势是一套代码可以同时编译到微信小程序、App、H5。虽然这套系统当前只上微信小程序,但后续如果要出教师端 App 或者网页版,不需要重写业务逻辑。uniapp 本身基于 Vue 语法,写过 Vue 的人上手几乎没有成本,它对微信小程序的兼容处理也比较成熟,比如条件编译、manifest 配置都有对应方案。
微信小程序则是最合适的用户入口。跨校区班车预约的目标用户本来就是校内的师生,微信小程序不需要下载安装、点开就能用,分享也方便。小程序内部支持获取微信手机号,直接把登录、身份绑定一步做完,比传统的账号密码注册体验好太多。
这里想多说一句,很多人在考虑“是用原生微信小程序还是 uniapp”时容易纠结。如果你只做微信小程序、团队又熟悉原生开发,原生当然可以;但如果后续想覆盖多端,或者你的前端团队更熟悉 Vue,那直接上 uniapp,别犹豫。项目开发周期短,团队没有专职小程序工程师,这种场景下 uniapp 的性价比非常明显。
至于数据库,MySQL 足够,量级完全不用担心,预约数据撑死就是每天几百条。部署的时候用一台 2 核 4G 的云服务器就能跑得很稳,后端用 Gunicorn 起 Flask,前面挂上域名和 HTTPS,小程序里面要求所有请求域名必须配置在后台,并且必须是 HTTPS,这一点后面部署章节会详细说。
2. 后端核心设计与数据模型
2.1 数据表怎么建才够用
数据模型是整个项目的骨架,我第一版设计吃了不少亏,这里直接给出最终用得顺手的四张表结构,你照着建基本不会走弯路。
第一张是用户表user。字段包含自增主键id、微信小程序端唯一标识openid、用户手机号phone、姓名name、身份角色role(学生/教职工/管理员)、创建时间create_time。openid必须加唯一索引,这是用户和微信绑定的关键。
第二张是班车信息表bus。字段包含id、班车名称name、路线起点start_point、路线终点end_point、准载人数capacity、描述description、是否启用is_active。这里的“班车”是一个静态概念,比如“校区一至校区二班车”,不包含具体发车时间。
第三张是班次表schedule。字段包含id、关联bus_id、发车时间departure_time、到达时间arrival_time、运行日期run_date、剩余座位remaining_seats、状态status。为什么要把班次单独拆出来?因为同一辆车在不同的日期和时间点发车,其实就是不同的班次,拆开之后查询“今天有哪些班次”就非常简单了。
第四张是预约表reservation。字段包含id、关联user_id、关联schedule_id、预约状态status(已预约/已取消/已完成)、创建时间create_time、乘车日期ride_date。这里关键是给(user_id, schedule_id)加唯一联合索引,保证同一个用户同一天同一班车只能预约一次。
四张表的关系很清晰:用户和班次通过预约表关联,班次归属到班车。这套模型不仅能支撑班车预约,稍微改一改字段也能用到会议室预约、实验室预约、场地预约,通用性很强。
2.2 预约核心逻辑:并发与冲突处理
这是全项目最需要想清楚的一块。用户点击“预约”按钮后,前端发请求到后端,后端要做的事不是简单插入一条预约记录,而是要保证:同一用户同一班次不能重复预约、座位不能被超卖、取消时座位能正确释放。
先看代码逻辑,我用一个加锁的方式来做:
from flask import jsonify, request from models import db, Schedule, Reservation from redis import Redis redis_client = Redis.from_url('redis://localhost:6379/0') @app.route('/api/reserve', methods=['POST']) def reserve(): data = request.get_json() user_id = data.get('user_id') schedule_id = data.get('schedule_id') # 先检查是否已经预约过 exist = Reservation.query.filter_by( user_id=user_id, schedule_id=schedule_id, status='booked' ).first() if exist: return jsonify({'code': 1, 'msg': '您已经预约过这个班次'}) # 加锁处理座位扣减,防止并发超卖 lock_key = f'lock:schedule:{schedule_id}' with redis_client.lock(lock_key, timeout=10): schedule = db.session.get(Schedule, schedule_id) if not schedule: return jsonify({'code': 1, 'msg': '班次不存在'}) if schedule.status != 'open': return jsonify({'code': 1, 'msg': '当前班次不可预约'}) if schedule.remaining_seats <= 0: return jsonify({'code': 1, 'msg': '座位已满'}) schedule.remaining_seats -= 1 reservation = Reservation( user_id=user_id, schedule_id=schedule_id, status='booked' ) db.session.add(reservation) db.session.commit() return jsonify({'code': 0, 'msg': '预约成功'})为什么加锁?因为 MySQL 的普通表在这类场景下做不到细粒度并发控制,两个请求同时读到剩余座位是 1,同时执行减一,最后两个人都提示预约成功,但座位只剩 0 个,这就是超卖。我在本地测试时直接用 JMeter 模拟了 100 个并发请求,不加锁的情况下能跑出 17 个超卖记录,加了 Redis 锁之后稳定在零超卖。
这里用 Redis 锁而不是数据库锁,是为了避免事务持有时间过长、拖慢整体吞吐。如果是更复杂的场景,还可以用数据库行锁配合事务,但对这个体量的项目,Redis 锁已经足够清晰好用了。
取消预约的逻辑就是对偶操作:把状态改成cancelled,同时把remaining_seats加一。这里要注意加锁和扣回的顺序,一定要在锁内完成“改状态 + 回补座位”两步操作,否则还是会有并发问题。
2.3 接口设计示例
后端接口我按业务拆了七个,全部返回 JSON,前端直接对接,非常省事:
| 接口 | 方法 | 功能 |
|---|---|---|
/api/login | POST | 接收微信code,返回用户信息和 token |
/api/buses | GET | 获取所有可用班车路线 |
/api/schedules?date=... | GET | 按日期获取班次列表 |
/api/reserve | POST | 预约班次 |
/api/reservation/list | GET | 获取我的预约记录 |
/api/reservation/cancel | POST | 取消预约 |
/api/admin/schedule/add | POST | 管理员添加班次 |
以查询班次列表为例,核心就是一段简单的查询:
@app.route('/api/schedules', methods=['GET']) def get_schedules(): date = request.args.get('date') schedules = Schedule.query.filter_by(run_date=date).all() result = [] for s in schedules: bus = db.session.get(Bus, s.bus_id) result.append({ 'schedule_id': s.id, 'route': f'{bus.start_point} → {bus.end_point}', 'departure_time': s.departure_time.strftime('%H:%M'), 'arrival_time': s.arrival_time.strftime('%H:%M'), 'remaining_seats': s.remaining_seats, 'total_seats': bus.capacity }) return jsonify({'code': 0, 'data': result})这里建议给前端返回剩余座位和总座位数,前端就能直接在列表里展示“还剩多少座”,用户心里有个预期。另外,前端查询班次时一定要把日期作为参数传过来,只查当天的班次,避免一次拉一个月的数据。
3. uniapp 前端开发与微信小程序适配
3.1 页面结构:班车列表、预约表单、我的行程
uniapp 端的页面我拆成了四个 Tab,分别是“班车”、“预约”、“我的”和“管理”。其实“预约”和“班车”可以在同一个页面完成,但考虑到小程序交互简单直接的特性,我选择把“选择班次”和“确认预约”拆成两个步骤,用户不容易操作失误。
首页“班车”逻辑很简单,进入页面时拿到当前日期,请求后端/api/schedules,用卡片列表展示每个班次,卡片上写清楚起点终点、发车时间、剩余座位数。座位数小于等于 5 时强调显示“余座紧张”。点击卡片后跳转到预约确认页,确认页再显示班次详情和用户信息,最后点“确认预约”按钮调接口。
“我的”页面展示当前用户的所有预约记录,按日期倒序排列。每条记录有班次信息、乘车日期、状态标签,已经过去的行程显示“已完成”,还没出行的显示“已预约”,这时候旁边放一个“取消预约”按钮。这里要注意,取消预约只允许在发车前至少半小时内操作,后端也要校验,不能只在前端藏按钮,否则用户改个时间就能绕过限制。
管理员的“管理”页面我单独做了一组接口和页面,用来添加班次、调整座位数、停运某一天的车。这个部分不需要太复杂,能增删改查班次就够了。
3.2 微信登录与手机号授权
微信小程序的用户身份体系跟普通网页完全不同,核心就是openid。用户打开小程序时,前端通过uni.login拿到临时code,把这个code发给后端,后端调用微信的code2Session接口,换回用户的openid。有了openid,就能识别唯一用户,不需要用户名密码。
具体的前端代码如下:
uni.login({ provider: 'weixin', success: (loginRes) => { uni.request({ url: 'https://api.example.com/api/login', method: 'POST', data: { code: loginRes.code }, success: (res) => { const { token, user } = res.data.data; uni.setStorageSync('token', token); uni.setStorageSync('userInfo', user); } }); } });后端的code2Session调用需要用到小程序的appid和secret,这两个值在小程序后台可以查到,注意secret千万不要写进前端代码,只在服务端使用。
手机号授权则是另一个逻辑。小程序的手机号不能直接通过接口读取,必须让用户点击一个“获取手机号”按钮,在按钮的open-type="getPhoneNumber"事件回调里拿到code,再拿这个code去后端换手机号明文。前端代码大概是:
<button open-type="getPhoneNumber" @getphonenumber="getPhoneNumber">绑定手机号</button>getPhoneNumber(e) { if (e.detail.code) { uni.request({ url: 'https://api.example.com/api/phone', method: 'POST', data: { code: e.detail.code }, success: (res) => { // 保存手机号 } }); } }手机号授权这里有个坑,个人主体的小程序是不支持获取用户手机号的,必须是企业主体、并且在小程序后台开通相关能力才行。如果你只是个人开发者测试,可以用一个模拟手机号输入的页面代替,等主体资质下来再换正式逻辑。
3.3 打包配置与 2MB 上限处理
uniapp 开发完以后要编译成微信小程序,打开 HBuilderX,点“运行到小程序模拟器”,就会自动生成dist/dev/mp-weixin目录,然后用微信开发者工具打开这个目录,就能看到小程序效果。
真正上线前有几个配置必须手动处理。第一个是manifest.json,要填小程序的appid,并且配置权限声明。第二个是微信小程序后台的 request 合法域名,必须把你后端的 HTTPS 域名填进去,否则真机调试时所有请求都会报url not in domain list。第三个是版本号管理和上传代码,在微信开发者工具里点“上传”,填好版本号和备注,再到后台提交审核。
最常见的问题就是打包体积超过 2MB。uniapp 项目如果图片资源多、引用了大量第三方库,编译出来的包很容易超限。我项目的解决办法是:所有图片资源从本地静态文件改成网络图,页面启动后再加载;全局样式压缩精简;如果还是超,就做分包处理,把“管理后台”相关页面单独拆到一个 subpackage 里。微信小程序的主包大小不能超过 2MB,但整个小程序总大小上限是 20MB,分包能很好地解决这个问题。
还有一个容易被忽略的点:uniapp 在开发时默认不会把所有 console.log 打到微信开发者工具的调试台里。如果你发现console.log看不到输出,可以在main.js里统一配置一下调试开关。网上说“uniapp 不打印日志信息”,其实就是这个原因,不是代码没执行,而是日志级别被压缩了。
4. 从本地联调到正式部署
4.1 本地开发调试环境
开发环境的搭建其实很简单。后端本地跑 Flask,默认在127.0.0.1:5000启动,前端在 HBuilderX 里运行到微信开发者工具,然后你需要解决一个关键问题:小程序真机预览时不能访问localhost,必须用电脑的局域网 IP。
在微信开发者工具里,可以勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”,这样本地开发时可以直接请求http://127.0.0.1:5000,但如果要用手机预览,就得把 baseURL 改成电脑的局域网 IP,手机和电脑连同一个 Wi-Fi 才能访问。
我踩过一次很尴尬的坑:后端跑在电脑上,手机预览小程序,所有接口都超时,排查了半天才发现是 Windows 防火墙把 5000 端口挡了。所以本地调试时,记得在防火墙里放行 Python 进程,或者临时关掉防火墙测试。
数据库我用的是本机 MySQL,创建数据库后,直接用 SQLAlchemy 的create_all()建表,几秒钟就完成,不需要手工一条条写建表语句。
4.2 部署上线与备案注意事项
正式上线流程其实比很多人想象的要琐碎。你需要买一台云服务器,装好 Nginx、MySQL、Redis,把 Flask 项目用 Gunicorn 跑起来,进程管理可以用 systemd 或 Supervisor,然后配置 HTTPS 证书。
Nginx 配置里把/api/路径代理到 Flask 的 5000 端口即可,核心配置类似:
server { listen 443 ssl; server_name api.example.com; ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }HTTPS 证书现在可以用免费版,申请后配置到 Nginx 即可。这里必须强调,微信小程序的request合法域名只支持 HTTPS,且域名不能带端口,所以一定要用 443 端口,走 Nginx 代理。
另外一个小程序上线前提是企业主体认证。未认证的小程序最多只能用测试版,很多接口也不开放,尤其是我前面提到的获取手机号功能。认证需要提交营业执照等材料,正常流程一到三个工作日可以完成,认证费用是作为年审费收取的,这里提前规划好预算和时间。
5. 常见问题排查与避坑实录
5.1 高频问题排查表
整个开发过程中,我把团队和测试人员反馈最多的问题整理成了一张表,排查效率非常高:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
小程序请求接口报url not in domain list | 请求域名未配置到小程序后台 | 在小程序后台配置 request 合法域名 |
| 真机预览接口超时 | 使用 localhost、未开防火墙端口 | baseURL 改成局域网 IP,放行防火墙端口 |
| 手机号获取失败 | 个人主体不支持、未开通接口权限 | 企业主体认证,开通能力 |
| 打包上传超 2MB | 图片资源过大、主包塞了太多页面 | 图片转网络图,使用分包 |
| 并发预约出现超卖 | 未加锁、座位扣减无一致保护 | 加 Redis 锁,扣减和插入同一事务 |
| 同一个用户重复预约 | 前端没有做校验、后端没有唯一索引 | 加联合唯一索引,后端做存在性检查 |
| console.log 看不到输出 | uniapp 关闭了日志打印 | 按环境配置调试开关 |
这张表基本就是我的项目排障手册,测试人员提一个 bug,我先对号入座看一眼,十有八九能直接定位。
5.2 几个值得留意的业务细节
第一,班次状态一定要有“停运”这个分支。我刚开始只设计了“可预约”和“已满”,结果遇上一次临时检修,车走不了,但系统还在卖票,最后只能手动改数据库,非常狼狈。后来加了status字段,停运的班次前端直接置灰,用户点不进去。
第二,取消预约的时间限制不能只靠前端。微信小程序的前端代码是可以被逆向的,所以像“发车前 30 分钟不可取消”这种规则必须后端强制校验,前端只是体验上的提示。别问我怎么知道,我就是因为只在前端做了限制,被一个学生用抓包工具绕过去,把已经发车的预约取消掉了。
第三,班次座位数不能写死成班车的总座位数。同一辆大巴,工作日可能跑两趟,考虑驾驶员和车辆调度,每一趟的可预约座位数可能不一样,所以remaining_seats的初始值应该来自班次的配置,而不是bus.capacity。这个字段放在schedule表里更合理,我第一版放在bus表里,后来业务调整时改了一轮,教训很深刻。
6. 最后的一些个人心得
这套系统开发完以后,最大的感悟是:项目前期的数据模型设计,决定了后面所有环节的顺畅程度。我前前后后改了三版数据库,每一次改动都引发接口、前端、测试文档的连锁调整,如果一开始就把班车、班次、预约的关系拆干净,至少能省出两三天。
另外想提醒的是,小程序审核时对“预约类”业务的权限审核比较严格,尤其是涉及收集用户手机号的功能,一定要在隐私协议里写清楚用途,否则容易被驳回。提交审核前,把测试账号、演示流程准备好,审核人员要求体验时能快速走通全流程,通过率会高很多。
如果你也准备做类似的项目,我的建议是先把手头的需求列成一张表,搞清楚哪些是核心、哪些是附加,然后尽快把数据模型定下来,再进入编码阶段。这套班车预约系统后续其实还能扩展出很多方向,比如按站点动态调度、余座微信推送提醒、司机端的排班打卡,甚至和现有的一卡通系统打通。只要基础架构不出问题,这些功能都是往上叠加的事。