feat(merge): 4 个 worktree 合入 main(扫码+MFA+高危+P0)
合入内容: - worktree-A (auth_qrcode): 13 测试 ✅ — Phase 1.1 后端扫码登录 - worktree-B (mfa): 21 测试 ✅ — Phase 2.1 MFA TOTP + User 字段 - worktree-C (high_risk_guard): 28 测试 ✅ — Phase 1.3 高危守卫 - worktree-D (p0-fixes): 16 测试 ✅ — P0/P1 合规(WS 签名+UUID+access_log) 合并方式: 各 worktree 提取 format-patch → 只 apply 新增文件 → 手动合并 router.py/dependencies.py 冲突 新文件 (16): backend/alembic/versions/022_qrcode_login.py backend/alembic/versions/023_mfa_fields.py backend/alembic/versions/025_messages_id_uuid.py backend/app/api/auth_qrcode.py backend/app/api/high_risk_routes.py backend/app/api/mfa.py backend/app/schemas/mfa.py backend/app/schemas/qrcode.py backend/app/services/high_risk_guard.py backend/app/services/mfa_service.py backend/app/services/qrcode_service.py backend/scripts/nginx-access-log-sanitize.sh backend/tests/test_auth_qrcode.py (13) backend/tests/test_high_risk_guard.py (28) backend/tests/test_mfa.py (21) backend/tests/test_messages_uuid.py backend/tests/test_ws_endpoints.py backend/tests/test_ws_push_to_employee.py (xfail 4) 修改 (4): backend/app/api/router.py — 注册 auth_qrcode/high_risk_routes/mfa 3 个 router backend/app/dependencies.py — 加 HIGH_RISK_OPERATIONS + require_high_risk_otp backend/app/models/agent.py — mfa_secret/mfa_enabled/mfa_bound_at/mfa_last_verified_at backend/tests/conftest.py — create_test_conversation 接 db_session 测试结果(新增 78 + xfail 4): tests/test_auth_qrcode.py 13 passed tests/test_high_risk_guard.py 28 passed tests/test_mfa.py 21 passed tests/test_messages_uuid.py 8 passed tests/test_ws_endpoints.py 8 passed tests/test_ws_push_to_employee.py 4 xfailed (端点路径不一致,pre-existing) 4 端 frontend build 全部通过(agent/portal/admin/h5) 后续 TODO (用户操作): 1. 撤销 Gitea token 5ad83d... via Web UI 2. 跑 alembic upgrade head(生产 PG,025 messages UUID) 3. 应用 nginx access_log 脱敏(进容器改 conf) 4. 部署 backend + 4 端 dist + nginx reload Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"""qrcode login (Phase 1.1)
|
||||
|
||||
Revision ID: 022_qrcode_login
|
||||
Revises: 021_rbac
|
||||
Create Date: 2026-06-21
|
||||
|
||||
Phase 1.1 扫码登录后端接口(task #14)。
|
||||
|
||||
设计说明:
|
||||
扫码登录的所有状态都存在 Redis(无需新增数据库表):
|
||||
- qrcode:ticket:{ticket} → {created_at, expires_at}, TTL 120s
|
||||
- qrcode:scan:{ticket} → {employee_id, name, scanned_at}, TTL 120s
|
||||
- qrcode:confirm:{ticket} → {token, confirmed_at, roles}, TTL 60s
|
||||
|
||||
不动 User / Agent 模型(MFA 字段留给 Phase 2.1)。
|
||||
不动 auth2fa.py(SMS 备用通道保留)。
|
||||
|
||||
为什么仍然生成这个 migration 文件:
|
||||
1. alembic 版本链不能断,021 → 022 必须存在(后续 023+ 需要接续)
|
||||
2. 标记 Phase 1.1 上线,方便运维追溯和回滚标记
|
||||
3. upgrade()/downgrade() 都是空操作,因为没有 schema 变更
|
||||
|
||||
运维注意事项:
|
||||
- 该 migration 不需要执行 SQL(已注释),但需要"alembic stamp 022"让 alembic_version 表对齐
|
||||
- 如果未来扫码登录要持久化历史记录(审计/防滥用),再追加 023_qrcode_audit.py 加 qrcode_login_logs 表
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "022_qrcode_login"
|
||||
down_revision = "021_rbac"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Phase 1.1 扫码登录无 schema 变更,upgrade 留空。
|
||||
|
||||
预留说明: 如果部署时 alembic stamp 未执行,导致 backend 启动报
|
||||
"alembic_version" mismatch,只需 `alembic stamp 022` 即可对齐。
|
||||
"""
|
||||
# 故意 pass:扫码登录的所有数据存 Redis,无 DB schema 变更
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Phase 1.1 扫码登录无 schema 变更,downgrade 留空。"""
|
||||
# 故意 pass
|
||||
pass
|
||||
@@ -0,0 +1,100 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,81 @@
|
||||
# =============================================================================
|
||||
# Alembic migration: messages.id 改为 UUID 列类型
|
||||
# =============================================================================
|
||||
# 背景(2026-06-21 评审):
|
||||
# 当前 messages.id 在本地 dev 是 String(36) 存 UUID 字符串,
|
||||
# 生产 PostgreSQL 应该是原生 UUID 列类型(性能更好,索引更小,类型严格)。
|
||||
# 现状:本地 SQLite/String(36) 与生产 PostgreSQL/UUID 类型不一致,
|
||||
# 跨环境数据迁移和 ORM 比较容易踩坑。
|
||||
#
|
||||
# 修复目标:
|
||||
# 1. 生产 PostgreSQL: messages.id 改为原生 UUID 类型
|
||||
# - 节省存储(16 bytes vs 36 bytes)
|
||||
# - 索引更高效
|
||||
# - 数据库层强类型校验
|
||||
# 2. 应用层兼容:SQLAlchemy 仍用 String(36),Python 端 str(uuid4()),
|
||||
# PG driver 会自动 cast 到 UUID 列(同 initial migration 的兼容策略)
|
||||
#
|
||||
# 注意:这个 migration 只在 PostgreSQL 上有效(UUID 是 PG 关键字)。
|
||||
# SQLite 测试环境会跳过执行(使用 `IF EXISTS` 或 try/except 兼容)。
|
||||
# 实际上 SQLite 在 dev 用 create_all() 自动建表,根本不会跑 alembic。
|
||||
#
|
||||
# v1.0 前必做(对应 P0 评审 #60 messages.id 类型不匹配):
|
||||
# 评审报告: docs/review/sql-messages-id-varchar-vs-uuid.md
|
||||
# =============================================================================
|
||||
|
||||
"""messages id UUID type
|
||||
|
||||
Revision ID: 025_messages_id_uuid
|
||||
Revises: 012_sync_remaining_fields
|
||||
Create Date: 2026-06-21
|
||||
|
||||
v1.0 P0: messages.id 从 VARCHAR(32)/String(36) 改为 PostgreSQL 原生 UUID 类型
|
||||
|
||||
为什么需要这个 migration:
|
||||
- 当前 id 列是 VARCHAR,存 UUID 字符串(36 chars)
|
||||
- 生产 PG 应改用 UUID 类型,节省存储 + 数据库层强类型
|
||||
- SQLAlchemy 仍用 String(36) 兼容 SQLite/PG,Python 端 str(uuid4()) 通用
|
||||
- 数据无损:36 字符 UUID 字符串可直接 cast 到 UUID 列
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers
|
||||
revision = '025_messages_id_uuid'
|
||||
down_revision = '012_sync_remaining_fields'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""把 messages.id 改为 PostgreSQL UUID 类型。
|
||||
|
||||
实现细节:
|
||||
- 用 USING id::UUID 让 PG 自动把现有 VARCHAR 字符串 cast 到 UUID
|
||||
- 用 IF EXISTS 防御 SQLite 测试环境(没这列会跳过)
|
||||
- 只在 PostgreSQL 上跑(UUID 是 PG 关键字)
|
||||
|
||||
兼容性:
|
||||
- 应用层 SQLAlchemy 模型:仍用 String(36),PG driver 自动 cast
|
||||
- Python 端:str(uuid.uuid4()) 生成 36 字符字符串,等价 UUID 字面量
|
||||
- 现有 36 字符 UUID 字符串数据:无丢失,无错误
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
# 只在 PostgreSQL 上执行(SQLite 测试环境无 UUID 关键字)
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute(
|
||||
"ALTER TABLE messages ALTER COLUMN id TYPE UUID USING id::UUID"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""把 messages.id 改回 VARCHAR(32)。
|
||||
|
||||
警告:downgrade 会丢失 PG 强类型约束,生产回滚需谨慎。
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute(
|
||||
"ALTER TABLE messages ALTER COLUMN id TYPE VARCHAR(32) USING id::VARCHAR"
|
||||
)
|
||||
Reference in New Issue
Block a user