"""催办节奏的计数表。 平台上「一直催到你按按钮为止」的推送有两个 —— 资产盘点提醒、云订阅 Owner 半年确认 —— 两个都是每天一张卡、无限期。最近 30 天生产数据:资产盘点人均 **16.7 条**,云 Owner 确认人均 **7.6 条**。 一个已经无视了 20 张同样卡片的人不会被第 21 张说动;那 21 张唯一确定做到 的事,是训练他把这个来源整个过滤掉 —— 连同将来某一张真正要紧的卡。 这张表让催办可以「不放弃、但递减」:记住某个人因为某件事已经被催过几次、 上次是什么时候,`app/notifications/cadence.py` 据此决定今天该不该再发。 阶梯是 1/1/1/2/3/5 天,之后每周一次,**永不停止** —— 开着的事一直开着 (那正是 2026-08-24 那条决定的要求),而第一个月从 30 条降到 8 条。 **刻意不从 `notification_outbox` 推导计数**:那是一张投递日志,有自己的 保留期,而且一次逻辑上的提醒可能对应多行(重试、按渠道拆分)。拿它当计数 会让催办节奏随投递机制漂移,而不是随「这个人被问了几次」变化。 `subject_key` 指的是**那件事**(盘点周期 id、确认单 id),不是那条消息: 两件同时开着的事各自独立递减,把一件放慢绝不能顺带让另一件静音。 行在事情办完时**删除而不是归档** —— 这是个计数器,而一个过期的计数器比 没有更糟:它会让下一件全新的事从「每周一次」那一档开始。 Revision ID: 576 Revises: 575 """ from __future__ import annotations import sqlalchemy as sa from alembic import op from app.core.migration_helpers import id_type_for, table_kw_for revision = "576" down_revision = "575" branch_labels = None depends_on = None TABLE = "notification_nag_state" def upgrade() -> None: conn = op.get_bind() if sa.inspect(conn).has_table(TABLE): return op.create_table( TABLE, sa.Column("id", id_type_for("identity_persons"), primary_key=True), sa.Column( "recipient_person_id", id_type_for("identity_persons"), nullable=False, ), sa.Column("template_key", sa.String(96), nullable=False), # The THING, not the message. sa.Column("subject_key", sa.String(128), nullable=False), sa.Column("first_sent_at", sa.DateTime(timezone=True), nullable=True), sa.Column("last_sent_at", sa.DateTime(timezone=True), nullable=True), sa.Column( "sent_count", sa.Integer(), nullable=False, server_default="0", ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), onupdate=sa.text("CURRENT_TIMESTAMP"), ), sa.ForeignKeyConstraint( ["recipient_person_id"], ["identity_persons.id"], ondelete="CASCADE", ), **table_kw_for("identity_persons"), ) op.create_index( "uq_nag_state_recipient_template_subject", TABLE, ["recipient_person_id", "template_key", "subject_key"], unique=True, ) op.create_index( "ix_nag_state_recipient", TABLE, ["recipient_person_id"], ) def downgrade() -> None: conn = op.get_bind() if not sa.inspect(conn).has_table(TABLE): return op.drop_index("ix_nag_state_recipient", TABLE) op.drop_index("uq_nag_state_recipient_template_subject", TABLE) op.drop_table(TABLE)