"""卡片回执对账任务入台账 ## 这是在解决什么 回执有两条腿。**内联**那条是各域在终态迁移处调用 `settle()` —— 快,员工几秒内 就看到卡片变了。但一个 46 处 actionable 卡片调用点的平台里,「记得加那一行」注定 会漏,而漏掉的后果是一张**永久**骗人的卡片(「去审批」按钮点下去被拒)。 这条 cron 是**保证**那一条腿:每 15 分钟扫一遍「还没结案、而它说的那件事已经有 结局」的通知行,补上。有了它,漏掉一处内联调用只是最多 15 分钟的延迟,不再是 正确性问题。 ## 为什么是 15 分钟 回执的价值随时间衰减得很快:员工在收到卡片后的几分钟内最可能去点它。15 分钟是 「漏了也不会有人真的点到一张骗人的卡」和「不要每分钟扫一张随平台增长的表」之间的 折中。扫描本身是有界的(一轮最多 500 行,且只看 5 分钟前~30 天内的行)。 `timezone` 用 UTC:这个任务和任何本地钟点都无关,`*/15` 在哪个时区解释都一样。 (与之相反的是各种「每日 09:00」的任务,那些必须按 `Asia/Shanghai` 存 —— 见 `cron-and-worker-traps` 里那一条。) ## 不回填 存量的未结案行由这条 cron 自己按各资源的**当前真实状态**逐步结案 —— 迁移里不做 任何 UPDATE。原因和 625 一样:我们不知道那些卡片说的事情后来怎么了,而凭空标一批 「已完成」会在员工的收件箱里造出从未发生过的回执。 """ from __future__ import annotations import uuid import sqlalchemy as sa from alembic import op revision = "626" down_revision = "625" branch_labels = None depends_on = None JOBS = "scheduled_jobs" HANDLER = "notifications.card_settlement_reconcile" DISPLAY = "卡片回执对账" CRON = "*/15 * * * *" DESC = ( "每 15 分钟扫一遍「还没结案、而它说的那件事已经有结局」的通知行,把卡片改成" "回执。回执的快路径是各域在终态迁移处内联调用 settle();这个任务是保证那一" "条腿 —— 漏掉一处内联调用只是最多 15 分钟的延迟,不再是一张永久骗人的卡片。" "两边都幂等,抢到同一件事也只有第一次做实事。" ) def upgrade() -> None: bind = op.get_bind() inspector = sa.inspect(bind) if JOBS not in inspector.get_table_names(): return bind.execute( sa.text( f"INSERT INTO {JOBS} " "(id, name, display_name, description, handler_key, " " trigger_type, cron_expr, timezone, enabled, config, " " run_count, failure_count, created_at, updated_at) " "SELECT :id, :name, :display, :desc, :handler, 'cron', " ":cron, 'UTC', 1, :config, 0, 0, NOW(), NOW() " "FROM DUAL WHERE NOT EXISTS " f"(SELECT 1 FROM {JOBS} WHERE handler_key = :handler)" ), { "id": str(uuid.uuid4()), "name": HANDLER, "display": DISPLAY, "desc": DESC, "handler": HANDLER, "cron": CRON, "config": "{}", }, ) def downgrade() -> None: bind = op.get_bind() inspector = sa.inspect(bind) if JOBS not in inspector.get_table_names(): return bind.execute( sa.text(f"DELETE FROM {JOBS} WHERE handler_key = :handler"), {"handler": HANDLER}, )