Files
company-ai-platform/alembic/versions/202607270002_feishu_inbound_event_inbox.py
JiuContinent eb8267ed18 ```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能

- 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避
- 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐
- 增加运行组件心跳检测和readiness就绪检查机制
- 实现app_ticket事件的安全轮换和验证处理
- 添加生产环境运行编排和fail-closed安全机制
- 支持webhook快速确认和长连接独立进程处理
- 完善个人数据擦除时的待处理事件清理功能
```
2026-07-27 17:14:37 +08:00

252 lines
6.9 KiB
Python

"""Upgrade Feishu event receipts to a durable inbound inbox.
Revision ID: 202607270002
Revises: 202607270001
Create Date: 2026-07-27
"""
import re
from hashlib import sha256
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "202607270002"
down_revision = "202607270001"
branch_labels = None
depends_on = None
TABLE_NAME = "feishu_event_receipts"
INDEX_COLUMNS = (
"event_type",
"status",
"next_attempt_at",
"locked_until",
"locked_by",
"processed_at",
"reply_status",
"reply_next_attempt_at",
"reply_locked_until",
"reply_locked_by",
"reply_sent_at",
)
IDENTIFIER_DIGEST_PATTERN = re.compile(r"sha256-[0-9a-f]{64}\Z")
IDENTIFIER_DIGEST_PREFIX = "company-ai-platform:feishu"
def _identifier_digest(value: Any, domain: str) -> str | None:
text = str(value or "").strip()
if not text:
return None
if IDENTIFIER_DIGEST_PATTERN.fullmatch(text):
return text
digest = sha256(
f"{IDENTIFIER_DIGEST_PREFIX}:{domain}:v1\0{text}".encode("utf-8")
).hexdigest()
return f"sha256-{digest}"
def _migrate_historical_identifiers() -> None:
"""Replace legacy raw receipt identifiers without hashing them twice.
A strict current-format digest is the only durable marker available in the
legacy schema, so matching values are treated as already migrated. A raw
identifier that happens to match that format is therefore intentionally
indistinguishable and remains unchanged.
"""
bind = op.get_bind()
receipts = sa.table(
TABLE_NAME,
sa.column("id", sa.Integer()),
sa.column("event_key", sa.String()),
sa.column("event_id", sa.String()),
sa.column("message_id", sa.String()),
)
rows = bind.execute(
sa.select(
receipts.c.id,
receipts.c.event_key,
receipts.c.event_id,
receipts.c.message_id,
).order_by(receipts.c.id)
).mappings()
updates: list[tuple[int, dict[str, str | None]]] = []
target_event_keys: dict[str, int] = {}
for row in rows:
row_id = int(row["id"])
values = {
"event_key": _identifier_digest(row["event_key"], "event-key"),
"event_id": _identifier_digest(row["event_id"], "event-id"),
"message_id": _identifier_digest(row["message_id"], "message-id"),
}
target_event_key = values["event_key"]
if target_event_key is None:
raise RuntimeError(
f"Cannot migrate blank Feishu receipt event_key at row {row_id}"
)
conflicting_row_id = target_event_keys.setdefault(target_event_key, row_id)
if conflicting_row_id != row_id:
raise RuntimeError(
"Cannot migrate colliding Feishu receipt event_key values at "
f"rows {conflicting_row_id} and {row_id}"
)
if any(values[column] != row[column] for column in values):
updates.append((row_id, values))
for row_id, values in updates:
bind.execute(
sa.update(receipts).where(receipts.c.id == row_id).values(**values)
)
def upgrade() -> None:
_migrate_historical_identifiers()
op.add_column(
TABLE_NAME,
sa.Column("event_type", sa.String(length=128), nullable=True),
)
op.add_column(TABLE_NAME, sa.Column("payload", sa.JSON(), nullable=True))
op.add_column(
TABLE_NAME,
sa.Column(
"auto_reply",
sa.Boolean(),
server_default=sa.true(),
nullable=False,
),
)
op.add_column(
TABLE_NAME,
sa.Column(
"status",
sa.String(length=32),
server_default="pending",
nullable=False,
),
)
op.add_column(
TABLE_NAME,
sa.Column(
"attempt_count",
sa.Integer(),
server_default="0",
nullable=False,
),
)
op.add_column(
TABLE_NAME,
sa.Column(
"max_attempts",
sa.Integer(),
server_default="4",
nullable=False,
),
)
op.add_column(TABLE_NAME, sa.Column("last_error", sa.Text(), nullable=True))
op.add_column(
TABLE_NAME,
sa.Column("next_attempt_at", sa.DateTime(), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("locked_until", sa.DateTime(), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("locked_by", sa.String(length=128), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("processed_at", sa.DateTime(), nullable=True),
)
op.add_column(TABLE_NAME, sa.Column("reply_payload", sa.JSON(), nullable=True))
op.add_column(
TABLE_NAME,
sa.Column("reply_status", sa.String(length=32), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column(
"reply_attempt_count",
sa.Integer(),
server_default="0",
nullable=False,
),
)
op.add_column(
TABLE_NAME,
sa.Column("reply_last_error", sa.Text(), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("reply_next_attempt_at", sa.DateTime(), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("reply_locked_until", sa.DateTime(), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("reply_locked_by", sa.String(length=128), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("reply_sent_at", sa.DateTime(), nullable=True),
)
# Historical receipt-only rows have no replayable payload. Treat them as
# completed so deploying the inbox cannot execute old commands.
op.execute(
sa.text(
f"""
UPDATE {TABLE_NAME}
SET status = 'succeeded',
attempt_count = 1,
processed_at = received_at
"""
)
)
for column in INDEX_COLUMNS:
op.create_index(
op.f(f"ix_{TABLE_NAME}_{column}"),
TABLE_NAME,
[column],
unique=False,
)
def downgrade() -> None:
# Identifier digests are intentionally irreversible and remain in place.
for column in reversed(INDEX_COLUMNS):
op.drop_index(
op.f(f"ix_{TABLE_NAME}_{column}"),
table_name=TABLE_NAME,
)
for column in (
"processed_at",
"locked_by",
"locked_until",
"next_attempt_at",
"last_error",
"max_attempts",
"attempt_count",
"status",
"auto_reply",
"payload",
"event_type",
"reply_sent_at",
"reply_locked_by",
"reply_locked_until",
"reply_next_attempt_at",
"reply_last_error",
"reply_attempt_count",
"reply_status",
"reply_payload",
):
op.drop_column(TABLE_NAME, column)