Add MySQL web controls

This commit is contained in:
2026-03-20 13:38:00 +08:00
parent ac9720e7de
commit dfddbb5ea0
4 changed files with 299 additions and 21 deletions
+24 -2
View File
@@ -8,10 +8,32 @@ sh tools/init_runtime_data.sh
python3 tools/device_mapper.py build
python3 tools/export_mysql_seed.py
if [ "${MYSQL_AUTO_LOAD:-0}" = "1" ]; then
MYSQL_AUTO_LOAD_EFFECTIVE="$(python3 - <<'PY'
import json
import os
from pathlib import Path
data_root = Path(os.environ.get("MOBILEMODELS_DATA_ROOT", "/data"))
config_path = data_root / "state/mysql_settings.json"
raw_default = os.environ.get("MYSQL_AUTO_LOAD", "0").strip().lower()
value = raw_default in {"1", "true", "yes", "on"}
try:
if config_path.exists():
payload = json.loads(config_path.read_text(encoding="utf-8"))
raw = payload.get("auto_load", value) if isinstance(payload, dict) else value
value = raw if isinstance(raw, bool) else str(raw).strip().lower() in {"1", "true", "yes", "on"}
except Exception:
pass
print("1" if value else "0")
PY
)"
if [ "$MYSQL_AUTO_LOAD_EFFECTIVE" = "1" ]; then
python3 tools/load_mysql_seed.py
else
echo "Skipping MySQL load because MYSQL_AUTO_LOAD=${MYSQL_AUTO_LOAD:-0}"
echo "Skipping MySQL load because MYSQL_AUTO_LOAD=$MYSQL_AUTO_LOAD_EFFECTIVE"
fi
exec python3 tools/web_server.py --host 0.0.0.0 --port 8123
+149 -14
View File
@@ -26,8 +26,10 @@ MYSQL_LOADER = PROJECT_ROOT / "tools/load_mysql_seed.py"
DATA_ROOT = Path(os.environ.get("MOBILEMODELS_DATA_ROOT", "/data"))
SYNC_METADATA_PATH = DATA_ROOT / "state/sync_status.json"
SCHEDULE_CONFIG_PATH = DATA_ROOT / "state/sync_schedule.json"
MYSQL_CONFIG_PATH = DATA_ROOT / "state/mysql_settings.json"
SYNC_LOCK = threading.Lock()
SCHEDULE_LOCK = threading.Lock()
MYSQL_CONFIG_LOCK = threading.Lock()
NORMALIZE_RE = re.compile(r"[^0-9a-z\u4e00-\u9fff]+")
SCHEDULE_TIME_RE = re.compile(r"^(?:[01]?\d|2[0-3]):[0-5]\d$")
SCHEDULER_POLL_SECONDS = 20
@@ -47,7 +49,18 @@ def apply_timezone_from_env() -> None:
def mysql_auto_load_enabled() -> bool:
return truthy_env("MYSQL_AUTO_LOAD", "0")
return bool(read_mysql_config().get("auto_load"))
def mysql_probe_credentials() -> tuple[str, str]:
reader_user = os.environ.get("MYSQL_READER_USER", "").strip()
reader_password = os.environ.get("MYSQL_READER_PASSWORD", "")
if reader_user:
return reader_user, reader_password
return (
os.environ.get("MYSQL_ROOT_USER", "root").strip() or "root",
os.environ.get("MYSQL_ROOT_PASSWORD", ""),
)
def local_now() -> datetime:
@@ -116,6 +129,58 @@ def default_schedule_config() -> dict[str, object]:
}
def default_mysql_config() -> dict[str, object]:
return {
"auto_load": truthy_env("MYSQL_AUTO_LOAD", "0"),
"updated_at": None,
}
def normalize_mysql_config(raw: dict[str, object] | None) -> dict[str, object]:
config = default_mysql_config()
if isinstance(raw, dict):
if "auto_load" in raw:
value = raw.get("auto_load")
config["auto_load"] = value if isinstance(value, bool) else str(value).strip().lower() in {"1", "true", "yes", "on"}
if raw.get("updated_at") is not None:
config["updated_at"] = raw.get("updated_at")
return config
def read_mysql_config() -> dict[str, object]:
with MYSQL_CONFIG_LOCK:
if not MYSQL_CONFIG_PATH.exists():
return normalize_mysql_config(None)
try:
payload = json.loads(MYSQL_CONFIG_PATH.read_text(encoding="utf-8"))
except Exception:
return normalize_mysql_config(None)
return normalize_mysql_config(payload if isinstance(payload, dict) else None)
def write_mysql_config(payload: dict[str, object]) -> dict[str, object]:
normalized = normalize_mysql_config(payload)
with MYSQL_CONFIG_LOCK:
MYSQL_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
MYSQL_CONFIG_PATH.write_text(
json.dumps(normalized, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return normalized
def update_mysql_config(payload: dict[str, object]) -> dict[str, object]:
current = read_mysql_config()
auto_load_raw = payload.get("auto_load", current.get("auto_load", False))
auto_load = auto_load_raw if isinstance(auto_load_raw, bool) else str(auto_load_raw).strip().lower() in {"1", "true", "yes", "on"}
updated = {
**current,
"auto_load": auto_load,
"updated_at": local_now().isoformat(timespec="seconds"),
}
return write_mysql_config(updated)
def normalize_schedule_config(raw: dict[str, object] | None) -> dict[str, object]:
config = default_schedule_config()
if isinstance(raw, dict):
@@ -372,26 +437,38 @@ def get_status_payload() -> dict[str, object]:
mysql_database = os.environ.get("MYSQL_DATABASE", "mobilemodels")
mysql_reader_user = os.environ.get("MYSQL_READER_USER", "")
mysql_reader_password = os.environ.get("MYSQL_READER_PASSWORD", "")
mysql_auto_load = mysql_auto_load_enabled()
mysql_config = read_mysql_config()
mysql_auto_load = bool(mysql_config.get("auto_load"))
mysql_ready = False
mysql_status = ""
sync_metadata = read_sync_metadata()
schedule_config = read_schedule_config()
github_proxy_prefix = str(schedule_config.get("github_proxy_prefix") or "")
effective_repo_url = get_effective_repo_url(github_proxy_prefix)
if mysql_auto_load:
mysql_proc = run_command(["python3", str(MYSQL_LOADER), "--check-only", "--wait-timeout", "5"])
if mysql_proc.returncode == 0:
mysql_ready = True
mysql_status = mysql_proc.stdout.strip() or "MySQL ready"
else:
mysql_status = sanitize_mysql_message(
mysql_proc.stderr.strip() or mysql_proc.stdout.strip() or "MySQL unavailable",
host=mysql_host,
port=mysql_port,
)
probe_user, probe_password = mysql_probe_credentials()
mysql_proc = run_command(
[
"python3",
str(MYSQL_LOADER),
"--check-only",
"--wait-timeout",
"5",
f"--user={probe_user}",
f"--password={probe_password}",
]
)
if mysql_proc.returncode == 0:
mysql_ready = True
mysql_status = mysql_proc.stdout.strip() or "MySQL ready"
if not mysql_auto_load:
mysql_status = f"{mysql_status}; auto load disabled"
else:
mysql_status = "MySQL auto load disabled"
failure_message = sanitize_mysql_message(
mysql_proc.stderr.strip() or mysql_proc.stdout.strip() or "MySQL unavailable",
host=mysql_host,
port=mysql_port,
)
mysql_status = failure_message if mysql_auto_load else f"{failure_message}; auto load disabled"
return {
"supports_upstream_sync": True,
@@ -400,6 +477,8 @@ def get_status_payload() -> dict[str, object]:
"workspace_root": str(WORKSPACE_ROOT),
"data_root": str(DATA_ROOT),
"mysql_auto_load": mysql_auto_load,
"mysql_config_file": str(MYSQL_CONFIG_PATH.relative_to(DATA_ROOT)),
"mysql_config_updated_at": mysql_config.get("updated_at"),
"upstream_repo_url": DEFAULT_REPO_URL,
"effective_upstream_repo_url": effective_repo_url,
"upstream_branch": DEFAULT_BRANCH,
@@ -498,6 +577,30 @@ def run_upstream_sync(trigger_source: str = "manual") -> dict[str, object]:
SYNC_LOCK.release()
def run_mysql_init(trigger_source: str = "manual") -> dict[str, object]:
if not SYNC_LOCK.acquire(blocking=False):
raise RuntimeError("已有同步或 MySQL 初始化任务在执行,请稍后再试。")
try:
proc = run_command(["python3", str(MYSQL_LOADER)])
output = "\n".join(
part for part in [proc.stdout.strip(), proc.stderr.strip()] if part
).strip()
if proc.returncode != 0:
raise RuntimeError(output or f"mysql load failed with exit code {proc.returncode}")
payload = get_status_payload()
payload.update(
{
"trigger_source": trigger_source,
"output": output or "MySQL 初始化完成。",
}
)
return payload
finally:
SYNC_LOCK.release()
def run_scheduled_sync_if_due() -> None:
schedule_config = read_schedule_config()
if not schedule_config.get("enabled"):
@@ -590,6 +693,16 @@ class MobileModelsHandler(SimpleHTTPRequestHandler):
except Exception as err:
self._send_json({"error": str(err)}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
return
if self.path == "/api/init-mysql":
try:
payload = run_mysql_init()
self._send_json(payload)
except RuntimeError as err:
status = HTTPStatus.CONFLICT if "已有同步或 MySQL 初始化任务" in str(err) else HTTPStatus.INTERNAL_SERVER_ERROR
self._send_json({"error": str(err)}, status=status)
except Exception as err:
self._send_json({"error": str(err)}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
return
if self.path == "/api/query-sql":
try:
content_length = int(self.headers.get("Content-Length", "0") or "0")
@@ -631,6 +744,27 @@ class MobileModelsHandler(SimpleHTTPRequestHandler):
except Exception as err:
self._send_json({"error": str(err)}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
return
if self.path == "/api/mysql-settings":
try:
content_length = int(self.headers.get("Content-Length", "0") or "0")
raw_body = self.rfile.read(content_length) if content_length > 0 else b"{}"
req = json.loads(raw_body.decode("utf-8") or "{}")
if not isinstance(req, dict):
raise RuntimeError("请求体必须是 JSON 对象。")
mysql_config = update_mysql_config(req)
self._send_json(
{
"message": "MySQL 自动装载设置已保存。",
"mysql_auto_load": mysql_config.get("auto_load"),
"mysql_config_file": str(MYSQL_CONFIG_PATH.relative_to(DATA_ROOT)),
"mysql_config_updated_at": mysql_config.get("updated_at"),
}
)
except RuntimeError as err:
self._send_json({"error": str(err)}, status=HTTPStatus.BAD_REQUEST)
except Exception as err:
self._send_json({"error": str(err)}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
return
self._send_json({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)
@@ -645,6 +779,7 @@ def parse_args() -> argparse.Namespace:
def main() -> int:
apply_timezone_from_env()
write_schedule_config(read_schedule_config())
write_mysql_config(read_mysql_config())
args = parse_args()
scheduler = threading.Thread(target=scheduler_loop, name="sync-scheduler", daemon=True)
scheduler.start()