diff --git a/docs/web-ui.md b/docs/web-ui.md index 878d64f..79a3e5f 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -57,6 +57,8 @@ docker compose down -v - 启动项目内置的每日自动同步调度器 - 启动 Web 页面与 API 服务 +首次启动默认值仍来自环境变量;之后可在 Web UI 中修改自动装载开关,运行期配置会持久化到 `/data/state/mysql_settings.json`。 + ## MySQL 默认连接 - Host: `127.0.0.1` @@ -129,10 +131,25 @@ docker compose down -v - 品牌与厂商关系管理 - 品牌同义词管理 - 数据来源优先级管理 +- 外部 MySQL 手动初始化 - 原始数据同步 - 每日自动同步时间点设置 - 索引数据查看与重新加载 +### 外部 MySQL 手动初始化 + +- 页面入口:`数据管理 -> 原始数据同步 -> 初始化外部 MySQL` +- 适用于 `MYSQL_AUTO_LOAD=0` 的远程 MySQL +- 点击后会执行 schema 与 seed 导入,自动创建数据库,并重建 `mobilemodels` 相关表与视图 +- 执行前请确认 `MYSQL_HOST`、`MYSQL_PORT`、`MYSQL_ROOT_USER`、`MYSQL_ROOT_PASSWORD` 指向正确且具备建库建表权限 + +### MySQL 自动装载开关 + +- 页面入口:`数据管理 -> 原始数据同步 -> MySQL 自动装载` +- 保存后会更新运行期配置 `/data/state/mysql_settings.json` +- 会影响后续“开始同步原始数据”是否自动刷新 MySQL +- 也会影响容器后续启动时是否自动执行 schema 与 seed 导入 + ### 每日自动同步 - 调度器运行在项目容器内部,不依赖 GitHub Actions diff --git a/tools/container_start.sh b/tools/container_start.sh index bb5b163..f7c759f 100644 --- a/tools/container_start.sh +++ b/tools/container_start.sh @@ -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 diff --git a/tools/web_server.py b/tools/web_server.py index 885b9a5..573f225 100644 --- a/tools/web_server.py +++ b/tools/web_server.py @@ -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() diff --git a/web/brand_management.html b/web/brand_management.html index 713bebe..a64e033 100644 --- a/web/brand_management.html +++ b/web/brand_management.html @@ -426,6 +426,27 @@