"""Seed the missing ``feishu_group`` software-provisioning integration row. Revision ID: 110 Revises: 109 Create Date: 2026-05-02 Hot-fix for migration 109. The 109 docstring claimed an ``integrations`` row with ``provider='feishu_group'`` "already exists in every deploy from migration 028 / 034" — that was wrong. Migration 028 only registers ``feishu_group`` in the connector-binding *validation* mapping (the dict that says "this provider expects these config keys"); it never INSERTs an actual row. None of the other migrations seed it either. Result: ``feishu.seat_sync`` returned ``"Feishu software-provisioning integration … not configured"`` on prod even after migration 109 had run, because the coordinator's ``SELECT … WHERE provider='feishu_group'`` matched zero rows. This migration mirrors the seed pattern used by ``077`` (Jira), ``056`` (Slack), and ``051`` (Zoom): an empty integration row with ``enabled=0`` so the admin can fill in app_id + app_secret through ``/admin/integrations`` and flip enabled themselves. Pre-minted UUID because TiDB's ``UUID()`` is unreliable in DDL paths (precedent from the same Jira / Bailian seeds). Idempotent: re-running is a no-op once the row exists. Why a separate migration instead of editing 109? 109 has already run on prod (it's part of the merged b4b4b20 deploy); editing 109's ``upgrade`` body now would not re-execute on systems that already recorded 109 as applied in ``alembic_version``. The chain-forward fix is the only reliable shape. """ from __future__ import annotations import uuid import sqlalchemy as sa from alembic import op revision = "110" down_revision = "109" branch_labels = None depends_on = None _INTEGRATION_SLUG = "feishu-group-primary" def upgrade() -> None: bind = op.get_bind() # Idempotency: if an admin (or a future hot-fix run) already # created a feishu_group integration row by hand, leave it alone — # we don't want to clobber credentials they may have entered. existing = bind.execute( sa.text( "SELECT id FROM integrations " "WHERE kind = 'software_provisioning' " " AND provider = 'feishu_group' " "LIMIT 1" ), ).scalar() if existing is not None: return integration_id = str(uuid.uuid4()) bind.execute( sa.text( "INSERT INTO integrations " "(id, kind, provider, name, slug, description, " " enabled, is_primary, " " config_public, secrets_encrypted, secrets_nonce) " "VALUES " "(:id, 'software_provisioning', 'feishu_group', :name, :slug, :desc, " " 0, 1, '{}', NULL, NULL)" ), { "id": integration_id, "name": "飞书软件供给 (Feishu user-group / account)", "slug": _INTEGRATION_SLUG, "desc": ( "飞书 SaaS 访问的 software-provisioning 入口。和 " "kind='chat_approval' / provider='feishu' 的审批 integration " "是**两个独立的行** —— 那行用来发审批卡片,这行用来管坐席。" "需要单独的 app_id + app_secret(可以复用同一个飞书自建应用," "但权限范围不同:这里需要 contact:user.employee_id:readonly + " "contact:group.member:readonly + contact:user.email:readonly + " "contact:group.member:write,审批那个需要 approval:approval)。" "一个 integration 行可挂多个 software_connectors,每个软件一个 " "binding(operation=group_member 时填 group_chat_id;" "operation=user_suspend 用于离职二次保障,无额外参数)。" "feishu.seat_sync 每天读 group_member 模式的 binding 反向同步" "坐席。配好凭据后把 enabled 翻成 1。" ), }, ) def downgrade() -> None: """Conservative downgrade — drop only the seed row, and only when it's still empty (no admin-entered secrets). If an admin has filled in app_id / app_secret since the migration ran, ``secrets_encrypted`` will be non-NULL; in that case the seed has been "claimed" by real credentials and we leave it alone to avoid losing production state on a roll-back drill. """ bind = op.get_bind() bind.execute( sa.text( "DELETE FROM integrations " "WHERE slug = :s " " AND kind = 'software_provisioning' " " AND provider = 'feishu_group' " " AND secrets_encrypted IS NULL" ), {"s": _INTEGRATION_SLUG}, )