2023年之后再看这个项目名字,多少带点时间印记。但把“Python_uniapp-新冠疫苗预约小程序”拆开看,它本质上是一个特别典型的预约业务系统:Python 提供接口,uniapp 搭小程序前端,用户登录、选择时间场次、锁定名额、生成预约码,到线下核销,整个闭环走一遍。我复盘这个项目,不只是因为它把小程序开发到上线的链路走得很完整,更重要的是这套骨架可以直接复用到体检预约、场馆预约、活动报名这些场景里。适合刚学完 Python 基础、想拿一个完整项目练手的朋友,也适合已经写过小程序、但没正经联调过后端接口的前端同学。全文没有多高深的东西,但很多细节是文档里不会写的,我把能交代的坑都交代一遍。
1. 项目拆解与总体方案设计
1.1 这个预约小程序到底在解决什么问题
预约类业务看着简单,实际要处理的边界比想象中多。用户打开小程序要能看到哪些服务点可用、哪些时间场次还有余量,选完时间后提交预约,后台要生成一个唯一预约码,到现场由工作人员扫码或输入编号核销。整个流程里有几个点特别容易翻车:一是多人同时抢最后一个名额时会不会超卖;二是用户约了又不来,名额怎么释放;三是用户在微信里授权登录、拿手机号,这步权限和流程处理不好,审核都过不了。
在这个项目里,“疫苗”只是业务名称,代码层面其实就是一个 AppointMent 模型加一组状态流转。我习惯先把业务对象抽象成“服务点 + 场次 + 预约单”,这样后面换任何预约场景都不用大改。
1.2 后端接口为什么用 Python 而不是 Java、Node
选型阶段有过争论。Java 生态里 Spring Boot 很成熟,但团队里几个人对 Python 更熟,而且这类预约系统的并发量根本没到需要 Java 那套重型框架的地步。Python 在这一层有两个优势:开发效率极高,写接口、连数据库、做定时任务都很快;生态里有现成的 Flask、FastAPI、SQLAlchemy,十几分钟就能把工程骨架拉起来。
Flask 和 FastAPI 之间我选了 Flask。原因是预约项目要写的接口不多,Flask 的灵活度足够,网上资料也多,遇到问题随便一搜就有答案。FastAPI 的自动接口文档和异步性能确实更好,但如果团队没人用过,反而会增加沟通成本。这里没有绝对的对错,关键是团队能快速上手。
1.3 前端为什么用 uniapp 而不是原生微信小程序
如果只做微信小程序,原生 WXML 完全够用。但这个项目一开始就打算以后可能上支付宝小程序和抖音小程序,用 uniapp 写一套 Vue 语法,编译到不同平台,性价比就体现出来了。另外团队里有人熟 Vue 不熟小程序原生语法,uniapp 的学习成本低很多。
uniapp 打包到微信小程序时需要借助微信开发者工具,这步很多新手会卡住。后面我会专门讲打包以及 source size 超过 2MB 的解决办法。用 uniapp 的另一个好处是组件库选择多,像 uview-plus、uni-ui 都能直接拉进来用,比自己写样式省时间。
1.4 整体架构与核心数据流
架构不难,三个角色:微信小程序端、Python 后端接口、MySQL 数据库。小程序通过 uni.request 调后端接口,后端返回 JSON,前端渲染页面。用户首次打开小程序,先静默登录拿到 openid,后端生成 token 返回,之后所有请求都带 token 标识身份。
预约的核心数据流是:用户进入首页 → 查服务点和日期 → 前端展示某天的场次和余量 → 用户选定场次提交 → 后端事务内扣减场次余量并生成预约单 → 前端跳转预约成功页展示预约码 → 到现场工作人员输入编号核销 → 预约单状态改为“已核销”。这里所有对余量的修改都必须在后端完成,绝不能依赖前端传过来的剩余数字去计算,这是整个项目最核心的一条设计原则。
2. 数据库设计与预约状态机
2.1 核心表结构长什么样
数据库设计直接决定后面写接口顺不顺手。这个项目用五张核心表:用户表、服务点表、场次表、预约单表,再加上一张配置表。为了好移植,我尽量用通用字段命名。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| user | id, openid, phone, nickname, avatar, created_at | openid 唯一,手机号可为空 |
| station | id, name, address, work_start, work_end, status | 对应预约点,状态控制是否可约 |
| schedule | id, station_id, service_date, start_time, end_time, total, booked, status | 场次表,booked 是已预约数 |
| appointment | id, user_id, schedule_id, code, status, created_at, verify_at | 预约单,code 生成 6 位数字 |
| app_config | id, config_key, config_value | 放号规则、爽约次数限制等 |
场次表里的 total 和 booked 是防超卖的第一道防线。每次有人预约成功,booked 加一,等 booked 等于 total 时,这个场次就不该再被预约。这个判断不能再依赖前端灰掉按钮,因为前端可以做假数据绕过,后端必须硬校验。appointment 表里的 status 是预约状态机的地基,设计好状态,业务逻辑才会清晰。
2.2 预约状态机怎么流转
预约单不能只存一个“已预约”状态,否则取消、爽约、过期全都没有办法区分。我用五个状态:PENDING(已提交待确认)、CONFIRMED(已确认待核销)、USED(已核销)、CANCELLED(已取消)、EXPIRED(已过期)。
PENDING 一般不做强制确认,因为这种预约场景不像拼团要审核,所以提交后直接置为 CONFIRMED 可以省一个步骤。但如果对接了人工审核流程,就必须保留 PENDING。CONFIRMED 状态在核销后变 USED,用户主动取消时变 CANCELLED,超过预约时间还没核销则由定时任务把状态置为 EXPIRED。这个状态机其实很老套,但好处是每个状态职责单一,后面查数据、做统计都能轻松按状态分组。
还有一个细节:用户在 CONFIRMED 状态下取消预约,场次余量必须回补,否则名额就白白浪费了。回补操作要和状态变更放在同一个事务里,先更新预约单状态,再把 schedule.booked 减一。
2.3 接口清单与参数约定
接口设计我遵循一个原则:资源路径用名词,动作靠 HTTP 方法表达,操作文档统一写清楚参数和返回结构。这个项目里接口不多,列出来基本就是:登录、获取手机号、服务点列表、场次列表、创建预约、我的预约、取消预约、核销预约。
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /api/auth/login | 用 code 换 token |
| POST | /api/auth/phone | 用手机号授权 code 换手机号 |
| GET | /api/station/list | 获取预约点列表 |
| GET | /api/schedule/list?station_id&date | 查某天某点的场次余量 |
| POST | /api/appointment/create | 创建预约单 |
| GET | /api/appointment/mine | 查当前用户预约记录 |
| POST | /api/appointment/cancel | 取消预约并回补余量 |
| POST | /api/appointment/verify | 现场核销,输入或扫码 |
所有接口的返回结构我都统一成{ code: 0, msg: "ok", data: {...} },前端封装好 request 之后,只需要判断 code 就能处理绝大多数情况。千万别一会儿返回data一会儿返回result,联调的时候会疯掉。
3. Python 后端核心功能实现
3.1 工程骨架与运行准备
我用的依赖是 Flask + Flask-SQLAlchemy + Flask-CORS + PyMySQL + APScheduler。安装命令很简单,但有个坑:MySQL 的驱动必须装 PyMySQL,直接用mysqldb在 Python3 环境会报编码问题。数据库连接串要显式加上charset=utf8mb4,不然前端拿到中文容易乱码。
pip install flask flask-sqlalchemy flask-cors pymysql apscheduler工程目录不用搞太复杂,model、api、service、task 四个目录就够了。项目刚起步时如果设计过度,每写一个接口都要在十几个文件里来回跳,反而拖慢进度。我的习惯是先让接口跑通,再按模块抽离公共逻辑。
from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_cors import CORS app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = "mysql+pymysql://root:password@127.0.0.1/appointment?charset=utf8mb4" db = SQLAlchemy(app) CORS(app)这段代码里CORS(app)很多人会漏。本地方便做联调,但上线后如果接口和小程序都在腾讯云体系内,也可以把跨域关掉,改用微信小程序后台配置的 request 合法域名。CORS 开不开影响不大,开发阶段开着能省很多莫名其妙的“请求失败”问题。
3.2 微信登录与手机号授权
微信小程序的登录不是传统用户名密码,核心是wx.login()拿一个临时 code,后端拿 code 去微信接口换 openid 和 session_key。这个 code 只能用一次,有效期五分钟,所以拿到后必须立刻处理。
def wx_login(code): url = "https://api.weixin.qq.com/sns/jscode2session" params = { "appid": app.config["WX_APPID"], "secret": app.config["WX_SECRET"], "js_code": code, "grant_type": "authorization_code" } resp = requests.get(url, params=params).json() openid = resp.get("openid") # 查表或创建用户 user = User.query.filter_by(openid=openid).first() if not user: user = User(openid=openid) db.session.add(user) db.session.commit() return generate_token(user.id)手机号获取要注意:2023 年后微信收紧了这个能力,小程序必须认证,还得在“小程序管理后台-设置-服务内容声明”里申请手机号快速验证组件。前端用<button open-type="getPhoneNumber">拿到一个动态令牌,把令牌传给后端,后端调微信接口换手机号。这个过程里,手机号属于敏感信息,不能打进日志,也不能随便存到第三方数据库,能加密最好。
3.3 预约名额扣减:并发不超卖的关键
这是全项目最需要动脑的地方。最自然的写法是先查一下余量,再决定能不能预约,很多人会写成这样:
schedule = Schedule.query.get(schedule_id) if schedule.booked < schedule.total: schedule.booked += 1 db.session.commit()单用户测试没问题,但两个用户同时操作,都查到 booked 小于 total,都走到booked += 1,就可能超卖。解决思路是对同一行数据加锁,或者用一条 SQL 完成“判断 + 更新”。
schedule = Schedule.query.filter_by(id=schedule_id, status="open").with_for_update().first() if schedule and schedule.booked < schedule.total: appointment = Appointment( user_id=user_id, schedule_id=schedule_id, code=generate_code(), status="CONFIRMED" ) db.session.add(appointment) schedule.booked += 1 db.session.commit()我实际更推荐用一条 SQL 做原子条件更新:
result = db.session.execute( text("UPDATE schedule SET booked = booked + 1 " "WHERE id = :id AND booked < total"), {"id": schedule_id} ) if result.rowcount == 1: # 创建预约单 passrowcount == 1表示确实把余量加成功了,说明名额抢到了;如果等于 0,说明场次已满,直接告诉用户“手慢了”。这种写法不依赖数据库事务隔离级别,行锁时间短,性能也够用。预约码建议用随机 6 位数字,重复概率不高,但可以在 appointment 表里加唯一索引兜底。
3.4 定时任务与超时释放
预约系统离不开定时任务。我用 APScheduler 做三个任务:每天凌晨创建未来 14 天的场次、每隔 5 分钟清理超时未确认的预约单、每天标记过期预约。
scheduler = BackgroundScheduler() scheduler.add_job(generate_schedule, 'cron', hour=0, minute=5) scheduler.add_job(expire_appointments, 'interval', minutes=5) scheduler.start()定时任务最大的坑不是逻辑,而是重复执行。如果项目跑在多进程或多实例下,每个进程都会启动一个 scheduler,放号的定时任务就会被执行多次。最简单的方案是加一个全局配置表锁,任务执行前先查询今天的场次是否已生成,已生成就直接跳过。这种幂等设计看起来不起眼,但真上线后能省掉很多运维事故。
4. uniapp 前端实现与微信打包
4.1 创建支持 TypeScript 的 uniapp 项目
uniapp 创建项目有两种常见方式。习惯用 HBuilderX 的话,新建项目时可以直接勾选“使用 TypeScript”,会自动生成 main.ts 和 tsconfig.json;如果习惯命令行,可以用 Vite 创建的 uniapp 项目模板。
npx degit dcloudio/uni-preset-vue#vite my-projectTypeScript 在这类项目里的价值不是写出多复杂的类型体操,而是约束接口数据结构。我会把后端的返回结果定义成 interface,比如AppointmentResult、ScheduleResult,这样前端联调时能少很多低级错误。不过也要注意,uniapp 的 TS 环境偶尔会有类型报错,常见的坑是uni.getSystemInfoSync()返回类型里的字段在 TS 下没有完整定义,遇到这种情况可以直接断言成any,没必要为了类型折腾半天。
4.2 登录、手机号与动态设置标题
前端登录逻辑比较简单,先静默登录拿到 code,再传给后端接口换 token,token 存到uni.setStorageSync。之后每次请求都在 header 里带上 token。
uni.login({ provider: 'weixin', success: async (loginRes) => { const res = await request.post('/api/auth/login', { code: loginRes.code }) uni.setStorageSync('token', res.data.token) } })手机号授权用的是<button open-type="getPhoneNumber" @getphonenumber="getPhone">。回调里拿到的e.detail.code就是动态令牌,把它交给后端换手机号。这里要注意,如果e.detail.errMsg包含fail,说明用户拒绝了授权,不能继续处理,要给出友好提示。
动态设置标题用一行代码:
uni.setNavigationBarTitle({ title: '选择接种点' })这个能力在页面配置了自定义导航栏时无效,需要配合页面的navigationStyle: custom自行渲染导航栏,否则标题会重叠到状态栏上。
4.3 列表分页加载与下拉刷新
小程序列表页最容易犯的错误是把所有数据一次性渲染出来,数据一多,页面卡成 PPT。我做了一个通用分页逻辑:page 从 1 开始,每页 10 条,滚动到底部时自动加载下一页;下拉时重新从第一页拉。
onReachBottom() { if (this.hasMore && !this.loading) { this.page += 1 this.loadSchedule() } }hasMore的判断依据是后端返回的数据条数是否等于 pageSize,如果小于 pageSize,说明没有更多数据,把hasMore置成 false。加载时要加 loading 状态,防止用户在请求过程中反复触底,导致重复请求同一页。这是我实际开发里遇到过最多次的体验问题。
下拉刷新则通过页面的onPullDownRefresh钩子实现。请求完成后必须调用uni.stopPullDownRefresh(),否则 loading 动画一直转,用户会觉得页面卡死了。
4.4 打包超过 2MB 的解决方法
微信小程序主包大小限制是 2MB,但如果开了分包,每个分包独立限制 2MB。项目里只要有图片资源、图表组件、第三方库稍微多一点,很容易在打包时报source size 2612kb exceed max limit 2mb。
我的处理顺序是:先查哪里有体积大头,HBuilderX 的“运行-运行到小程序-构建后输出”信息里会列出各文件体积。最常见的大头是图片,解决方法是把图片从代码里挪到 CDN 或对象存储,不要在本地包资源里放超过 50KB 的图片。如果用到了 uview-plus 这类组件库,少用按需引入,不要整个 import。最后一步是分包:把业务页面拆到pages-station、pages-appointment这类分包目录,主包只保留 tabBar 页面和公共组件。分包过后,首次加载更快,审核也更容易过。
5. 接口联调与常见问题排查
5.1 用流量转发工具查看小程序真实请求
前端调试时,后端接口报错不能只靠前端控制台那点信息判断,最好能看到真实请求和响应。Charles 和 Fiddler 这类流量查看工具就是干这个的。手机和电脑连同一个局域网,在工具里开启 SSL 转发并添加要看的域名,设置好手机端转发,就能看到小程序发出的完整请求。
不同版本的工具配置有点差别,但核心就三步:一是手机端把网络请求转发到电脑 IP 和对应端口;二是给手机装调试证书;三是在工具里启用 HTTPS 解密。这套流程只建议在你自己开发调试的环境里用,不要拿流量工具去分析别人的线上应用,既没意义也可能踩合规红线。调试完记得把手机的转发配置关掉,不然手机会一直连不上外网。
5.2 uniapp 不打印日志信息的几个原因
这个问题被问过很多次,明明代码里写了console.log,真机上却什么都看不到。常见原因有几个:一是小程序真机调试时,vConsole 没有打开,要在开发者工具里点开“调试器”再看;二是在 release 包或体验版里,微信做了日志过滤,建议用console.info或debug级别;三是有些人把try catch包住了所有请求,错误被吞掉,控制台什么都没留下。排查时先打开项目配置里的开发调试模式,再在 HBuilderX 里用“真机运行”而不是“发行”来测,日志基本就能看到了。
5.3 顶部导航栏高度适配
用自定义导航栏时,最怕顶部内容被刘海屏遮挡。微信小程序的导航栏由状态栏和导航栏标题区组成,状态栏高度可以通过uni.getSystemInfoSync().statusBarHeight获取;右侧胶囊按钮的位置可以用uni.getMenuButtonBoundingClientRect()获取。
const systemInfo = uni.getSystemInfoSync() const statusBarHeight = systemInfo.statusBarHeight || 20 const menuButton = uni.getMenuButtonBoundingClientRect()自定义导航栏内容的 top 高度,一般可以直接用胶囊按钮的 top 值对齐,这样在 iPhone 和安卓上的观感都比较和谐。如果不用自定义导航栏,就别动这个逻辑,系统默认的导航栏在大多数机型上表现都不差。
5.4 常见问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 打包超过 2MB | 本地图片/组件库过大 | 图片走 CDN、按需引入组件、子包拆分 |
| 真机请求失败 | 域名未配置、未开调试、证书问题 | 后台配 request 合法域名,开发时勾选“不校验域名” |
| 获取手机号失败 | 小程序未认证、组件次数超限 | 认证小程序,查看次数额度,改用实时验证组件 |
| 列表加载不出更多 | 页面高度不足或 hasMore 判断错误 | 检查滚动容器,改为判断返回条数 |
| 数据库中文乱码 | 连接串没带 utf8mb4 | Python 连接串显式加 charset=utf8mb4 |
| 预约时间差 8 小时 | MySQL 时区设置 | 连接串加 server_timezone=Asia/Shanghai |
这张表我基本是照着一个多月踩坑记录总结出来的,每一条都真实发生过。做类似项目时,遇到问题可以先来这查一遍,比重新搜资料快不少。
6. 复盘、复用与下一步还能做什么
6.1 从 2048 小程序到预约项目,我迁移了什么
之前交付过一个 2048 小游戏的微信小程序源码包,它不能像网页端那样直接扔到浏览器看效果,必须用微信开发者工具编译预览。这个预约项目继承了同样的交付方式,但多了一层后端联调。从纯前端项目跳到全栈项目,最大的变化不是代码量,而是思维:小游戏里所有状态都在本地,预约项目里必须考虑服务端数据的一致性,比如用户换手机号登录、在不同设备上查看预约单,状态都能同步回来。这个迁移过程对我来说比重新学一门框架更有价值。
6.2 改成其他预约场景要改什么
这套代码的可复用性很高。把“服务点”改成“体检中心”“疫苗接种点”“会议室”,把“场次”改成日期和时段,核心的表结构、接口逻辑都不用动。如果要上线,还要接上微信订阅消息,在用户预约成功或前一天提醒。后端加一个消息推送任务,前端在用户确认预约时调订阅消息授权接口,这两步都是比较标准的做法。另外可以加一个排队序号展示功能,预约码改成按时间递增的排队号,现场叫号体验更好。
6.3 最后想说的三个小建议
做完这个项目再回头看,最想提醒自己的是三件事。第一,接口约定一定要在写代码前定好,字段名、返回结构、错误码统一整理成文档,后面前后端联调至少能省一倍的沟通成本。第二,并发问题不是靠压测压出来的,是靠设计防出来的,余量扣减这种核心操作,必须在数据库层面保证原子性。第三,打包体积从第一天就要在意,等到功能写完了再优化,各种页面依赖已经缠在一起,拆包要花的力气远大于一开始就规划。如果你也在做预约类小程序,按这套骨架去改,遇到问题对着第 5 节的速查表排查,大概率能少走很多弯路。