一次授权只发一次:微信喝水提醒的可靠状态机设计

「零碎百宝箱」微信小程序里有个工具叫健康打卡。 “每两小时提醒我喝水”听起来像一个 cron 表达式,但微信一次性订阅消息并不是无限额度的定时通知。一次授权只能支撑一次发送,用户拒绝、断网、worker 重启和接口超时都会改变下一步该做什么。

一、先接受平台约束

喝水提醒建立在微信一次性订阅消息上。它有三个决定架构的约束:

  1. 授权必须由用户点击触发,不能在页面加载时自动弹出;
  2. 一次accept对应一次可用发送机会,不能把它当成永久开关;
  3. 消息发送后若想继续提醒,需要用户再次主动授权。

因此产品语义不是“开启后永久每两小时推送”,而是:用户授权下一次提醒,收到消息后回到小程序完成喝水打卡,并在这次手势中决定是否授权再下一次。

这项能力被放进「零碎百宝箱」时,首先解决的也不是定时精度,而是怎样在平台规则内形成可靠闭环。

用户点击 +1

请求下一次授权

安排下一次提醒

发送一次消息

等待用户回到小程序

如果不先把这个限制变成产品流程,后端再可靠也无法实现“持续提醒”。

二、一个布尔值表达不了提醒状态

服务端为每个用户维护WaterReminderPlan,核心状态包括:

disabled 未开启 scheduled 已持有授权,等待到期 sending worker 已抢占,正在发送 awaiting_checkin 已发送,等待用户打卡 paused 缺少授权或发生不可恢复错误

状态迁移如下:

用户接受授权并开启

worker 抢占到期计划

微信确认发送成功

失败或结果不确定

打卡并获得下一次授权

拒绝或关闭订阅

用户关闭提醒

用户再次接受授权

disabled

scheduled

sending

awaiting_checkin

paused

前端不通过wx.getSetting猜测是否还有额度,而是读取服务端计算的needs_grant。授权机会是一条可消费的业务资源,必须在服务端持久化和审计。

三、不要用一个整数模拟授权额度

系统为每次授权创建独立的WaterReminderGrant

available -> reserved -> consumed \-> invalid

每条 grant 都有前端生成的request_id,数据库对(openid, request_id)建唯一约束。用户点击后因网络抖动重复提交同一个开启请求,只会登记一次授权,不会凭空多出额度。

同理,每次打卡推进动作带action_idWaterReminderAction对其去重。授权幂等和动作幂等必须分开:一次打卡可能消费或创建 grant,但二者代表不同业务事实。

单独建 grant 表比remaining_count += 1更可靠,因为它能回答:哪一次用户操作产生了额度、额度何时被预占、最终是否成功消费,以及失败后为何作废。

四、先本地打卡,再处理提醒

喝水记录使用本地优先的 record-sync。即使提醒服务不可用,用户点击+1也必须成功保存记录。页面处理顺序是:

  1. 立即写入本地喝水记录;
  2. 根据缓存的needs_grant判断是否需要申请订阅;
  3. 如需申请,在当前点击调用栈中直接调用requestSubscribeMessage
  4. 将打卡动作提交给提醒服务;
  5. 网络失败时,把动作放进本地 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:00
21:10120 分钟08:00-22:00次日 08:00
16: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 变为consumed
  • delivery 变为sent
  • plan 变为awaiting_checkin,并清空next_due_at

因此即使调度器下一轮马上扫描,也不会再次选中该计划。只有用户重新打卡并提供新的可用 grant,计划才回到scheduled

七、消息发送无法承诺严格 exactly-once

最棘手的情况是微信已经收到请求,但服务端在收到响应前超时。此时自动重试可能让用户收到两条消息;不重试则可能漏掉一条。

数据库事务无法覆盖外部 HTTP 系统,所以严格 exactly-once 并不存在。项目选择 at-most-once:

  • 明确成功标记sent
  • 明确失败标记failed
  • 超时等不确定结果标记unknown
  • unknown不盲目自动重发,计划转为paused

这对喝水提醒是合理取舍:少一次提醒的伤害远低于重复打扰用户。delivery 保存微信消息 ID、错误码和错误信息,为后续接入微信事件回调和人工对账保留依据。

worker 如果在抢占后、写回结果前退出,claimed会残留。扫描逻辑会找出超过五分钟的陈旧 delivery,将其标为unknown,同时作废预占 grant 并暂停计划,避免永远卡在sending

八、错误必须收敛为稳定状态

后台任务最危险的不是一次失败,而是状态悬空。发送函数外层捕获未分类异常,并始终调用finalize_delivery

try:result=send_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_NAME=health-log且功能开关开启时才注册后台调度器。其他复用 records 镜像的工具不会误启动喝水任务。

这个方式部署简单,数据库行锁也为未来多实例提供了抢占保护。但若应用改成多进程 WSGI,调度器会随每个进程启动;届时更清晰的做法是将process_due_reminders原样放入独立 worker 容器。因为计划、grant 和 delivery 都在数据库中,拆 worker 不需要重写业务模型。

十、可靠提醒的检查清单

一个涉及一次性授权和外部消息接口的提醒系统,至少要回答这些问题:

  1. 用户的一次授权是否有独立记录和幂等键?
  2. 两个 worker 能否同时领取同一任务?
  3. 外部调用是否发生在数据库事务之外?
  4. 超时后选择重试、漏发还是人工对账,业务代价是什么?
  5. worker 在任何语句退出后,状态是否都能被后续扫描收敛?
  6. 用户拒绝提醒时,核心打卡流程是否仍然可用?
  7. 用户关闭功能后,历史任务和未用授权是否全部失效?

喝水提醒不是一个定时器,而是一套跨越用户手势、本地存储、数据库事务、后台 worker 和微信接口的协议。把授权建模成资源,把发送建模成状态机,系统才有机会在断网和重启之后继续讲得清楚。