feat: 添加 cloudflare_pages、mysql_d1_backup_scheduler,更新 start_system、ensemble_models 及配置文件
This commit is contained in:
1 parent
6184c34a3a
commit
62110554d1
20 files changed
+1438
-19
No files matched your search
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MySQL 数据定时备份到 Cloudflare D1
|
||||
- 定时:每小时 XX:30
|
||||
- 模式:按游标列增量同步(timestamp/created_at/updated_at/id)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import schedule
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from api.models import get_db_connection
|
||||
from utils.project_logger import get_project_logger
|
||||
|
||||
load_dotenv()
|
||||
logger = get_project_logger("mysql_d1_backup")
|
||||
|
||||
|
||||
class MySQLD1BackupService:
|
||||
"""MySQL -> D1 备份服务。"""
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = os.getenv("D1_BACKUP_ENABLED", "false").lower() == "true"
|
||||
self.api_token = os.getenv("CF_D1_API_TOKEN", "")
|
||||
self.account_id = os.getenv("CF_D1_ACCOUNT_ID", "")
|
||||
self.database_id = os.getenv("CF_D1_DATABASE_ID", "")
|
||||
self.batch_size = int(os.getenv("D1_BACKUP_BATCH_SIZE", "500"))
|
||||
self.max_sql_params = int(os.getenv("D1_MAX_SQL_PARAMS", "90"))
|
||||
self.tables_config = os.getenv("D1_BACKUP_TABLES", "").strip()
|
||||
self._job_lock = threading.Lock()
|
||||
|
||||
def is_config_ready(self) -> bool:
|
||||
"""检查 D1 配置是否完整。"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
return bool(self.api_token and self.account_id and self.database_id)
|
||||
|
||||
def _d1_endpoint(self) -> str:
|
||||
return (
|
||||
f"https://api.cloudflare.com/client/v4/accounts/"
|
||||
f"{self.account_id}/d1/database/{self.database_id}/query"
|
||||
)
|
||||
|
||||
def _query_d1(self, sql: str, params: list[Any] | None = None) -> dict:
|
||||
"""执行 D1 SQL。"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
statement: dict[str, Any] = {"sql": sql}
|
||||
if params is not None:
|
||||
statement["params"] = params
|
||||
|
||||
with httpx.Client(timeout=30) as client:
|
||||
# Cloudflare D1 REST API 对 /query 更稳定的格式是语句数组
|
||||
resp = client.post(self._d1_endpoint(), headers=headers, json=[statement])
|
||||
if resp.status_code >= 400:
|
||||
# 兼容部分环境下使用对象格式的情况,失败时回退重试一次
|
||||
fallback_resp = client.post(self._d1_endpoint(), headers=headers, json=statement)
|
||||
if fallback_resp.status_code >= 400:
|
||||
sql_preview = " ".join(sql.strip().split())[:180]
|
||||
raise RuntimeError(
|
||||
f"D1 HTTP {fallback_resp.status_code},sql={sql_preview},响应={fallback_resp.text[:500]}"
|
||||
)
|
||||
data = fallback_resp.json()
|
||||
else:
|
||||
data = resp.json()
|
||||
|
||||
if not data.get("success", False):
|
||||
raise RuntimeError(f"D1 SQL 执行失败: {data}")
|
||||
return data
|
||||
|
||||
def _d1_results(self, response: dict) -> list[dict]:
|
||||
"""解析 D1 响应结果。"""
|
||||
result = response.get("result", [])
|
||||
if not result:
|
||||
return []
|
||||
first = result[0] if isinstance(result, list) else result
|
||||
return first.get("results", []) if isinstance(first, dict) else []
|
||||
|
||||
def _normalize_value(self, value: Any) -> Any:
|
||||
"""把 MySQL 字段值转换为可写入 D1 的类型。"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (datetime,)):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, bytes):
|
||||
return value.hex()
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return value
|
||||
|
||||
def _sqlite_type_from_mysql(self, mysql_type: str) -> str:
|
||||
t = mysql_type.lower()
|
||||
if any(x in t for x in ["int", "bit", "bool"]):
|
||||
return "INTEGER"
|
||||
if any(x in t for x in ["double", "float", "decimal", "numeric", "real"]):
|
||||
return "REAL"
|
||||
if any(x in t for x in ["blob", "binary", "varbinary"]):
|
||||
return "BLOB"
|
||||
return "TEXT"
|
||||
|
||||
def _get_mysql_tables(self) -> list[str]:
|
||||
"""获取待备份表。"""
|
||||
if self.tables_config:
|
||||
return [x.strip() for x in self.tables_config.split(",") if x.strip()]
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SHOW TABLES")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
tables: list[str] = []
|
||||
for row in rows:
|
||||
# DictCursor 下 SHOW TABLES 返回单键字典
|
||||
table_name = list(row.values())[0]
|
||||
tables.append(str(table_name))
|
||||
return tables
|
||||
|
||||
def _get_mysql_columns(self, table_name: str) -> list[dict]:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SHOW COLUMNS FROM `{table_name}`")
|
||||
return list(cursor.fetchall())
|
||||
|
||||
def _guess_cursor_column(self, columns: list[dict]) -> str | None:
|
||||
"""选择增量同步游标列。"""
|
||||
column_names = {c["Field"] for c in columns}
|
||||
for candidate in ["timestamp", "created_at", "updated_at", "id"]:
|
||||
if candidate in column_names:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _ensure_d1_meta_tables(self):
|
||||
"""创建 D1 元数据表。"""
|
||||
self._query_d1(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS backup_sync_state (
|
||||
table_name TEXT PRIMARY KEY,
|
||||
cursor_col TEXT,
|
||||
last_cursor TEXT,
|
||||
updated_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._query_d1(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS backup_run_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_at TEXT,
|
||||
table_name TEXT,
|
||||
synced_rows INTEGER,
|
||||
status TEXT,
|
||||
message TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _ensure_d1_table(self, table_name: str, columns: list[dict]):
|
||||
"""创建 D1 备份表。"""
|
||||
table_sql_name = f"backup_{table_name}"
|
||||
col_defs: list[str] = []
|
||||
primary_keys: list[str] = []
|
||||
|
||||
for col in columns:
|
||||
field = col["Field"]
|
||||
col_type = self._sqlite_type_from_mysql(col["Type"])
|
||||
col_defs.append(f'"{field}" {col_type}')
|
||||
if col.get("Key") == "PRI":
|
||||
primary_keys.append(field)
|
||||
|
||||
# SQLite/D1 对复合主键需要表级 PRIMARY KEY 约束,不能多列各自 PRIMARY KEY
|
||||
if primary_keys:
|
||||
pk_cols = ", ".join([f'"{k}"' for k in primary_keys])
|
||||
col_defs.append(f"PRIMARY KEY ({pk_cols})")
|
||||
|
||||
create_sql = f"CREATE TABLE IF NOT EXISTS \"{table_sql_name}\" ({', '.join(col_defs)})"
|
||||
self._query_d1(create_sql)
|
||||
|
||||
def _get_last_cursor(self, table_name: str) -> str | None:
|
||||
"""读取 D1 已同步游标。"""
|
||||
resp = self._query_d1(
|
||||
"SELECT last_cursor FROM backup_sync_state WHERE table_name = ?",
|
||||
[table_name],
|
||||
)
|
||||
rows = self._d1_results(resp)
|
||||
if not rows:
|
||||
return None
|
||||
return rows[0].get("last_cursor")
|
||||
|
||||
def _update_last_cursor(self, table_name: str, cursor_col: str, cursor_value: Any):
|
||||
"""更新 D1 已同步游标。"""
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self._query_d1(
|
||||
"""
|
||||
INSERT INTO backup_sync_state(table_name, cursor_col, last_cursor, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(table_name) DO UPDATE SET
|
||||
cursor_col = excluded.cursor_col,
|
||||
last_cursor = excluded.last_cursor,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
[table_name, cursor_col, str(cursor_value), now_str],
|
||||
)
|
||||
|
||||
def _log_run(self, table_name: str, synced_rows: int, status: str, message: str):
|
||||
"""记录备份执行日志。"""
|
||||
run_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self._query_d1(
|
||||
"INSERT INTO backup_run_log(run_at, table_name, synced_rows, status, message) VALUES (?, ?, ?, ?, ?)",
|
||||
[run_at, table_name, synced_rows, status, message[:1000]],
|
||||
)
|
||||
|
||||
def _fetch_mysql_rows(
|
||||
self,
|
||||
table_name: str,
|
||||
cursor_col: str | None,
|
||||
last_cursor: str | None,
|
||||
) -> list[dict]:
|
||||
"""按游标从 MySQL 抽取增量数据。"""
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
if cursor_col and last_cursor:
|
||||
sql = (
|
||||
f"SELECT * FROM `{table_name}` WHERE `{cursor_col}` > %s "
|
||||
f"ORDER BY `{cursor_col}` ASC LIMIT %s"
|
||||
)
|
||||
cursor.execute(sql, (last_cursor, self.batch_size))
|
||||
elif cursor_col:
|
||||
sql = f"SELECT * FROM `{table_name}` ORDER BY `{cursor_col}` ASC LIMIT %s"
|
||||
cursor.execute(sql, (self.batch_size,))
|
||||
else:
|
||||
sql = f"SELECT * FROM `{table_name}` LIMIT %s"
|
||||
cursor.execute(sql, (self.batch_size,))
|
||||
return list(cursor.fetchall())
|
||||
|
||||
def _upsert_rows_to_d1(self, table_name: str, columns: list[dict], rows: list[dict]):
|
||||
"""把数据批量 UPSERT 到 D1。"""
|
||||
if not rows:
|
||||
return
|
||||
|
||||
table_sql_name = f"backup_{table_name}"
|
||||
col_names = [c["Field"] for c in columns]
|
||||
col_sql = ", ".join([f'"{c}"' for c in col_names])
|
||||
|
||||
# 避免 D1 的 SQL 变量数量限制触发 400
|
||||
# 通过环境变量 D1_MAX_SQL_PARAMS 控制,默认保守值 90
|
||||
chunk_size = max(1, self.max_sql_params // max(1, len(col_names)))
|
||||
|
||||
for i in range(0, len(rows), chunk_size):
|
||||
chunk_rows = rows[i:i + chunk_size]
|
||||
values_clause: list[str] = []
|
||||
params: list[Any] = []
|
||||
for row in chunk_rows:
|
||||
placeholders = ", ".join(["?" for _ in col_names])
|
||||
values_clause.append(f"({placeholders})")
|
||||
for col in col_names:
|
||||
params.append(self._normalize_value(row.get(col)))
|
||||
|
||||
sql = (
|
||||
f"INSERT OR REPLACE INTO \"{table_sql_name}\" ({col_sql}) "
|
||||
f"VALUES {', '.join(values_clause)}"
|
||||
)
|
||||
self._query_d1(sql, params)
|
||||
|
||||
def backup_table_once(self, table_name: str) -> int:
|
||||
"""单表执行一次增量备份,返回同步行数。"""
|
||||
columns = self._get_mysql_columns(table_name)
|
||||
if not columns:
|
||||
return 0
|
||||
|
||||
cursor_col = self._guess_cursor_column(columns)
|
||||
self._ensure_d1_table(table_name, columns)
|
||||
|
||||
total_synced = 0
|
||||
last_cursor = self._get_last_cursor(table_name)
|
||||
|
||||
while True:
|
||||
rows = self._fetch_mysql_rows(table_name, cursor_col, last_cursor)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
self._upsert_rows_to_d1(table_name, columns, rows)
|
||||
total_synced += len(rows)
|
||||
|
||||
if cursor_col:
|
||||
last_cursor = self._normalize_value(rows[-1].get(cursor_col))
|
||||
self._update_last_cursor(table_name, cursor_col, last_cursor)
|
||||
else:
|
||||
# 无游标列时仅做一批,避免重复全量
|
||||
break
|
||||
|
||||
if len(rows) < self.batch_size:
|
||||
break
|
||||
|
||||
return total_synced
|
||||
|
||||
def run_backup_once(self):
|
||||
"""执行一次全表备份任务。"""
|
||||
if not self.is_config_ready():
|
||||
logger.warning("D1 备份未启用或配置不完整,跳过本次任务")
|
||||
return
|
||||
|
||||
if not self._job_lock.acquire(blocking=False):
|
||||
logger.warning("上一次 D1 备份尚未完成,跳过本次任务")
|
||||
return
|
||||
|
||||
try:
|
||||
self._ensure_d1_meta_tables()
|
||||
tables = self._get_mysql_tables()
|
||||
logger.info("D1 备份任务开始,待同步表: %s", ", ".join(tables))
|
||||
|
||||
for table_name in tables:
|
||||
try:
|
||||
synced = self.backup_table_once(table_name)
|
||||
logger.info("表 %s 同步完成,本次同步 %d 行", table_name, synced)
|
||||
self._log_run(table_name, synced, "success", "ok")
|
||||
except Exception as table_error:
|
||||
logger.exception("表 %s 同步失败", table_name)
|
||||
self._log_run(table_name, 0, "failed", str(table_error))
|
||||
except Exception:
|
||||
logger.exception("D1 备份任务执行失败")
|
||||
finally:
|
||||
self._job_lock.release()
|
||||
|
||||
|
||||
def schedule_mysql_d1_backup():
|
||||
"""启动 D1 备份定时任务(每小时 :30)。"""
|
||||
service = MySQLD1BackupService()
|
||||
if not service.is_config_ready():
|
||||
logger.warning("D1 备份调度未启动:请检查 D1_BACKUP_ENABLED 与 Cloudflare 参数")
|
||||
return
|
||||
|
||||
# 启动后先执行一次,避免首次备份必须等待到下一个 :30
|
||||
service.run_backup_once()
|
||||
|
||||
# 使用独立调度器,避免与项目内其他定时任务共享全局调度状态
|
||||
local_scheduler = schedule.Scheduler()
|
||||
local_scheduler.every().hour.at(":30").do(service.run_backup_once)
|
||||
logger.info("MySQL -> D1 定时备份已启动(每小时 :30 执行)")
|
||||
|
||||
while True:
|
||||
local_scheduler.run_pending()
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def run_mysql_d1_backup_scheduler_background():
|
||||
"""后台线程启动 D1 备份调度器。"""
|
||||
service = MySQLD1BackupService()
|
||||
if not service.is_config_ready():
|
||||
logger.warning("D1 备份配置不完整,后台线程不启动")
|
||||
return None
|
||||
|
||||
scheduler_thread = threading.Thread(target=schedule_mysql_d1_backup, daemon=True)
|
||||
scheduler_thread.start()
|
||||
logger.info("D1 备份调度器已在后台启动")
|
||||
return scheduler_thread
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
schedule_mysql_d1_backup()
|
||||
Reference in new issue
Block a user