"""add agent MFA fields Revision ID: 023_mfa_fields Revises: 012_sync_remaining_fields Create Date: 2026-06-21 Phase 2.1 task #17: pyotp TOTP 服务 + User MFA 字段 - 新增 mfa_secret 字段(存储 TOTP secret,绑定时生成,首次验证前不算启用) - 新增 mfa_enabled 字段(是否启用 MFA,默认 False) - 新增 mfa_bound_at 字段(首次绑定完成时间,可空) - 新增 mfa_last_verified_at 字段(最近一次验证成功时间,可空) 为什么需要独立字段而非复用早期 otp_*: Phase 2.1 的 MFA 是面向全员(员工 + 坐席)的统一二次认证方案, 与早期仅供 admin 强制 OTP 的 otp_secret / otp_enabled 是两套体系。 字段独立便于后续维护 + 迁移路径清晰。 为什么不破坏现有坐席: - mfa_secret 默认为 NULL,允许已注册坐席不绑定 - mfa_enabled 用 server_default=text('false')(字符串 false,不是 Python False), 否则 Alembic 会写入整数 0 在 PG 里被解读为 truthy """ from alembic import op import sqlalchemy as sa # revision identifiers revision = '023_mfa_fields' down_revision = '012_sync_remaining_fields' branch_labels = None depends_on = None def upgrade() -> None: """添加 4 个 MFA 字段到 agents 表""" # -------------------------------------------------------------------------- # mfa_secret: TOTP 共享密钥(base32,绑定时生成) # 可空,默认 None — 用户没绑定时就是空 # -------------------------------------------------------------------------- op.add_column( 'agents', sa.Column( 'mfa_secret', sa.String(32), nullable=True, comment='MFA TOTP 共享密钥(base32,绑定时生成)', ) ) # -------------------------------------------------------------------------- # mfa_enabled: 是否启用 MFA # 非空,默认 False # server_default 必须用 text('false') 字符串形式(PG 把 false 解析为布尔 false) # 直接传 sa.text('False') 或 Python False 会被 SQLAlchemy 当成 truthy 写出 '1' # 详见 memory: feedback-adopted-default-bug.md # -------------------------------------------------------------------------- op.add_column( 'agents', sa.Column( 'mfa_enabled', sa.Boolean(), nullable=False, server_default=sa.text('false'), comment='MFA 是否启用(False/True)', ) ) # -------------------------------------------------------------------------- # mfa_bound_at: 首次绑定完成时间(可空) # -------------------------------------------------------------------------- op.add_column( 'agents', sa.Column( 'mfa_bound_at', sa.DateTime(timezone=True), nullable=True, comment='MFA 首次绑定完成时间', ) ) # -------------------------------------------------------------------------- # mfa_last_verified_at: 最近一次验证成功时间(可空,审计用) # -------------------------------------------------------------------------- op.add_column( 'agents', sa.Column( 'mfa_last_verified_at', sa.DateTime(timezone=True), nullable=True, comment='MFA 最近一次验证成功时间', ) ) def downgrade() -> None: """删除 4 个 MFA 字段(按添加的逆序)""" op.drop_column('agents', 'mfa_last_verified_at') op.drop_column('agents', 'mfa_bound_at') op.drop_column('agents', 'mfa_enabled') op.drop_column('agents', 'mfa_secret')