Skip to content

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

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

一、先接受平台约束 ​

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

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

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

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

mermaid
flowchart LR
    Click[用户点击 +1] --> Grant[请求下一次授权]
    Grant --> Schedule[安排下一次提醒]
    Schedule --> Send[发送一次消息]
    Send --> Wait[等待用户回到小程序]
    Wait --> Click

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

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

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

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

状态迁移如下:

mermaid
stateDiagram-v2
    [*] --> disabled
    disabled --> scheduled: 用户接受授权并开启
    scheduled --> sending: worker 抢占到期计划
    sending --> awaiting_checkin: 微信确认发送成功
    sending --> paused: 失败或结果不确定
    awaiting_checkin --> scheduled: 打卡并获得下一次授权
    awaiting_checkin --> paused: 拒绝或关闭订阅
    scheduled --> disabled: 用户关闭提醒
    paused --> scheduled: 用户再次接受授权

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

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

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

text
available -> reserved -> consumed
                    \-> invalid

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

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

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

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

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

  1. 立即写入本地喝水记录;
  2. 根据缓存的 needs_grant 判断是否需要申请订阅;
  3. 如需申请,在当前点击调用栈中直接调用 requestSubscribeMessage;
  4. 将打卡动作提交给提醒服务;
  5. 网络失败时,把动作放进本地 pending 队列等待补交。

第三步不能先 await fetchReminderStatus()。微信要求订阅弹窗紧跟有效用户手势,一次网络等待可能让调用失去手势上下文。页面进入时提前获取状态,点击时使用缓存,再由服务端校验,才是正确顺序。

前端 pending action 保存完整幂等信息:

json
{
  "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 同时发送同一条消息。

mermaid
sequenceDiagram
    participant W as Worker
    participant DB as MySQL
    participant WX as 微信接口

    W->>DB: SELECT 到期计划 FOR UPDATE SKIP LOCKED
    W->>DB: grant available -> reserved
    W->>DB: plan scheduled -> sending
    W->>DB: 创建 delivery(claimed),提交
    W->>WX: 在事务外发送消息
    WX-->>W: 成功/失败/超时
    W->>DB: 锁定 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:

python
try:
    result = send_subscribe_message(...)
except Exception as exc:
    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 和微信接口的协议。把授权建模成资源,把发送建模成状态机,系统才有机会在断网和重启之后继续讲得清楚。

Released under the MIT License.