"""通知行记下「那件事后来怎么了」—— 卡片回执的四列 ## 这是在解决什么 用户的原话:「helix 的推送没有任何回执的能力,这个审批过了,还是去审批」。 一条通知说的是「有人在等你做 X」。在这四列之前,**没有任何东西回来说 X 做完了** —— 于是一张已经批准的续期审批卡,在飞书里原样挂着一颗活的「去审批」按钮,点下去 落在一个早已翻页的页面上。卡片是一份意图的快照,而且是永久的。 这不只是飞书那一面的问题:铃铛、移动端行动收件箱、飞书卡片读的是**同一张表**。 所以「结案」这件事必须落在行上,而不是落在某一个渠道的渲染里 —— 三个面同时改, 才不会互相矛盾。 ## 为什么是四列而不是一列布尔 - `settled_at` —— 什么时候结的。也是幂等闸:只有 `IS NULL` 的行会被结案,所以 重复调用 `settle()` 不会二次改写卡片、不会把处理人换成后来那个人。 - `settled_outcome` —— **批准 / 驳回 / 到期 / 撤回 / 完成**。刻意复用 `card_design.SETTLED_STATUSES` 那套词表(卡片头部状态用的同一套):回执长什么样 由**记录下来的事实**派生,而不是在渲染时再选一次。一列布尔做不到这件事 —— 「已处理」不能告诉员工是批了还是驳了,而那正是他要知道的。 - `settled_by_person_id` —— 谁做的。回执上那句「由 张三 于 09-05 15:20 批准」 的前半句。存 person_id 不存名字:名字会变,而且这一行会活很久。 - `settled_note` —— 领域自己要补的一句话,`{"zh": …, "en": …, "ja": …}` 形状。 存**本地化字典**而不是渲染好的字符串,理由和 `context` 里的 locale fragment 一样:行比发送活得久,一个改了语言偏好的读者不该看到一句被钉死在当时语言上的 回执。 ## 索引:为什么加在 (settled_at, related_resource_type) 对账 cron(PR3)要问的是「有 message_id、还没结案、而它说的那件事已经终态了」。 `related_resource_type/_id` 上已经有索引(迁移 509),但那条索引答不了「未结案」 这一半 —— 一次对账会把每一种资源类型的**全部历史行**都扫出来再逐行判。加这条 以 `settled_at` 起头的索引,让「未结案」成为第一个收窄条件。 ## 为什么是 625 而不是 624 原本抢的是 624。**#3501(下线阻断清理)在同一小时里也抢了 624 并先合并** —— 两个 分支各自按 `origin/main` 查过号,但查号和合并之间隔着几十分钟,而 git 对两个不同 文件名不会报冲突。结果是 main 上一度有两条 `revision = "624"`,`alembic heads` 给出 两个头。**已修:这条改成 625,并新增 `test_revision_chain_is_single_headed.py`** —— 仓库此前没有任何测试断言「revision 唯一 + 单头」,所以这次撞号是靠人眼在下一个 PR 里发现的,而不是靠闸。 ## 不回填 存量行**全部留 NULL**。这不是偷懒,是唯一诚实的选择:我们不知道那些卡片说的事情 后来怎么了,而把它们一律标成「已完成」会在员工的收件箱里凭空造出一批从未发生过的 回执。它们会由 PR3 的对账 cron 按各自资源的**当前真实状态**逐步结案 —— 那一路 读的是权威表,不是猜的。 """ from __future__ import annotations import sqlalchemy as sa from alembic import op revision = "625" down_revision = "624" branch_labels = None depends_on = None OUTBOX = "notification_outbox" COLUMNS = ( ("settled_at", lambda: sa.Column("settled_at", sa.DateTime(timezone=True))), ("settled_outcome", lambda: sa.Column("settled_outcome", sa.String(24))), ( "settled_by_person_id", lambda: sa.Column("settled_by_person_id", sa.CHAR(36)), ), ("settled_note", lambda: sa.Column("settled_note", sa.JSON())), ) INDEX = "ix_notification_outbox_settlement" def _has_table(bind, table: str) -> bool: return bind.execute( sa.text( "SELECT 1 FROM information_schema.tables " "WHERE table_schema = DATABASE() AND table_name = :t" ), {"t": table}, ).scalar() is not None def _has_column(bind, table: str, column: str) -> bool: return bind.execute( sa.text( "SELECT 1 FROM information_schema.columns " "WHERE table_schema = DATABASE() " "AND table_name = :t AND column_name = :c" ), {"t": table, "c": column}, ).scalar() is not None def _has_index(bind, table: str, index: str) -> bool: return bind.execute( sa.text( "SELECT 1 FROM information_schema.statistics " "WHERE table_schema = DATABASE() " "AND table_name = :t AND index_name = :i LIMIT 1" ), {"t": table, "i": index}, ).scalar() is not None def upgrade() -> None: bind = op.get_bind() if not _has_table(bind, OUTBOX): return for name, factory in COLUMNS: if not _has_column(bind, OUTBOX, name): op.add_column(OUTBOX, factory()) # ``settled_by_person_id`` 是 CHAR(36) 而不是带 FK 的 Uuid:这张表是投递 # 日志,有自己的保留期,而人是会被硬删的(见 prod runbook 里枚举 FK 的那一节)。 # 一条指向已删除的人的 FK 会把「删人」变成「先改一年的通知历史」。 if not _has_index(bind, OUTBOX, INDEX): op.create_index( INDEX, OUTBOX, ["settled_at", "related_resource_type", "related_resource_id"], ) def downgrade() -> None: bind = op.get_bind() if not _has_table(bind, OUTBOX): return if _has_index(bind, OUTBOX, INDEX): op.drop_index(INDEX, table_name=OUTBOX) for name, _factory in COLUMNS: if _has_column(bind, OUTBOX, name): op.drop_column(OUTBOX, name)