```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能 - 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避 - 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐 - 增加运行组件心跳检测和readiness就绪检查机制 - 实现app_ticket事件的安全轮换和验证处理 - 添加生产环境运行编排和fail-closed安全机制 - 支持webhook快速确认和长连接独立进程处理 - 完善个人数据擦除时的待处理事件清理功能 ```
This commit is contained in:
251
alembic/versions/202607270002_feishu_inbound_event_inbox.py
Normal file
251
alembic/versions/202607270002_feishu_inbound_event_inbox.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user