"""RBAC 角色权限基础表 Revision ID: 021_rbac Revises: 012_sync_remaining_fields Create Date: 2026-06-22 (v0.7.1 重建) v0.7.1 重建原因: 022_qrcode_login 的 down_revision 指向 021_rbac 但原文件丢失 本 migration 重建 RBAC 三张表 + 预置 3 角色 + 索引: - roles 角色定义 - user_roles 用户-角色多对多 - role_mapping_rules 自动映射规则(企微标签 / eHR 字段) 使用 IF NOT EXISTS 兼容"生产数据库已建表"的情况: - 如果生产 alembic 已 stamp 022 跳过 021(且表已存在),则 upgrade 是 noop - 如果生产跑过 021 但文件丢了,upgrade 是 noop - 只有全新环境才真正建表 下游: - 022_qrcode_login / 023_mfa_fields / 025_messages_id_uuid / 026_drop_agent_otp_legacy - 都在 021 之后(022 改为 down_revision="021_rbac") 预置数据: - user 角色 (is_default=True, 所有在职员工自动获得) - agent 角色 (IT坐席) - admin 角色 (管理员, is_default=False) """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '021_rbac' down_revision = '012_sync_remaining_fields' branch_labels = None depends_on = None def upgrade() -> None: """重建 RBAC 三张表(IF NOT EXISTS 兼容)。""" bind = op.get_bind() inspector = sa.inspect(bind) # ---------------------------------------------------------------------- # 1. roles 表 # ---------------------------------------------------------------------- if not inspector.has_table('roles'): op.create_table( 'roles', sa.Column('id', sa.String(36), primary_key=True), sa.Column('name', sa.String(50), unique=True, nullable=False, comment='角色标识:user/agent/admin'), sa.Column('display_name', sa.String(100), nullable=False, comment='显示名称:用户/坐席/管理员'), sa.Column('description', sa.Text, nullable=True, comment='角色描述'), sa.Column('permissions', sa.JSON, nullable=False, default=list, comment='权限列表(JSON数组)'), sa.Column('is_default', sa.Boolean, nullable=False, default=False, comment='是否默认角色(所有员工自动获得)'), sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='更新时间'), ) # ---------------------------------------------------------------------- # 2. user_roles 表 # ---------------------------------------------------------------------- if not inspector.has_table('user_roles'): op.create_table( 'user_roles', sa.Column('id', sa.String(36), primary_key=True), sa.Column('employee_id', sa.String(100), nullable=False, comment='企微 UserID'), sa.Column('role_id', sa.String(36), sa.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, comment='角色 ID'), sa.Column('source', sa.String(50), nullable=False, comment='角色来源:auto/tag/ehr/manual'), sa.Column('assigned_by', sa.String(100), nullable=True, comment='分配者(手动分配时记录操作人)'), sa.Column('assigned_at', sa.DateTime(timezone=True), nullable=False, comment='分配时间'), sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True, comment='过期时间(可选,用于临时角色)'), sa.UniqueConstraint('employee_id', 'role_id', name='uq_user_role'), ) op.create_index('idx_user_roles_employee_id', 'user_roles', ['employee_id']) op.create_index('idx_user_roles_role_id', 'user_roles', ['role_id']) # ---------------------------------------------------------------------- # 3. role_mapping_rules 表 # ---------------------------------------------------------------------- if not inspector.has_table('role_mapping_rules'): op.create_table( 'role_mapping_rules', sa.Column('id', sa.String(36), primary_key=True), sa.Column('role_id', sa.String(36), sa.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, comment='目标角色 ID'), sa.Column('source_type', sa.String(50), nullable=False, comment='来源类型:wecom_tag/ehr_position'), sa.Column('source_value', sa.String(200), nullable=False, comment='来源值:标签名/岗位关键词'), sa.Column('priority', sa.Integer, nullable=False, default=0, comment='优先级(数值越大优先级越高)'), sa.Column('is_active', sa.Boolean, nullable=False, default=True, comment='是否启用'), sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'), ) op.create_index('idx_role_mapping_rules_role_id', 'role_mapping_rules', ['role_id']) op.create_index('idx_role_mapping_rules_source_type', 'role_mapping_rules', ['source_type']) def downgrade() -> None: """删除 RBAC 三张表(顺序: 子表 → 父表)。""" op.drop_table('role_mapping_rules') op.drop_table('user_roles') op.drop_table('roles')