```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能 - 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避 - 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐 - 增加运行组件心跳检测和readiness就绪检查机制 - 实现app_ticket事件的安全轮换和验证处理 - 添加生产环境运行编排和fail-closed安全机制 - 支持webhook快速确认和长连接独立进程处理 - 完善个人数据擦除时的待处理事件清理功能 ```
This commit is contained in:
@@ -5,6 +5,7 @@ from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base
|
||||
from app.core.database.safety import validate_platform_migration_target
|
||||
from app.modules.ai_memory import models as ai_memory_models
|
||||
from app.modules.audit import models as audit_models
|
||||
from app.modules.business import models as business_models
|
||||
@@ -23,6 +24,10 @@ if config.config_file_name is not None:
|
||||
|
||||
target_metadata = Base.metadata
|
||||
settings = get_settings()
|
||||
validate_platform_migration_target(
|
||||
settings.database_url,
|
||||
settings.legacy_database_url,
|
||||
)
|
||||
|
||||
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
|
||||
_REGISTERED_MODEL_MODULES = (
|
||||
@@ -52,6 +57,20 @@ def run_migrations_offline() -> None:
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
supplied_connection = config.attributes.get("connection")
|
||||
if supplied_connection is not None:
|
||||
validate_platform_migration_target(
|
||||
supplied_connection.engine.url,
|
||||
settings.legacy_database_url,
|
||||
)
|
||||
context.configure(
|
||||
connection=supplied_connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
return
|
||||
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = settings.database_url
|
||||
connectable = engine_from_config(
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
"""Reconcile the known unversioned PostgreSQL schema.
|
||||
|
||||
Revision ID: 202607270001
|
||||
Revises: 202607260005
|
||||
Create Date: 2026-07-27
|
||||
|
||||
This revision is intentionally idempotent. A normally versioned database at
|
||||
202607260005 only receives the PostgreSQL NULLS NOT DISTINCT hardening. The
|
||||
one-time baseline runner may also use it, in the same transaction as an atomic
|
||||
stamp, to repair the explicitly allowlisted mixed schema.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "202607270001"
|
||||
down_revision = "202607260005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
AI_MEMORY_TABLE = "ai_memory_entries"
|
||||
MARKET_WATCHLIST_TABLE = "market_watchlists"
|
||||
APPROVAL_TABLE = "approval_requests"
|
||||
|
||||
AI_MEMORY_OWNER_UNIQUE = "uq_ai_memory_owner_fingerprint"
|
||||
MARKET_WATCHLIST_OWNER_UNIQUE = "uq_market_watchlist_owner_symbol"
|
||||
MARKET_WATCHLIST_LEGACY_UNIQUE = "uq_market_watchlist_actor_symbol"
|
||||
|
||||
|
||||
def _inspector() -> sa.Inspector:
|
||||
return sa.inspect(op.get_bind())
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return table_name in _inspector().get_table_names()
|
||||
|
||||
|
||||
def _column_names(table_name: str) -> set[str]:
|
||||
return {
|
||||
str(column["name"])
|
||||
for column in _inspector().get_columns(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _add_missing_columns(
|
||||
table_name: str,
|
||||
columns: Iterable[sa.Column],
|
||||
) -> None:
|
||||
existing = _column_names(table_name)
|
||||
for column in columns:
|
||||
if column.name not in existing:
|
||||
op.add_column(table_name, column)
|
||||
|
||||
|
||||
def _index_by_name(table_name: str, index_name: str) -> dict | None:
|
||||
return next(
|
||||
(
|
||||
index
|
||||
for index in _inspector().get_indexes(table_name)
|
||||
if index.get("name") == index_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_index(
|
||||
table_name: str,
|
||||
index_name: str,
|
||||
columns: tuple[str, ...],
|
||||
*,
|
||||
unique: bool = False,
|
||||
) -> None:
|
||||
existing = _index_by_name(table_name, index_name)
|
||||
if existing is not None:
|
||||
existing_columns = tuple(existing.get("column_names") or ())
|
||||
if existing_columns != columns or bool(existing.get("unique")) != unique:
|
||||
raise RuntimeError(
|
||||
f"Existing index {index_name} does not match the reconciliation definition"
|
||||
)
|
||||
return
|
||||
op.create_index(index_name, table_name, list(columns), unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_present(table_name: str, index_name: str) -> None:
|
||||
if _index_by_name(table_name, index_name) is not None:
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def _unique_by_name(table_name: str, constraint_name: str) -> dict | None:
|
||||
return next(
|
||||
(
|
||||
constraint
|
||||
for constraint in _inspector().get_unique_constraints(table_name)
|
||||
if constraint.get("name") == constraint_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _drop_unique_if_present(table_name: str, constraint_name: str) -> None:
|
||||
if _unique_by_name(table_name, constraint_name) is None:
|
||||
return
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.drop_constraint(constraint_name, type_="unique")
|
||||
|
||||
|
||||
def _uses_nulls_not_distinct(constraint: dict) -> bool:
|
||||
options = constraint.get("dialect_options") or {}
|
||||
return bool(options.get("postgresql_nulls_not_distinct"))
|
||||
|
||||
|
||||
def _ensure_owner_unique(
|
||||
table_name: str,
|
||||
constraint_name: str,
|
||||
columns: tuple[str, ...],
|
||||
) -> None:
|
||||
existing = _unique_by_name(table_name, constraint_name)
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if existing is not None:
|
||||
if tuple(existing.get("column_names") or ()) != columns:
|
||||
raise RuntimeError(
|
||||
f"Existing constraint {constraint_name} does not match the reconciliation definition"
|
||||
)
|
||||
if dialect_name != "postgresql" or _uses_nulls_not_distinct(existing):
|
||||
return
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.drop_constraint(constraint_name, type_="unique")
|
||||
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.create_unique_constraint(
|
||||
constraint_name,
|
||||
list(columns),
|
||||
postgresql_nulls_not_distinct=True,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_foreign_key(
|
||||
table_name: str,
|
||||
constraint_name: str,
|
||||
local_columns: tuple[str, ...],
|
||||
remote_table: str,
|
||||
remote_columns: tuple[str, ...],
|
||||
) -> None:
|
||||
for foreign_key in _inspector().get_foreign_keys(table_name):
|
||||
if (
|
||||
tuple(foreign_key.get("constrained_columns") or ()) == local_columns
|
||||
and foreign_key.get("referred_table") == remote_table
|
||||
and tuple(foreign_key.get("referred_columns") or ()) == remote_columns
|
||||
):
|
||||
ondelete = str((foreign_key.get("options") or {}).get("ondelete") or "")
|
||||
if ondelete.upper() != "CASCADE":
|
||||
raise RuntimeError(
|
||||
f"Existing foreign key on {table_name} does not use ON DELETE CASCADE"
|
||||
)
|
||||
return
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.create_foreign_key(
|
||||
constraint_name,
|
||||
remote_table,
|
||||
list(local_columns),
|
||||
list(remote_columns),
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
|
||||
def _alter_without_server_default(
|
||||
table_name: str,
|
||||
columns: tuple[tuple[str, sa.types.TypeEngine], ...],
|
||||
) -> None:
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
for column_name, column_type in columns:
|
||||
batch_op.alter_column(
|
||||
column_name,
|
||||
existing_type=column_type,
|
||||
nullable=False,
|
||||
server_default=None,
|
||||
)
|
||||
|
||||
|
||||
def _add_business_columns() -> None:
|
||||
_add_missing_columns(
|
||||
"attendance_records",
|
||||
(
|
||||
sa.Column(
|
||||
"attendance_scope",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default="company",
|
||||
),
|
||||
sa.Column("source_status", sa.String(length=64), nullable=True),
|
||||
sa.Column("source_location_status", sa.String(length=64), nullable=True),
|
||||
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
),
|
||||
)
|
||||
_add_missing_columns(
|
||||
"audit_logs",
|
||||
(sa.Column("request_id", sa.String(length=64), nullable=True),),
|
||||
)
|
||||
_add_missing_columns(
|
||||
"projects",
|
||||
(
|
||||
sa.Column("display_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("department_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("department_name", sa.String(length=128), nullable=True),
|
||||
sa.Column("owner_employee_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("source_stage", sa.String(length=32), nullable=True),
|
||||
sa.Column("source_stage_label", sa.String(length=128), nullable=True),
|
||||
sa.Column(
|
||||
"source_archived",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
sa.Column("source_created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
sa.Column(
|
||||
"source_contract_amount",
|
||||
sa.Numeric(precision=16, scale=2),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"source_project_investment_amount",
|
||||
sa.Numeric(precision=16, scale=2),
|
||||
nullable=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
_add_missing_columns(
|
||||
"risk_events",
|
||||
(
|
||||
sa.Column("assigned_to", sa.String(length=128), nullable=True),
|
||||
sa.Column("resolved_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("closed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("closed_reason", sa.Text(), nullable=True),
|
||||
sa.Column("review_summary", sa.Text(), nullable=True),
|
||||
),
|
||||
)
|
||||
_add_missing_columns(
|
||||
"work_reports",
|
||||
(
|
||||
sa.Column("employee_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("external_id", sa.String(length=128), nullable=True),
|
||||
sa.Column(
|
||||
"is_late",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
sa.Column(
|
||||
"is_draft",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
),
|
||||
)
|
||||
_add_missing_columns(
|
||||
"work_tasks",
|
||||
(
|
||||
sa.Column(
|
||||
"source_system",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
server_default="internal",
|
||||
),
|
||||
sa.Column("external_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("employee_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("source_created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE attendance_records
|
||||
SET attendance_scope = 'company'
|
||||
WHERE attendance_scope IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE attendance_records
|
||||
SET is_active = true
|
||||
WHERE is_active IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE projects
|
||||
SET source_archived = false
|
||||
WHERE source_archived IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE projects
|
||||
SET is_active = true
|
||||
WHERE is_active IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE work_reports
|
||||
SET is_late = false
|
||||
WHERE is_late IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE work_reports
|
||||
SET is_draft = false
|
||||
WHERE is_draft IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE work_reports
|
||||
SET is_active = true
|
||||
WHERE is_active IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE work_tasks
|
||||
SET source_system = 'internal'
|
||||
WHERE source_system IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE work_tasks
|
||||
SET is_active = true
|
||||
WHERE is_active IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
_alter_without_server_default(
|
||||
"attendance_records",
|
||||
(
|
||||
("attendance_scope", sa.String(length=32)),
|
||||
("is_active", sa.Boolean()),
|
||||
),
|
||||
)
|
||||
_alter_without_server_default(
|
||||
"projects",
|
||||
(
|
||||
("source_archived", sa.Boolean()),
|
||||
("is_active", sa.Boolean()),
|
||||
),
|
||||
)
|
||||
_alter_without_server_default(
|
||||
"work_reports",
|
||||
(
|
||||
("is_late", sa.Boolean()),
|
||||
("is_draft", sa.Boolean()),
|
||||
("is_active", sa.Boolean()),
|
||||
),
|
||||
)
|
||||
_alter_without_server_default(
|
||||
"work_tasks",
|
||||
(
|
||||
("source_system", sa.String(length=64)),
|
||||
("is_active", sa.Boolean()),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_business_indexes() -> None:
|
||||
definitions = {
|
||||
"attendance_records": (
|
||||
"attendance_scope",
|
||||
"is_active",
|
||||
"last_seen_at",
|
||||
"source_status",
|
||||
"source_updated_at",
|
||||
),
|
||||
"audit_logs": ("request_id",),
|
||||
"projects": (
|
||||
"department_code",
|
||||
"department_name",
|
||||
"display_code",
|
||||
"is_active",
|
||||
"last_seen_at",
|
||||
"owner_employee_code",
|
||||
"source_archived",
|
||||
"source_stage",
|
||||
"source_updated_at",
|
||||
),
|
||||
"risk_events": ("assigned_to",),
|
||||
"work_reports": (
|
||||
"employee_code",
|
||||
"external_id",
|
||||
"is_active",
|
||||
"is_draft",
|
||||
"is_late",
|
||||
"last_seen_at",
|
||||
"source_updated_at",
|
||||
),
|
||||
"work_tasks": (
|
||||
"employee_code",
|
||||
"external_id",
|
||||
"is_active",
|
||||
"last_seen_at",
|
||||
"source_updated_at",
|
||||
),
|
||||
}
|
||||
for table_name, columns in definitions.items():
|
||||
for column_name in columns:
|
||||
_ensure_index(
|
||||
table_name,
|
||||
f"ix_{table_name}_{column_name}",
|
||||
(column_name,),
|
||||
)
|
||||
|
||||
|
||||
def _reconcile_ai_memory() -> None:
|
||||
_add_missing_columns(
|
||||
AI_MEMORY_TABLE,
|
||||
(
|
||||
sa.Column("owner_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"kind",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default="memory",
|
||||
),
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE ai_memory_entries
|
||||
SET
|
||||
owner_id = NULL,
|
||||
kind = 'company_rule',
|
||||
source = 'legacy_company',
|
||||
status = 'active'
|
||||
WHERE source = 'user_rule'
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE ai_memory_entries
|
||||
SET kind = 'memory', status = 'archived'
|
||||
WHERE owner_id IS NULL AND source IN ('auto', 'hermes')
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE ai_memory_entries
|
||||
SET kind = 'memory'
|
||||
WHERE kind IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
_alter_without_server_default(
|
||||
AI_MEMORY_TABLE,
|
||||
(("kind", sa.String(length=32)),),
|
||||
)
|
||||
|
||||
fingerprint_index = _index_by_name(
|
||||
AI_MEMORY_TABLE,
|
||||
"ix_ai_memory_entries_fingerprint",
|
||||
)
|
||||
if fingerprint_index is not None and bool(fingerprint_index.get("unique")):
|
||||
op.drop_index(
|
||||
"ix_ai_memory_entries_fingerprint",
|
||||
table_name=AI_MEMORY_TABLE,
|
||||
)
|
||||
_ensure_index(
|
||||
AI_MEMORY_TABLE,
|
||||
"ix_ai_memory_entries_fingerprint",
|
||||
("fingerprint",),
|
||||
)
|
||||
_ensure_index(
|
||||
AI_MEMORY_TABLE,
|
||||
"ix_ai_memory_entries_kind",
|
||||
("kind",),
|
||||
)
|
||||
_ensure_index(
|
||||
AI_MEMORY_TABLE,
|
||||
"ix_ai_memory_entries_owner_id",
|
||||
("owner_id",),
|
||||
)
|
||||
_ensure_foreign_key(
|
||||
AI_MEMORY_TABLE,
|
||||
"fk_ai_memory_entries_owner_id",
|
||||
("owner_id",),
|
||||
"feishu_users",
|
||||
("id",),
|
||||
)
|
||||
_ensure_owner_unique(
|
||||
AI_MEMORY_TABLE,
|
||||
AI_MEMORY_OWNER_UNIQUE,
|
||||
("owner_id", "fingerprint"),
|
||||
)
|
||||
|
||||
|
||||
def _reconcile_market_watchlists() -> None:
|
||||
_add_missing_columns(
|
||||
MARKET_WATCHLIST_TABLE,
|
||||
(sa.Column("owner_id", sa.Integer(), nullable=True),),
|
||||
)
|
||||
_drop_unique_if_present(
|
||||
MARKET_WATCHLIST_TABLE,
|
||||
MARKET_WATCHLIST_LEGACY_UNIQUE,
|
||||
)
|
||||
_ensure_index(
|
||||
MARKET_WATCHLIST_TABLE,
|
||||
"ix_market_watchlists_owner_id",
|
||||
("owner_id",),
|
||||
)
|
||||
_ensure_foreign_key(
|
||||
MARKET_WATCHLIST_TABLE,
|
||||
"fk_market_watchlists_owner_id",
|
||||
("owner_id",),
|
||||
"feishu_users",
|
||||
("id",),
|
||||
)
|
||||
_ensure_owner_unique(
|
||||
MARKET_WATCHLIST_TABLE,
|
||||
MARKET_WATCHLIST_OWNER_UNIQUE,
|
||||
("owner_id", "symbol"),
|
||||
)
|
||||
|
||||
|
||||
def _create_feishu_app_tickets_if_missing() -> None:
|
||||
if not _table_exists("feishu_app_tickets"):
|
||||
op.create_table(
|
||||
"feishu_app_tickets",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("app_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("app_ticket", sa.Text(), nullable=False),
|
||||
sa.Column("received_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_ensure_index(
|
||||
"feishu_app_tickets",
|
||||
"ix_feishu_app_tickets_app_id",
|
||||
("app_id",),
|
||||
unique=True,
|
||||
)
|
||||
_ensure_index(
|
||||
"feishu_app_tickets",
|
||||
"ix_feishu_app_tickets_received_at",
|
||||
("received_at",),
|
||||
)
|
||||
|
||||
|
||||
def _create_admin_tombstones_if_missing() -> None:
|
||||
if not _table_exists("feishu_admin_bootstrap_tombstones"):
|
||||
op.create_table(
|
||||
"feishu_admin_bootstrap_tombstones",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("identity_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_ensure_index(
|
||||
"feishu_admin_bootstrap_tombstones",
|
||||
"ix_feishu_admin_bootstrap_tombstones_created_at",
|
||||
("created_at",),
|
||||
)
|
||||
_ensure_index(
|
||||
"feishu_admin_bootstrap_tombstones",
|
||||
"ix_feishu_admin_bootstrap_tombstones_identity_hash",
|
||||
("identity_hash",),
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def _drop_empty_approval_table() -> None:
|
||||
if not _table_exists(APPROVAL_TABLE):
|
||||
return
|
||||
count = int(
|
||||
op.get_bind().execute(
|
||||
sa.text("SELECT COUNT(*) FROM approval_requests")
|
||||
).scalar_one()
|
||||
)
|
||||
if count:
|
||||
raise RuntimeError(
|
||||
"Refusing to remove approval_requests because it contains rows"
|
||||
)
|
||||
for table_name in _inspector().get_table_names():
|
||||
for foreign_key in _inspector().get_foreign_keys(table_name):
|
||||
if foreign_key.get("referred_table") == APPROVAL_TABLE:
|
||||
raise RuntimeError(
|
||||
"Refusing to remove approval_requests because a foreign key depends on it"
|
||||
)
|
||||
op.drop_table(APPROVAL_TABLE)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_add_business_columns()
|
||||
_ensure_business_indexes()
|
||||
_reconcile_ai_memory()
|
||||
_reconcile_market_watchlists()
|
||||
_create_feishu_app_tickets_if_missing()
|
||||
_create_admin_tombstones_if_missing()
|
||||
_drop_empty_approval_table()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError(
|
||||
"Revision 202607270001 is irreversible: it classifies legacy data, "
|
||||
"adds ownership relationships, and removes the obsolete approval table. "
|
||||
"Restore a verified database backup instead of downgrading."
|
||||
)
|
||||
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