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