一次授权只发一次:微信喝水提醒的可靠状态机设计 「零碎百宝箱」微信小程序里有个工具叫健康打卡。 “每两小时提醒我喝水”听起来像一个 cron 表达式但微信一次性订阅消息并不是无限额度的定时通知。一次授权只能支撑一次发送用户拒绝、断网、worker 重启和接口超时都会改变下一步该做什么。一、先接受平台约束喝水提醒建立在微信一次性订阅消息上。它有三个决定架构的约束授权必须由用户点击触发不能在页面加载时自动弹出一次accept对应一次可用发送机会不能把它当成永久开关消息发送后若想继续提醒需要用户再次主动授权。因此产品语义不是“开启后永久每两小时推送”而是用户授权下一次提醒收到消息后回到小程序完成喝水打卡并在这次手势中决定是否授权再下一次。这项能力被放进「零碎百宝箱」时首先解决的也不是定时精度而是怎样在平台规则内形成可靠闭环。用户点击 1请求下一次授权安排下一次提醒发送一次消息等待用户回到小程序如果不先把这个限制变成产品流程后端再可靠也无法实现“持续提醒”。二、一个布尔值表达不了提醒状态服务端为每个用户维护WaterReminderPlan核心状态包括disabled 未开启 scheduled 已持有授权等待到期 sending worker 已抢占正在发送 awaiting_checkin 已发送等待用户打卡 paused 缺少授权或发生不可恢复错误状态迁移如下用户接受授权并开启worker 抢占到期计划微信确认发送成功失败或结果不确定打卡并获得下一次授权拒绝或关闭订阅用户关闭提醒用户再次接受授权disabledscheduledsendingawaiting_checkinpaused前端不通过wx.getSetting猜测是否还有额度而是读取服务端计算的needs_grant。授权机会是一条可消费的业务资源必须在服务端持久化和审计。三、不要用一个整数模拟授权额度系统为每次授权创建独立的WaterReminderGrantavailable - reserved - consumed \- invalid每条 grant 都有前端生成的request_id数据库对(openid, request_id)建唯一约束。用户点击后因网络抖动重复提交同一个开启请求只会登记一次授权不会凭空多出额度。同理每次打卡推进动作带action_idWaterReminderAction对其去重。授权幂等和动作幂等必须分开一次打卡可能消费或创建 grant但二者代表不同业务事实。单独建 grant 表比remaining_count 1更可靠因为它能回答哪一次用户操作产生了额度、额度何时被预占、最终是否成功消费以及失败后为何作废。四、先本地打卡再处理提醒喝水记录使用本地优先的 record-sync。即使提醒服务不可用用户点击1也必须成功保存记录。页面处理顺序是立即写入本地喝水记录根据缓存的needs_grant判断是否需要申请订阅如需申请在当前点击调用栈中直接调用requestSubscribeMessage将打卡动作提交给提醒服务网络失败时把动作放进本地 pending 队列等待补交。第三步不能先await fetchReminderStatus()。微信要求订阅弹窗紧跟有效用户手势一次网络等待可能让调用失去手势上下文。页面进入时提前获取状态点击时使用缓存再由服务端校验才是正确顺序。前端 pending action 保存完整幂等信息{action_id:wc_m...,grant_request_id:wr_m...,subscription_status:accept,occurred_at:2026-07-27T12:05:00.000Z,current_count:4,daily_goal:8}授权已经接受但登记接口失败时后续仍用相同 ID 重试。否则这次宝贵的一次性授权可能因短暂断网丢失。五、下一次时间是一个可测试的纯规则提醒计划包含间隔、起床时间和睡觉时间。下一次时间遵循正常情况下为本次打卡时间加间隔早于起床时间则推到当天起床时间晚于或等于睡觉时间则推到次日起床时间当日已经达到目标则直接推到次日起床时间服务端以 UTC 存储接口返回带时区的 ISO 8601 时间。打卡时间间隔作息今日达标下一次提醒10:00120 分钟08:00-22:00否当日 12:0021:10120 分钟08:00-22:00否次日 08:0016:00120 分钟08:00-22:00是次日 08:00时间计算集中在服务端客户端传来的occurred_at还要限制合理偏差防止设备时间错误把任务排到遥远未来。将这部分写成纯函数可以覆盖跨日、边界时刻和夏令时之外的时区转换测试。六、worker 抢占锁在事务内发送在事务外APScheduler 默认每 30 秒扫描一次到期计划。可靠发送的关键不是扫描频率而是如何避免两个 worker 同时发送同一条消息。微信接口MySQLWorker微信接口MySQLWorkerSELECT 到期计划 FOR UPDATE SKIP LOCKEDgrant available - reservedplan scheduled - sending创建 delivery(claimed)提交在事务外发送消息成功/失败/超时锁定 delivery写入最终状态SKIP LOCKED让多个 worker 可以各自领取不同计划而不会重复领取同一行。事务只负责抢占调用微信接口必须放在提交之后否则十秒网络超时会让数据库锁一直占用拖住其他用户计划。发送成功后grant 变为consumeddelivery 变为sentplan 变为awaiting_checkin并清空next_due_at。因此即使调度器下一轮马上扫描也不会再次选中该计划。只有用户重新打卡并提供新的可用 grant计划才回到scheduled。七、消息发送无法承诺严格 exactly-once最棘手的情况是微信已经收到请求但服务端在收到响应前超时。此时自动重试可能让用户收到两条消息不重试则可能漏掉一条。数据库事务无法覆盖外部 HTTP 系统所以严格 exactly-once 并不存在。项目选择 at-most-once明确成功标记sent明确失败标记failed超时等不确定结果标记unknownunknown不盲目自动重发计划转为paused。这对喝水提醒是合理取舍少一次提醒的伤害远低于重复打扰用户。delivery 保存微信消息 ID、错误码和错误信息为后续接入微信事件回调和人工对账保留依据。worker 如果在抢占后、写回结果前退出claimed会残留。扫描逻辑会找出超过五分钟的陈旧 delivery将其标为unknown同时作废预占 grant 并暂停计划避免永远卡在sending。八、错误必须收敛为稳定状态后台任务最危险的不是一次失败而是状态悬空。发送函数外层捕获未分类异常并始终调用finalize_deliverytry:resultsend_subscribe_message(...)exceptExceptionasexc:result{ok:False,uncertain:True,errmsg:str(exc)[:500]}finalize_delivery(delivery_id,result)这保证数据库中的claimed/sending最终会转入可解释状态。对调度系统来说“失败但已记录”通常比“抛出异常后什么都没更新”更容易恢复。用户拒绝或永久关闭订阅时计划进入paused但喝水记录仍然成功用户关闭提醒时所有未消费 grant 置为invalid避免旧任务稍后突然发出。九、部署形态决定调度器放在哪里当前 health-log 是单进程 Flask 容器只有SERVICE_NAMEhealth-log且功能开关开启时才注册后台调度器。其他复用 records 镜像的工具不会误启动喝水任务。这个方式部署简单数据库行锁也为未来多实例提供了抢占保护。但若应用改成多进程 WSGI调度器会随每个进程启动届时更清晰的做法是将process_due_reminders原样放入独立 worker 容器。因为计划、grant 和 delivery 都在数据库中拆 worker 不需要重写业务模型。十、可靠提醒的检查清单一个涉及一次性授权和外部消息接口的提醒系统至少要回答这些问题用户的一次授权是否有独立记录和幂等键两个 worker 能否同时领取同一任务外部调用是否发生在数据库事务之外超时后选择重试、漏发还是人工对账业务代价是什么worker 在任何语句退出后状态是否都能被后续扫描收敛用户拒绝提醒时核心打卡流程是否仍然可用用户关闭功能后历史任务和未用授权是否全部失效喝水提醒不是一个定时器而是一套跨越用户手势、本地存储、数据库事务、后台 worker 和微信接口的协议。把授权建模成资源把发送建模成状态机系统才有机会在断网和重启之后继续讲得清楚。