基于Python Flask和微信小程序的个人日程弹窗提醒系统实战

基于Python Flask和微信小程序的个人日程弹窗提醒系统实战 说实话微信自带的系统日历提醒看起来够用但真正用起来你会发现一个很实际的痛点提醒方式完全是系统定的粒度粗糙数据不掌握在自己手里更别说按自己的习惯去定制提前多久提醒重复规则优先级这些东西。我也不想为一个日程管理再去装一个陌生 App——信息分散、广告还多。所以去年年底我抽了两周业余时间自己动手做了一个个人日程计划弹窗提醒系统后端用Python 写 API前端用微信小程序做交互支持日程的增删改查、到点弹窗提醒以及通过微信订阅消息做离线触达。这篇文章我会把整个项目的选型逻辑、数据库设计、后端接口、小程序页面、弹窗触发机制和踩坑记录完整拆开讲一遍适合那种会一点 Python 基础、想完整走一遍前后端实战的人参考。1. 技术选型Python 后端 小程序前端的组合逻辑1.1 后端为什么选 Python而不是 Java 或 Node做这种轻量级工具类系统第一原则是降低开发和维护成本而不是追求极致的并发性能。个人日程管理的并发量极低同一时刻可能就你自己和少数几个用户在访问所以后端框架完全没必要上 Spring Boot 这类重型框架。Python 里的 Flask 框架一个文件就能跑起一个可用的 API 服务写起来直观调试也快。这里要说一下我为什么不用 FastAPI 而用 FlaskFastAPI 的自动文档和类型检查确实很香但如果是小程序端 简单 JSON 接口这种场景Flask 的 route 写法反而更直白依赖也更少。退一步说后续真要换 FastAPI主要逻辑也就是给函数加类型标注迁移成本并不高。所以初期项目我坚持能简单就简单。另一个选 Python 的理由是定时任务生态。日程提醒系统最核心的一个后端动作是到点了去通知用户这里需要定时扫描数据库里的日程表。Python 的 APScheduler 库可以很轻量地在 Flask 进程里跑定时任务不用像 Java 那边专门引入 Quartz 或者搞消息队列。个人项目做到这个程度架构上已经够了。1.2 小程序前端的不可替代性优势同样是免安装 App微信小程序和 H5 网页最本质的区别在于小程序能直接使用微信的登录体系、订阅消息和分享能力。我可以不用自己搭一套账号注册登录流程直接通过wx.login拿到 code后端再调微信的code2Session接口换取 openid用户身份问题就解决了。对个人开发者来说这一步省掉的工作量非常可观。另外微信小程序有一个可以被利用的特性——它在用户主动进入时是可以弹窗的。所谓弹窗提醒很多人以为只能靠系统通知其实在小程序内可以通过自定义组件做出提醒弹窗效果相当于打开小程序就能看到即将到期的日程。同时配合订阅消息能做到用户没打开小程序也能收到微信里的服务通知这两者结合才是完整的提醒链路。这一点我在第四章会详细讲。1.3 弹窗提醒的三种实现路径对比在动手写代码前我先把弹窗提醒的技术方案拉出来对比了一轮。很多人一上来就纠结到底该用哪种其实就看你的使用场景和主体资质。方案实现难度能不能离线触达适合场景小程序内自定义弹窗组件低否必须打开过小程序用户正在小程序里浏览时提示即将到期的日程微信订阅消息一次性订阅中是会出现在微信服务通知里用户授权一次触发一次通知适合每次提醒都要重新授权的场景服务号模板消息 / 长期订阅高是需要服务号主体且模板消息类目受限个人主体很难拿下对我这种个人项目来说最终采用的是小程序内自定义弹窗 订阅消息混合方案用户打开小程序时本地检查是否有 15 分钟内到期的日程并弹窗同时用户每次新增日程时引导授权订阅消息到点后由后端调订阅消息接口做离线提醒。两套机制互不冲突也各解决了不同的使用场景。2. 系统架构与数据模型设计2.1 整体架构拆解整个系统从部署形态来看是经典的小程序客户端 HTTP API 数据库 定时任务四层结构。小程序端负责三件事展示日程列表、录入和修改日程、接收/展示弹窗。它不直接连数据库所有数据操作都走 HTTPS 请求到 Python 后端。后端 Flask 应用一方面提供 REST API 给小程序端调用另一方面用 APScheduler 启动一个独立的后台任务每 30 秒扫描一次数据库里的日程表把即将到期且尚未通知的日程查出来调微信订阅消息接口发给用户。数据库我用的是 SQLite。个人项目不需要单独装 MySQLSQLite 单文件、零配置Flask 配合sqlite3标准库就能跑起来。等数据量真的大到 SQLite 撑不住我估计得好几年后再切 MySQL 不迟。2.2 数据库表设计我设计了三张核心表users、plans、remind_log。听起来好像很简单但表结构的细节决定了很多后续开发是否顺畅。users表的核心字段是openid它是用户在微信生态里的唯一标识。后端通过wx.login返回的 code 去换一个 openid 对应一个用户。还有个字段nickname是从小程序端传上来的展示昵称不强制唯一。plans表是主体表核心字段有id主键自增user_id关联 users 表title日程标题content日程详情可空plan_time日程具体执行时间准确到分钟remind_before提前提醒的分钟数比如 10、30、60status0 待处理1 已完成2 已取消reminded0 未提醒1 已提醒created_at、updated_at记录创建和更新时间这里我特别加了reminded字段配合定时任务使用。定时任务每轮只会处理status0且reminded0且plan_time - remind_before 当前时间的记录处理完立刻把reminded置 1避免重复推送。很多初学者会漏掉这个字段结果就是每次进程重启或者定时任务重复执行用户会收到好几条同样的提醒。remind_log表用来记录每次提醒的推送结果方便排查用户为什么没收到通知这类问题。字段包括plan_id、send_time、response_code、response_msg把微信接口返回的原始信息存下来。2.3 API 接口规划我按 RESTful 风格规划了下面这些接口不多但足够覆盖整个业务流程方法路径功能POST/api/login小程序 code 换 openid返回自定义 tokenGET/api/plans获取当前用户的日程列表POST/api/plans新增日程PUT/api/plans/{id}更新日程DELETE/api/plans/{id}删除日程POST/api/plans/{id}/finish标记日程完成POST/api/subscribe/send触发订阅消息推送内部也可由定时任务调用登录接口做了一层封装拿到 openid 后我用itsdangerous生成一个签名 token 返回给小程序端后续请求都带上Authorization: Bearer token后端解析 token 得到用户身份。不直接把 openid 透传给前端这样就算别人抓包也不会轻易拿到用户敏感标识。3. Python 后端的具体实现3.1 Flask 项目初始化和结构项目我命名为reminder_server目录结构如下reminder_server/ ├── app.py ├── models.py ├── auth.py ├── plans.py ├── scheduler.py ├── config.py └── requirements.txtapp.py是入口负责创建 Flask 实例、注册蓝图和启动定时任务。我用了蓝图把登录、日程、订阅消息拆到不同模块这样代码逻辑不会堆在一起。config.py里集中放 SQLite 数据库路径、微信小程序 AppID、AppSecret、token 密钥等配置。启动方式很简单python app.py就会在 5000 端口起服务同时启动 APScheduler 的 scheduler。这里要注意一个点Flask 自带的开发服务器不适合直接用。我用的是waitressWindows 和 Linux 都能跑性能比 Flask 自带服务器稳定得多。生产环境我用 Nginx 反向代理 waitress 的组合HTTP 服务这层基本不用操心了。3.2 微信登录接口code 换 token小程序端调用wx.login拿到一个临时 code然后传给后端。后端拿这个 code 去请求微信的jscode2session接口换取 openid。这个 code 只能用一次而且有效期很短所以要尽快处理。# auth.py app_login.route(/login, methods[POST]) def login(): data request.get_json() code data.get(code) if not code: return jsonify({code: 1, msg: 缺少 code}), 400 url ( https://api.weixin.qq.com/sns/jscode2session f?appid{WX_APPID}secret{WX_SECRET} fjs_code{code}grant_typeauthorization_code ) resp requests.get(url, timeout5).json() if openid not in resp: # 记录原始错误日志方便排查 code 过期 / appid 不匹配 / secret 错误 app.logger.error(wx login failed: %s, resp) return jsonify({code: 1, msg: 微信登录失败}), 500 openid resp[openid] user get_or_create_user(openid) token generate_token(user[id]) return jsonify({code: 0, data: {token: token}})这段代码里我加了一个很关键的判断如果响应里没有openid必须把微信返回的原始错误信息记到日志里。因为实际开发中登录失败十有八九是 AppID 和 Secret 配错了或者是 code 被重复使用只看微信登录失败这五个字永远查不出原因。3.3 日程 CRUD 接口实现日程的新增和更新逻辑核心就是解析 JSON、校验必填字段、写库。我吸取了一个教训前端传过来的时间格式要统一。我规定小程序端一律传YYYY-MM-DD HH:mm:ss这种字符串后端用datetime.strptime解析如果格式不对就立刻返回参数错误不等到落库后才发现问题。新增日程的代码大概是这样plans_bp.route(, methods[POST]) def create_plan(): user_id get_current_user_id() # 从 token 解析 data request.get_json() title (data.get(title) or ).strip() plan_time_str data.get(plan_time, ) remind_before int(data.get(remind_before, 10)) if not title: return jsonify({code: 1, msg: 标题不能为空}), 400 try: plan_time datetime.strptime(plan_time_str, %Y-%m-%d %H:%M:%S) except ValueError: return jsonify({code: 1, msg: 时间格式错误}), 400 # 提醒时间必须大于当前时间 remind_at plan_time - timedelta(minutesremind_before) if remind_at datetime.now(): return jsonify({code: 1, msg: 提醒时间已过请调整}), 400 sql INSERT INTO plans (user_id, title, content, plan_time, remind_before, status, reminded) VALUES (?, ?, ?, ?, ?, 0, 0) cur db.execute(sql, (user_id, title, data.get(content), plan_time_str, remind_before)) db.commit() return jsonify({code: 0, data: {id: cur.lastrowid}})这段代码里有个大家容易忽略的校验新增日程时不光要校验plan_time合法性还要校验提醒时间是否已经过掉。如果你设置的日程是 10 分钟后的但提醒提前量是 30 分钟那么这个日程创建的时候其实已经错过提醒了。我在初期就没做这个判断结果出现了一条日程创建完下一秒就被定时任务推送提醒的诡异现象。加上这个校验后体验正常了很多。3.4 定时扫描与通知触达定时任务用的是 APScheduler 的BackgroundScheduler每 30 秒跑一次。为什么不每秒钟跑因为日程提醒这种场景30 秒的误差用户完全感知不到但能显著降低数据库查询压力和微信接口调用频率。# scheduler.py def scan_and_send(): now_str datetime.now().strftime(%Y-%m-%d %H:%M:%S) sql SELECT p.id, p.title, p.content, p.plan_time, p.remind_before, u.openid FROM plans p JOIN users u ON p.user_id u.id WHERE p.status 0 AND p.reminded 0 AND datetime(p.plan_time) datetime(?, || p.remind_before || minutes) # 注意这里用的是 SQLite 的 datetime 计算把 plan_time 减去 remind_before 之后和当前时间比较等等上面这个 SQL 我实际调试的时候发现写法有点绕。SQLite 的datetime函数支持 modifier但拼接字符串可读性太差。后来我改成另一种方案直接在 Python 里算出提醒时间阈值拼进 SQL。更稳妥的写法是def scan_and_send(): now datetime.now() # 查询所有需要提醒的记录条件未完成、未提醒、计划时间在「当前时间 提前分钟数」之内 rows db.execute( SELECT p.id, p.title, p.content, p.plan_time, p.remind_before, u.openid FROM plans p JOIN users u ON p.user_id u.id WHERE p.status 0 AND p.reminded 0 ).fetchall() for row in rows: plan_time datetime.strptime(row[plan_time], %Y-%m-%d %H:%M:%S) remind_at plan_time - timedelta(minutesrow[remind_before]) if remind_at now: send_subscribe_message(row) mark_reminded(row[id])虽然每次扫描都查全表但数据量小完全没问题。这段逻辑也给我提了个醒不要把 SQL 写得太花哨尽量把计算搬到业务代码里做可读性和可调试性会好很多。4. 微信小程序前端的实现4.1 小程序页面结构我用原生小程序语法来写不依赖 uni-app 或 Taro。不是说那些框架不好而是这个项目本身页面不多用原生框架反而能减少一层编译和踩坑成本。页面一共四个pages/index/index日程列表页pages/edit/edit新增/编辑日程页pages/detail/detail日程详情页pages/mine/mine个人中心页包含订阅消息授权入口app.json里注册这几个页面窗口标题设为我的日程。小程序端的难点其实不在页面数量而在于登录态的管理。我通过封装一个request工具函数在每次请求前检查本地是否有 token没有就调wx.login获取 code再调后端/api/login换 token并把 token 存到wx.setStorageSync。后续所有请求自动带Authorization头。// utils/request.js const BASE_URL https://your-domain.com/api function request(path, method, data) { return new Promise((resolve, reject) { let token wx.getStorageSync(token) if (!token) { wx.login({ success: res { wx.request({ url: ${BASE_URL}/login, method: POST, data: { code: res.code }, success: loginRes { token loginRes.data.data.token wx.setStorageSync(token, token) doRequest(path, method, data, token, resolve, reject) } }) } }) return } doRequest(path, method, data, token, resolve, reject) }) }这里有一个很容易踩的坑wx.login拿到的 code只能使用一次。如果我在网络请求失败后重试时再次调wx.login后端的code2session就会报invalid code。所以我在request里做了 token 不存在时才走登录逻辑一旦换到 token 后续请求都复用不重复执行 login。4.2 日程列表与新增编辑日程页的核心是列表展示。我用了小程序的scroll-view加onPullDownRefresh下拉刷新时重新拉取/api/plans。列表项显示三个信息标题、计划时间、当前状态。状态用颜色区分未完成是黑色已完成是灰色并加删除线。长按列表项弹出一个 ActionSheet可以选择编辑、删除、标记完成。新增和编辑共用一个pages/edit/edit页面。表单字段有标题、内容、时间、提前提醒分钟数、重复规则。提前提醒分钟数我用picker组件来选可选项是 0、10、30、60、120这个设计比直接输入数字体验好很多也减少了前端校验的负担。提交表单时会做一次前端校验标题不能为空、时间不能早于当前时间。前端校验的意义在于提升响应速度后端校验才是真正的安全兜底。所以两边我都写了。4.3 弹窗交互的 UI 实现这正是标题里最核心的弹窗提醒。我在小程序里实现了一种软弹窗用户打开小程序首页onShow时检查本地是否有即将到期的日程如果有就弹出一个自定义遮罩层组件。自定义弹窗组件reminder-popup的结构大概是这样!-- components/reminder-popup/reminder-popup.wxml -- block wx:if{{visible}} view classmask bindtapclose/view view classpopup view classpopup-title日程提醒/view view classpopup-content view classplan-title{{plan.title}}/view view classplan-time{{plan.plan_time}}/view view classplan-remain距离开始还有 {{remainMinutes}} 分钟/view /view view classpopup-actions button sizemini bindtapclose知道了/button button sizemini typeprimary bindtapgoDetail查看详情/button /view /view /block判断即将到期的逻辑在首页的onShow里写把从后端获取的日程列表过滤出status 0且reminded为 false 且计划时间在当前时间到当前时间 15 分钟之间的记录取最早的一条展示。这里有个细节为什么不在定时器里轮询而要在 onShow 检查因为小程序退到后台后定时器会被系统冻结轮询根本跑不起来。onShow 的机制能保证用户每次回到小程序时一定能触发检查既节约资源又可靠。4.4 弹窗联动订阅消息弹窗只能解决用户已经打开小程序的场景如果用户根本不打开小程序再好看的弹窗也没用。所以我另外做了微信订阅消息的授权和发送。小程序端在新增日程成功后会调用wx.requestSubscribeMessage请求订阅消息授权。wx.requestSubscribeMessage({ tmplIds: [模板ID], success(res) { if (res[模板ID] accept) { // 用户同意授权 } } })这里用户看到的提示是允许发送提醒通知之类的系统文案。微信的订阅消息规则比较特殊用户点击一次授权只允许你发送一条订阅消息。也就是说我每发一条日程提醒前都得先让用户授权一次。所以我的策略是在新增日程的时候就引导用户授权一次这样到点后端推送时才有额度。如果用户拒绝授权了那就只能靠小程序内弹窗兜底。后端发送订阅消息的核心接口是https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_tokenACCESS_TOKEN先要用 AppID 和 Secret 换取access_tokendef send_subscribe_message(plan): access_token get_access_token() # 带缓存避免频繁调用 url fhttps://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token{access_token} data { touser: plan[openid], template_id: WX_TEMPLATE_ID, page: fpages/detail/detail?id{plan[id]}, data: { thing1: {value: plan[title][:20]}, time2: {value: plan[plan_time]}, thing3: {value: 您有一条日程即将开始} }, miniprogram_state: developer # 开发版发布后改为 formal } resp requests.post(url, jsondata).json() if resp.get(errcode) 0: return True, None return False, resp注意thing类型的字段有长度限制最多 20 个汉字time类型必须是YYYY-MM-DD HH:mm:ss的格式不然微信会直接拒绝。这些是我实际对接时被微信文档坑过才记住的。5. 定时提醒与订阅消息的配合逻辑5.1 后端定时扫描的策略细节回到后端定时任务。每 30 秒一次的全表扫描如果数据量大确实有性能隐患但我这里个人项目完全可接受。真正需要注意的反而是重复提醒的防止。我在plans表里加了reminded字段。定时任务处理一条记录时两步是关键先调微信接口再立刻把reminded置 1。如果微信接口调用失败比如access_token过期或网络抖动我不会直接标记为已提醒而是等下一轮扫描时重试。但这里有个风险如果微信一直返回错误任务就会每 30 秒重试一次可能把微信的接口限流打爆。所以我后来又加了一个重试次数控制remind_log表里累计重试到 3 次就强制标记reminded 1然后记录失败原因等人工排查。这种重试 熔断的思路在个人项目里也一定要有不然后端日志里全是重复刷屏的错误信息。5.2 小程序内弹窗的触发时机很多人以为弹窗提醒系统就是小程序里写一个wx.showModal那么简单其实弹窗的时机比弹窗本身重要得多。我总结了一套判断逻辑小程序启动后onShow触发首先检查当前时间是否已有过期未处理的日程。如果有说明用户错过提醒了弹窗文案要强调已过期让用户知道要补处理。然后检查即将到来的日程比如 15 分钟内。如果有弹窗提醒并给出查看详情入口。每次关闭弹窗后在本地存储里记一条lastPopupTime避免用户一直打开关闭页面导致弹窗反复弹出。这个去重逻辑很简单但能显著提升使用体验。function checkReminder(plans) { const now new Date() const soon plans.filter(p { const planTime new Date(p.plan_time.replace(/-/g, /)) const diff (planTime - now) / 60000 return p.status 0 diff 0 diff 15 }) if (soon.length 0) { const latest soon.sort((a, b) a.plan_time.localeCompare(b.plan_time))[0] const lastTime wx.getStorageSync(lastPopupTime) const nowStr Date.now() if (!lastTime || nowStr - lastTime 60 * 1000) { this.setData({ popupVisible: true, currentPlan: latest }) wx.setStorageSync(lastPopupTime, nowStr) } } }这段代码里有个很细节的点iOS 的new Date(2024-01-01 10:00:00)直接解析会返回 Invalid Date所以我在解析前把-替换成/。这个坑对安卓和 iOS 的小程序兼容性影响很大不处理的话 iOS 用户永远看不到弹窗。5.3 本地提醒和订阅消息的分工两套提醒机制分工总结一句话订阅消息负责把用户叫回来小程序内弹窗负责回来后看到完整信息。用户在小程序里新增日程并授权订阅消息之后到点后端会推送一条订阅消息到微信的服务通知里。用户点这条通知直接跳转到小程序的日程详情页。而如果用户当时正好打开着小程序首页的onShow弹窗就会先出现给用户一个更直观的提醒卡片。两条路径完全不冲突。这样做还有一个额外的好处用户不打开小程序也能收到提醒提醒的主动感就出来了。这也是弹窗提醒系统相对普通日历应用的一个突出差异点。6. 实际开发中的高频坑与优化方向6.1 我踩过的五个高频坑位第一个坑是wx.login 的 code 不能重复使用。我在调试时经常遇到 token 过期后自动重新登录的场景如果代码写得不够严谨同一秒内多个请求同时触发wx.login一个 code 被后端换了两次第二次必然报错。解决方案是给登录逻辑加一个 Promise 单例模式第一次wx.login还没完成时后面的请求直接await同一个 Promise而不是重新发起登录。第二个坑是小程序定时器在后台会被冻结。我最初想用setInterval在前端每隔一分钟扫描一次日程后来发现只要用户切到后台定时器基本就停了回来后又突然乱跳。后来我彻底放弃了前端定时器方案全部依赖onShow触发检查和后端订阅消息推送。第三个坑是HBuilderX 和微信开发者工具的编译路径问题。如果只用原生开发直接用微信开发者工具打开项目根目录就行注意project.config.json里的miniprogramRoot要指向小程序代码的实际目录。我最初把 Flask 后端代码和小程序前端代码放在同一个仓库的根目录下结果微信开发者工具把整个项目都当成了小程序来编译报了一堆莫名其妙的模块错误。第四个坑是订阅消息的thing字段长度限制。我一开始把日程标题原样传过去结果某天标题超过 20 个字微信直接返回errCode 47003参数错误。解决方案是在后端发送前做截断处理但如果标题被截断了用户看到的信息不完整所以我后来调整了模板设计主要内容用time字段承载标题字段尽量精简。第五个坑是基础库版本不兼容。像wx.requestSubscribeMessage这个 API需要基础库版本在 2.8.2 以上才支持。用户手机微信版本太旧调用会直接失败。我在小程序后台设置了最低基础库版本同时在代码里做了wx.canIUse(requestSubscribeMessage)的兼容判断不支持就提示用户升级微信版本。6.2 项目的可扩展方向与性能优化做完这个系统之后我明显感觉到它已经具备了一个可用工具的雏形但还是有些地方可以继续扩展。一个是重复日程。目前每次日程都要单独建如果要实现每周一健身这种周期性的日程还需要加一个repeat_type字段配合定时任务在每次标记完成后生成下一次日程。这个逻辑不复杂但细节多得单独开一轮开发。另一个是多端数据同步。目前数据只在微信小程序端可以看到如果我想在电脑上快速新增日程就还得写一个 Web 端。好在我后端 API 已经是 RESTful 风格Web 端直接复用现成接口就行前端花一天就能搭出来。还有一个值得做的方向是数据统计。建一张日志表记录用户每天完成了哪些日程然后用 Python 的数据分析能力做一个简单的周报比如你这周完成了 12 个日程最常被拖延的时间段是晚上 9 点以后。这种个性化分析正是 Python 生态里最擅长的东西也是日程管理 App 区别于普通日历的高级卖点。6.3 部署环境里值得注意的两个细节最后补充两个生产环境部署的细节。第一微信小程序正式环境必须用 HTTPS。开发时可以用开发者工具的不校验合法域名来绕过上线前一定要在微信公众平台后台配置 HTTPS 域名同时要保证后端服务器的证书是有效的。我用的是 Nginx 配置 SSL 证书反向代理到本机的 8000 端口 waitress 服务。第二access_token必须加缓存。微信的access_token有效期是 7200 秒但每天有获取次数限制。我在后端写了一个简单的文件缓存先读本地 JSON 文件里的 token 和过期时间如果没过期就直接用过期了才请求新的。千万不要每次发订阅消息都去获取一次 token那样很快就会触发微信的接口频率限制。从最开始冒出自己做一个日程提醒系统的念头到小程序内弹窗、后端定时任务、订阅消息推送全部跑通整个过程让我最大的感受是这类项目拼的不是单个技术点的深度而是把登录态、数据表设计、定时任务、前端交互、第三方接口约束全部串起来的能力。如果你也想练手我的建议是先不要急着写代码把用户会在什么时候打开小程序提醒最晚提前多久发订阅消息授权失败怎么办这几个问题想清楚再动工整个开发过程会顺畅很多。