feat(backend): add automatic DB migrations

Add a lightweight migration runner with schema_migrations tracking, run pending migrations during backend startup before the scheduler, and keep a manual backend-migrate entrypoint.

The change also moves the existing lockout and task-thread-ID schema steps into shared migration modules, updates docs, and archives the OpenSpec change.
This commit is contained in:
2026-05-05 01:36:58 +08:00
parent e243dccfd7
commit 3ab845798d
21 changed files with 911 additions and 145 deletions
@@ -1,69 +1,26 @@
"""
数据库迁移脚本:添加账户锁定相关字段
数据库迁移脚本:添加账户锁定相关字段
添加字段
- failed_login_attempts: 连续登录失败次数
- locked_until: 账户锁定到期时间
- last_failed_login: 最后一次登录失败时间
运行方式:
通常无需手动运行,后端启动时会自动执行待迁移项。需要单独执行时
uv run python -m backend.scripts.migrate_add_account_lockout
"""
import sys
from pathlib import Path
from __future__ import annotations
APPS_DIR = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(APPS_DIR))
from sqlalchemy import text
from backend.models.database import engine
import logging
import sys
from backend.migration_steps.account_lockout import apply as apply_account_lockout
from backend.models.database import engine
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def migrate():
"""执行迁移"""
def migrate() -> None:
logger.info("开始迁移:添加账户锁定相关字段...")
with engine.connect() as conn:
# 检查字段是否已存在
result = conn.execute(text("PRAGMA table_info(users)"))
columns = [row[1] for row in result]
# 添加 failed_login_attempts 字段
if "failed_login_attempts" not in columns:
logger.info("添加 failed_login_attempts 字段...")
conn.execute(
text(
"ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER DEFAULT 0 NOT NULL"
)
)
conn.commit()
logger.info("✓ failed_login_attempts 字段添加成功")
else:
logger.info("✓ failed_login_attempts 字段已存在,跳过")
# 添加 locked_until 字段
if "locked_until" not in columns:
logger.info("添加 locked_until 字段...")
conn.execute(text("ALTER TABLE users ADD COLUMN locked_until DATETIME"))
conn.commit()
logger.info("✓ locked_until 字段添加成功")
else:
logger.info("✓ locked_until 字段已存在,跳过")
# 添加 last_failed_login 字段
if "last_failed_login" not in columns:
logger.info("添加 last_failed_login 字段...")
conn.execute(text("ALTER TABLE users ADD COLUMN last_failed_login DATETIME"))
conn.commit()
logger.info("✓ last_failed_login 字段添加成功")
else:
logger.info("✓ last_failed_login 字段已存在,跳过")
apply_account_lockout(conn)
logger.info("✅ 迁移完成!账户锁定功能已启用")
@@ -71,5 +28,5 @@ if __name__ == "__main__":
try:
migrate()
except Exception as e:
logger.error(f"❌ 迁移失败: {e}")
logger.error("❌ 迁移失败: %s", e)
sys.exit(1)
@@ -1,107 +1,26 @@
"""
数据库迁移脚本:添加打卡任务 thread_id 字段并回填。
运行方式
通常无需手动运行,后端启动时会自动执行待迁移项。需要单独执行时
uv run python -m backend.scripts.migrate_add_task_thread_id
"""
import json
from __future__ import annotations
import logging
import sys
from pathlib import Path
APPS_DIR = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(APPS_DIR))
from sqlalchemy import text
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
from backend.models.database import engine
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def _extract_thread_id(payload_config: str | None) -> str | None:
if not payload_config:
return None
try:
payload = json.loads(payload_config)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
thread_id = payload.get("ThreadId")
value = str(thread_id).strip() if thread_id is not None else ""
return value or None
def migrate() -> None:
"""执行迁移。"""
logger.info("开始迁移:添加 check_in_tasks.thread_id 字段...")
with engine.connect() as conn:
result = conn.execute(text("PRAGMA table_info(check_in_tasks)"))
columns = [row[1] for row in result]
if "thread_id" not in columns:
logger.info("添加 thread_id 字段...")
conn.execute(text("ALTER TABLE check_in_tasks ADD COLUMN thread_id VARCHAR(100)"))
conn.commit()
logger.info("✓ thread_id 字段添加成功")
else:
logger.info("✓ thread_id 字段已存在,跳过")
rows = conn.execute(text("SELECT id, payload_config FROM check_in_tasks")).fetchall()
invalid_ids: list[int] = []
seen: dict[tuple[int, str], int] = {}
duplicate_ids: list[int] = []
full_rows = conn.execute(
text("SELECT id, user_id, payload_config FROM check_in_tasks")
).fetchall()
for row in full_rows:
thread_id = _extract_thread_id(row.payload_config)
if not thread_id:
invalid_ids.append(row.id)
continue
key = (row.user_id, thread_id)
if key in seen:
duplicate_ids.append(row.id)
else:
seen[key] = row.id
if invalid_ids or duplicate_ids:
messages = []
if invalid_ids:
messages.append(f"payload_config 缺少有效 ThreadId 的任务: {invalid_ids}")
if duplicate_ids:
messages.append(f"同用户 ThreadId 重复的任务: {duplicate_ids}")
raise RuntimeError("".join(messages))
for row in rows:
thread_id = _extract_thread_id(row.payload_config)
if thread_id:
conn.execute(
text("UPDATE check_in_tasks SET thread_id = :thread_id WHERE id = :id"),
{"thread_id": thread_id, "id": row.id},
)
conn.commit()
indexes = conn.execute(text("PRAGMA index_list(check_in_tasks)")).fetchall()
index_names = [row[1] for row in indexes]
if "ix_task_user_thread_id_unique" not in index_names:
logger.info("添加用户级 thread_id 唯一索引...")
conn.execute(
text(
"CREATE UNIQUE INDEX ix_task_user_thread_id_unique "
"ON check_in_tasks (user_id, thread_id)"
)
)
conn.commit()
logger.info("✓ 用户级 thread_id 唯一索引添加成功")
else:
logger.info("✓ 用户级 thread_id 唯一索引已存在,跳过")
apply_task_thread_id(conn)
logger.info("✅ 迁移完成!任务 thread_id 身份字段已启用")
@@ -109,5 +28,5 @@ if __name__ == "__main__":
try:
migrate()
except Exception as e:
logger.error(f"❌ 迁移失败: {e}")
logger.error("❌ 迁移失败: %s", e)
sys.exit(1)
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""
运行数据库迁移的脚本。
使用方法:
uv run python -m backend.scripts.run_migrations
"""
from __future__ import annotations
import logging
from backend.migrations import MigrationExecutionError, run_pending_migrations
from backend.models import init_db
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def main() -> int:
try:
init_db()
result = run_pending_migrations()
except MigrationExecutionError as exc:
logger.error("❌ 迁移失败: %s", exc)
return 1
logger.info("✅ 迁移完成:applied=%s skipped=%s", len(result.applied), len(result.skipped))
return 0
if __name__ == "__main__":
raise SystemExit(main())