diff --git a/.gitignore b/.gitignore index a616625..9a4cbc9 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ **/.env.bak **/.env.local manual_trading_hub/hub_settings.json +manual_trading_hub/hub_backup_state.json +manual_trading_hub/hub_fund_history.json +manual_trading_hub/hub_supervisor_state.json manual_trading_hub/hub_ai_summaries.json manual_trading_hub/hub_ai_chat.json manual_trading_hub/hub_ai_fund_history.json diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example index 137c87b..ae546a6 100644 --- a/crypto_monitor_binance/.env.example +++ b/crypto_monitor_binance/.env.example @@ -21,9 +21,9 @@ APP_PORT=5001 APP_DEBUG=false # 登录账号 -APP_USERNAME=dekun +APP_USERNAME=admin # 登录密码(请改成你自己的强密码) -APP_PASSWORD=ChangeMe123! +APP_PASSWORD=admin123 # 是否关闭登录校验(局域网可设 true;公网务必 false) APP_AUTH_DISABLED=true # --- 多账户交易中控 manual_trading_hub --- diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example index bae49c5..125d117 100644 --- a/crypto_monitor_gate/.env.example +++ b/crypto_monitor_gate/.env.example @@ -21,9 +21,9 @@ APP_PORT=5000 APP_DEBUG=false # 登录账号 -APP_USERNAME=dekun +APP_USERNAME=admin # 登录密码(请改成你自己的强密码) -APP_PASSWORD=ChangeMe123! +APP_PASSWORD=admin123 # 是否关闭登录校验(局域网可设 true;公网务必 false) APP_AUTH_DISABLED=true # --- 多账户交易中控 manual_trading_hub --- diff --git a/crypto_monitor_gate_bot/.env.example b/crypto_monitor_gate_bot/.env.example index df7897d..6c0170c 100644 --- a/crypto_monitor_gate_bot/.env.example +++ b/crypto_monitor_gate_bot/.env.example @@ -21,9 +21,9 @@ APP_PORT=5002 APP_DEBUG=false # 登录账号 -APP_USERNAME=dekun +APP_USERNAME=admin # 登录密码(请改成你自己的强密码) -APP_PASSWORD=ChangeMe123! +APP_PASSWORD=admin123 # 是否关闭登录校验(局域网可设 true;公网务必 false) APP_AUTH_DISABLED=true # --- 多账户交易中控 manual_trading_hub --- diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index cd1ec76..36da27c 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -21,9 +21,9 @@ APP_PORT=5004 APP_DEBUG=false # 登录账号 -APP_USERNAME=dekun +APP_USERNAME=admin # 登录密码(请改成你自己的强密码) -APP_PASSWORD=ChangeMe123! +APP_PASSWORD=admin123 # 是否关闭登录校验(局域网可设 true;公网务必 false) APP_AUTH_DISABLED=true # --- 多账户交易中控 manual_trading_hub --- diff --git a/lib/hub/hub_backup_lib.py b/lib/hub/hub_backup_lib.py new file mode 100644 index 0000000..c1919c1 --- /dev/null +++ b/lib/hub/hub_backup_lib.py @@ -0,0 +1,447 @@ +"""中控备份与恢复:四所 SQLite、K 线库、env、hub JSON。""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import tempfile +import zipfile +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +from lib.paths import REPO_ROOT, hub_data_dir, manual_trading_hub_dir + +HUB_DIR = manual_trading_hub_dir() +TZ_NAME = (os.getenv("HUB_BACKUP_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai" + +EXCHANGE_DIRS: list[tuple[str, str]] = [ + ("binance", "crypto_monitor_binance"), + ("okx", "crypto_monitor_okx"), + ("gate", "crypto_monitor_gate"), + ("gate_bot", "crypto_monitor_gate_bot"), +] + +HUB_JSON_FILES = ( + "hub_settings.json", + "hub_fund_history.json", + "hub_ai_summaries.json", + "hub_ai_chat.json", + "hub_supervisor_state.json", +) + +HUB_DATA_FILES = ( + "hub_kline.db", + "hub_symbol_archive.db", + "hub_entry_plans.db", + "hub_macro_calendar.db", + "hub_volume_rank.json", +) + +DEFAULT_BACKUP_SETTINGS = { + "auto_enabled": True, + "auto_hour": 0, + "retention_days": 30, + "include_env": True, + "include_exchange_images": False, + "backup_root": "", +} + +BACKUP_STATE_PATH = HUB_DIR / "hub_backup_state.json" + + +def normalize_backup_settings(raw: dict | None) -> dict: + out = dict(DEFAULT_BACKUP_SETTINGS) + if isinstance(raw, dict): + for key in DEFAULT_BACKUP_SETTINGS: + if key in raw: + out[key] = raw[key] + try: + out["auto_hour"] = max(0, min(23, int(out.get("auto_hour", 0)))) + except (TypeError, ValueError): + out["auto_hour"] = 0 + try: + out["retention_days"] = max(1, min(365, int(out.get("retention_days", 30)))) + except (TypeError, ValueError): + out["retention_days"] = 30 + out["auto_enabled"] = bool(out.get("auto_enabled")) + out["include_env"] = bool(out.get("include_env", True)) + out["include_exchange_images"] = bool(out.get("include_exchange_images")) + out["backup_root"] = str(out.get("backup_root") or "").strip() + return out + + +def backup_root(settings: dict | None = None) -> Path: + cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None) + raw = cfg.get("backup_root") or (os.getenv("HUB_BACKUP_ROOT") or "").strip() + if not raw: + raw = (os.getenv("BACKUP_ROOT") or "/root/backups").strip() + root = Path(raw).expanduser() + if not root.is_absolute(): + root = REPO_ROOT / root + portal = root / "crypto_monitor_portal" + portal.mkdir(parents=True, exist_ok=True) + return portal + + +def _now_local() -> datetime: + try: + return datetime.now(ZoneInfo(TZ_NAME)) + except Exception: + return datetime.now() + + +def _read_env_var(env_path: Path, key: str, default: str = "") -> str: + if not env_path.is_file(): + return default + try: + for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + raw = line.strip() + if not raw or raw.startswith("#") or "=" not in raw: + continue + k, v = raw.split("=", 1) + if k.strip() == key: + return v.strip().strip('"').strip("'") + except Exception: + pass + return default + + +def _resolve_project_path(project_dir: Path, rel: str) -> Path: + p = Path(rel or "") + if p.is_absolute(): + return p + return project_dir / p + + +def _load_backup_state() -> dict: + if not BACKUP_STATE_PATH.is_file(): + return {} + try: + data = json.loads(BACKUP_STATE_PATH.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _save_backup_state(state: dict) -> None: + BACKUP_STATE_PATH.write_text( + json.dumps(state, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def _safe_archive_name(name: str) -> bool: + return bool(re.fullmatch(r"backup_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}\.zip", name or "")) + + +def _collect_targets( + *, + include_env: bool, + include_exchange_images: bool, +) -> list[tuple[str, Path, str]]: + """Return list of (archive_rel_path, source_path, kind).""" + items: list[tuple[str, Path, str]] = [] + + if include_env: + hub_env = HUB_DIR / ".env" + if hub_env.is_file(): + items.append(("hub/.env", hub_env, "env")) + + for name in HUB_JSON_FILES: + src = HUB_DIR / name + if src.is_file(): + items.append((f"hub/{name}", src, "json")) + + data_dir = hub_data_dir() + for name in HUB_DATA_FILES: + src = data_dir / name + if src.is_file(): + items.append((f"hub/data/{name}", src, "sqlite" if name.endswith(".db") else "json")) + + for key, dirname in EXCHANGE_DIRS: + proj = REPO_ROOT / dirname + prefix = dirname + env_path = proj / ".env" + db_rel = "crypto.db" + upload_rel = "static/images" + if env_path.is_file(): + db_rel = _read_env_var(env_path, "DB_PATH", "crypto.db") or "crypto.db" + upload_rel = _read_env_var(env_path, "UPLOAD_DIR", "static/images") or "static/images" + if include_env: + items.append((f"{prefix}/.env", env_path, "env")) + db_path = _resolve_project_path(proj, db_rel) + if db_path.is_file(): + items.append((f"{prefix}/{db_rel}", db_path, "sqlite")) + if include_exchange_images: + img_dir = _resolve_project_path(proj, upload_rel) + if img_dir.is_dir(): + for fp in sorted(img_dir.rglob("*")): + if fp.is_file(): + rel = fp.relative_to(proj).as_posix() + items.append((f"{prefix}/{rel}", fp, "image")) + return items + + +def _write_manifest(staging: Path, trigger: str, files: list[dict]) -> None: + manifest = { + "version": 1, + "created_at": _now_local().strftime("%Y-%m-%d %H:%M:%S"), + "timezone": TZ_NAME, + "trigger": trigger, + "repo_root": str(REPO_ROOT), + "files": files, + } + (staging / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def run_backup( + *, + trigger: str = "manual", + settings: dict | None = None, + log_fn: Callable[[str], None] | None = None, +) -> dict[str, Any]: + cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None) + root = backup_root(settings) + ts = _now_local().strftime("%Y-%m-%d_%H%M%S") + archive_name = f"backup_{ts}.zip" + archive_path = root / archive_name + + def log(msg: str) -> None: + if log_fn: + log_fn(msg) + + targets = _collect_targets( + include_env=cfg["include_env"], + include_exchange_images=cfg["include_exchange_images"], + ) + if not targets: + return {"ok": False, "error": "没有可备份的文件"} + + file_meta: list[dict] = [] + with tempfile.TemporaryDirectory(prefix="hub_backup_") as tmp: + staging = Path(tmp) + for arc_rel, src, kind in targets: + dest = staging / arc_rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + file_meta.append( + { + "path": arc_rel.replace("\\", "/"), + "size": src.stat().st_size, + "kind": kind, + } + ) + _write_manifest(staging, trigger, file_meta) + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for fp in sorted(staging.rglob("*")): + if fp.is_file(): + zf.write(fp, fp.relative_to(staging).as_posix()) + + size = archive_path.stat().st_size + prune_old_backups(root, cfg["retention_days"]) + state = _load_backup_state() + if trigger == "auto": + state["last_auto_day"] = _now_local().strftime("%Y-%m-%d") + state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S") + state["last_backup_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S") + state["last_backup_file"] = archive_name + state["last_trigger"] = trigger + _save_backup_state(state) + log(f"backup written: {archive_path}") + return { + "ok": True, + "file": archive_name, + "path": str(archive_path), + "size": size, + "file_count": len(file_meta), + "trigger": trigger, + } + + +def prune_old_backups(root: Path, retention_days: int) -> int: + if not root.is_dir(): + return 0 + cutoff = _now_local() - timedelta(days=max(1, retention_days)) + removed = 0 + for fp in root.glob("backup_*.zip"): + try: + mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=cutoff.tzinfo) + except Exception: + continue + if mtime < cutoff: + fp.unlink(missing_ok=True) + removed += 1 + return removed + + +def list_backups(settings: dict | None = None) -> list[dict[str, Any]]: + root = backup_root(settings) + rows: list[dict[str, Any]] = [] + if not root.is_dir(): + return rows + for fp in sorted(root.glob("backup_*.zip"), reverse=True): + try: + st = fp.stat() + except OSError: + continue + rows.append( + { + "name": fp.name, + "size": st.st_size, + "modified_at": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"), + } + ) + return rows + + +def backup_status(settings: dict | None = None) -> dict[str, Any]: + cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None) + state = _load_backup_state() + root = backup_root(settings) + return { + "ok": True, + "settings": cfg, + "backup_root": str(root), + "state": state, + "backups": list_backups(settings)[:50], + "timezone": TZ_NAME, + } + + +def _pm2_restart_all() -> dict[str, Any]: + if os.name != "posix": + return {"ok": False, "skipped": True, "reason": "non-posix"} + try: + proc = subprocess.run( + ["pm2", "restart", "all"], + capture_output=True, + text=True, + timeout=120, + ) + return { + "ok": proc.returncode == 0, + "returncode": proc.returncode, + "stdout": (proc.stdout or "")[-2000:], + "stderr": (proc.stderr or "")[-2000:], + } + except Exception as e: + return {"ok": False, "error": str(e)} + + +def restore_backup_archive( + archive_path: Path, + *, + settings: dict | None = None, + pre_backup: bool = True, + restart_pm2: bool = True, +) -> dict[str, Any]: + if not archive_path.is_file(): + return {"ok": False, "error": "备份文件不存在"} + + pre = None + if pre_backup: + pre = run_backup(trigger="pre_restore", settings=settings) + + restored: list[str] = [] + skipped: list[str] = [] + with tempfile.TemporaryDirectory(prefix="hub_restore_") as tmp: + extract_dir = Path(tmp) + with zipfile.ZipFile(archive_path, "r") as zf: + zf.extractall(extract_dir) + manifest_path = extract_dir / "manifest.json" + if not manifest_path.is_file(): + return {"ok": False, "error": "无效的备份包:缺少 manifest.json"} + + for fp in extract_dir.rglob("*"): + if not fp.is_file() or fp.name == "manifest.json": + continue + rel = fp.relative_to(extract_dir).as_posix() + parts = Path(rel).parts + if parts[0] == "hub": + if len(parts) >= 3 and parts[1] == "data": + dest = hub_data_dir() / parts[-1] + else: + dest = HUB_DIR.joinpath(*parts[1:]) + else: + matched = False + for _key, dirname in EXCHANGE_DIRS: + if rel.startswith(dirname + "/"): + dest = REPO_ROOT / rel + matched = True + break + if not matched: + skipped.append(rel) + continue + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(fp, dest) + restored.append(rel) + + pm2 = _pm2_restart_all() if restart_pm2 else {"ok": False, "skipped": True} + state = _load_backup_state() + state["last_restore_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S") + state["last_restore_from"] = archive_path.name + _save_backup_state(state) + return { + "ok": True, + "restored": restored, + "skipped": skipped, + "pre_backup": pre, + "pm2": pm2, + } + + +def restore_backup_upload( + content: bytes, + filename: str, + *, + settings: dict | None = None, +) -> dict[str, Any]: + if not content: + return {"ok": False, "error": "空文件"} + suffix = Path(filename or "").suffix.lower() + if suffix != ".zip": + return {"ok": False, "error": "仅支持 .zip 备份包"} + with tempfile.NamedTemporaryFile(prefix="hub_restore_upload_", suffix=".zip", delete=False) as tf: + tf.write(content) + temp_path = Path(tf.name) + try: + return restore_backup_archive(temp_path, settings=settings) + finally: + temp_path.unlink(missing_ok=True) + + +def resolve_backup_download(settings: dict | None, name: str) -> Optional[Path]: + if not _safe_archive_name(name): + return None + fp = backup_root(settings) / name + if fp.is_file(): + return fp + return None + + +def should_run_auto_backup(settings: dict) -> bool: + cfg = normalize_backup_settings(settings.get("backup")) + if not cfg.get("auto_enabled"): + return False + now = _now_local() + today = now.strftime("%Y-%m-%d") + state = _load_backup_state() + if state.get("last_auto_day") == today: + return False + if now.hour < int(cfg.get("auto_hour", 0)): + return False + return True + + +def mark_auto_backup_done() -> None: + state = _load_backup_state() + state["last_auto_day"] = _now_local().strftime("%Y-%m-%d") + state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S") + _save_backup_state(state) diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example index 8f4e1d2..18dc4a3 100644 --- a/manual_trading_hub/.env.example +++ b/manual_trading_hub/.env.example @@ -26,9 +26,9 @@ HUB_TRUST_LAN=true # 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"}) # HUB_ALLOW_PUBLIC=true -# 中控 Web 登录(密码非空即启用;反代到公网时务必设置用户名+密码) -# HUB_USERNAME=admin -# HUB_PASSWORD=your-strong-password-here +# 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改) +HUB_USERNAME=admin +HUB_PASSWORD=admin123 # 会话签名密钥(建议单独随机串;未设则用用户名+密码拼接) # HUB_SESSION_SECRET=another-long-random-string # HTTPS 反代时建议 true:仅 HTTPS 访问会带 Secure Cookie;http://内网IP:5100 仍可登录 @@ -106,6 +106,7 @@ AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest TRADING_DAY_RESET_HOUR=8 # 资金概况 / AI 上下文:分户资金快照保留交易日数(默认 180) # HUB_FUND_HISTORY_DAYS=180 -# 资金概况:曲线与回撤统计起始交易日(页面说明与曲线均读取此项;该日之前不记、不展示) -# 修改后须重启 manual-trading-hub(pm2 restart manual-trading-hub) +# 自动备份(系统设置 → 备份与恢复;也可设 HUB_BACKUP_ROOT) +# HUB_BACKUP_ROOT=/root/backups/crypto_monitor_portal +# 资金概况:曲线与回撤统计起始交易日 HUB_FUND_HISTORY_START_DAY=2026-06-09 diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 95eedb7..73afb58 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -86,7 +86,7 @@ from env_load import load_hub_dotenv load_hub_dotenv() import httpx -from fastapi import Body, FastAPI, HTTPException, Request +from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field @@ -99,6 +99,15 @@ from settings_store import ( normalize_supervisor_settings, save_settings, ) +from lib.hub.hub_backup_lib import ( + backup_status, + normalize_backup_settings, + resolve_backup_download, + restore_backup_archive, + restore_backup_upload, + run_backup, + should_run_auto_backup, +) from hub_web_auth import ( SESSION_COOKIE, SESSION_MAX_AGE_SEC, @@ -157,6 +166,8 @@ _last_archive_sync: dict | None = None _volume_rank_stop: asyncio.Event | None = None _volume_rank_task: asyncio.Task | None = None _volume_rank_cache: dict | None = None +_backup_stop: asyncio.Event | None = None +_backup_task: asyncio.Task | None = None HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8")) HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10")) HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45")) @@ -518,9 +529,28 @@ async def _run_supervisor_tick() -> dict: ) +async def _backup_scheduler_loop() -> None: + global _backup_stop + stop = _backup_stop + if stop is None: + return + while not stop.is_set(): + try: + settings = load_settings() + if should_run_auto_backup(settings): + await asyncio.to_thread(run_backup, trigger="auto", settings=settings) + except Exception as e: + print(f"[backup] auto backup failed: {e}", flush=True) + try: + await asyncio.wait_for(stop.wait(), timeout=60.0) + except asyncio.TimeoutError: + pass + + @asynccontextmanager async def _hub_lifespan(_app: FastAPI): global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task + global _backup_stop, _backup_task set_supervisor_notify_hook(supervisor_store.bump) await board_store.start(_run_board_aggregate) await dashboard_store.start(_run_dashboard_aggregate) @@ -530,9 +560,21 @@ async def _hub_lifespan(_app: FastAPI): _archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync") _volume_rank_stop = asyncio.Event() _volume_rank_task = asyncio.create_task(_volume_rank_loop(), name="hub-volume-rank") + _backup_stop = asyncio.Event() + _backup_task = asyncio.create_task(_backup_scheduler_loop(), name="hub-backup-scheduler") try: yield finally: + if _backup_stop: + _backup_stop.set() + if _backup_task: + _backup_task.cancel() + try: + await _backup_task + except asyncio.CancelledError: + pass + _backup_task = None + _backup_stop = None if _archive_sync_stop: _archive_sync_stop.set() if _archive_sync_task: @@ -879,10 +921,20 @@ class SupervisorSettingsBody(BaseModel): reopen_after_close_minutes: int = 30 +class BackupSettingsBody(BaseModel): + auto_enabled: bool = True + auto_hour: int = Field(default=0, ge=0, le=23) + retention_days: int = Field(default=30, ge=1, le=365) + include_env: bool = True + include_exchange_images: bool = False + backup_root: str = "" + + class SettingsBody(BaseModel): exchanges: list[dict] = Field(default_factory=list) display: SettingsDisplayBody | None = None supervisor: SupervisorSettingsBody | None = None + backup: BackupSettingsBody | None = None @app.post("/api/settings") @@ -903,7 +955,18 @@ def api_save_settings(body: SettingsBody): supervisor = normalize_supervisor_settings(existing.get("supervisor")) if body.supervisor is not None: supervisor = normalize_supervisor_settings(body.supervisor.model_dump()) - save_settings({"version": 1, "exchanges": to_save, "display": display, "supervisor": supervisor}) + backup = normalize_backup_settings(existing.get("backup")) + if body.backup is not None: + backup = normalize_backup_settings(body.backup.model_dump()) + save_settings( + { + "version": 1, + "exchanges": to_save, + "display": display, + "supervisor": supervisor, + "backup": backup, + } + ) return {"ok": True, "settings": load_settings()} @@ -1290,6 +1353,65 @@ async def api_chart_poll_meta(): return chart_poll_store.event_dict() +@app.get("/api/backup/status") +def api_backup_status(): + return backup_status(load_settings()) + + +@app.post("/api/backup/run") +async def api_backup_run(): + result = await asyncio.to_thread(run_backup, trigger="manual", settings=load_settings()) + if not result.get("ok"): + raise HTTPException(status_code=500, detail=result.get("error") or "backup failed") + return result + + +@app.get("/api/backup/download/{name}") +def api_backup_download(name: str): + fp = resolve_backup_download(load_settings(), name) + if not fp: + raise HTTPException(status_code=404, detail="backup not found") + return FileResponse( + str(fp), + media_type="application/zip", + filename=fp.name, + ) + + +@app.post("/api/backup/restore") +async def api_backup_restore( + file: UploadFile = File(...), + confirm: str = Form(""), +): + if (confirm or "").strip().upper() != "RESTORE": + raise HTTPException(status_code=400, detail='请在 confirm 字段填写 RESTORE 以确认恢复') + content = await file.read() + result = await asyncio.to_thread( + restore_backup_upload, + content, + file.filename or "backup.zip", + settings=load_settings(), + ) + if not result.get("ok"): + raise HTTPException(status_code=500, detail=result.get("error") or "restore failed") + return result + + +@app.post("/api/backup/restore-local") +async def api_backup_restore_local(body: dict = Body(...)): + confirm = str(body.get("confirm") or "").strip().upper() + name = str(body.get("name") or "").strip() + if confirm != "RESTORE": + raise HTTPException(status_code=400, detail='请在 confirm 字段填写 RESTORE 以确认恢复') + fp = resolve_backup_download(load_settings(), name) + if not fp: + raise HTTPException(status_code=404, detail="backup not found") + result = await asyncio.to_thread(restore_backup_archive, fp, settings=load_settings()) + if not result.get("ok"): + raise HTTPException(status_code=500, detail=result.get("error") or "restore failed") + return result + + @app.get("/api/settings/meta") def api_settings_meta(): po = public_origin() @@ -1304,6 +1426,7 @@ def api_settings_meta(): else "复盘/展示链接已替换为对外地址" ), "password_required": password_required(), + "default_username": expected_username(), } diff --git a/manual_trading_hub/hub_web_auth.py b/manual_trading_hub/hub_web_auth.py index 542149c..4fbd33c 100644 --- a/manual_trading_hub/hub_web_auth.py +++ b/manual_trading_hub/hub_web_auth.py @@ -13,6 +13,7 @@ from secrets import compare_digest SESSION_COOKIE = "hub_sess" SESSION_MAX_AGE_SEC = max(3600, int(os.getenv("HUB_SESSION_DAYS", "7")) * 86400) DEFAULT_USERNAME = "admin" +DEFAULT_PASSWORD = "admin123" def _env_username() -> str: @@ -20,12 +21,13 @@ def _env_username() -> str: def _env_password() -> str: - return (os.getenv("HUB_PASSWORD") or "").strip() + raw = (os.getenv("HUB_PASSWORD") or "").strip() + return raw or DEFAULT_PASSWORD def password_required() -> bool: - """已配置密码即要求登录(用户名未设时默认 admin)。""" - return bool(_env_password()) + """默认启用登录(admin / admin123,可通过 .env 覆盖)。""" + return True def expected_username() -> str: @@ -33,8 +35,6 @@ def expected_username() -> str: def verify_credentials(username: str, password: str) -> bool: - if not _env_password(): - return True u_ok = compare_digest(expected_username(), (username or "").strip()) p_ok = compare_digest(_env_password(), (password or "").strip()) return u_ok and p_ok diff --git a/manual_trading_hub/settings_store.py b/manual_trading_hub/settings_store.py index b1ed739..fa728be 100644 --- a/manual_trading_hub/settings_store.py +++ b/manual_trading_hub/settings_store.py @@ -8,13 +8,17 @@ from pathlib import Path DIR = Path(__file__).resolve().parent SETTINGS_PATH = DIR / "hub_settings.json" +_REPO_ROOT = DIR.parent import sys +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) if str(DIR) not in sys.path: sys.path.insert(0, str(DIR)) from hub_supervisor_lib import DEFAULT_SUPERVISOR, normalize_supervisor_settings +from lib.hub.hub_backup_lib import normalize_backup_settings DEFAULT_DISPLAY = { "show_account_pnl": True, @@ -106,6 +110,7 @@ def load_settings() -> dict: pass data["display"] = normalize_display_prefs(data.get("display")) data["supervisor"] = normalize_supervisor_settings(data.get("supervisor")) + data["backup"] = normalize_backup_settings(data.get("backup")) force_off = env_force_disabled_ids() for ex in data.get("exchanges") or []: if str(ex.get("id")) in force_off: @@ -120,6 +125,7 @@ def save_settings(data: dict) -> None: payload = dict(data) payload["display"] = normalize_display_prefs(payload.get("display")) payload["supervisor"] = normalize_supervisor_settings(payload.get("supervisor")) + payload["backup"] = normalize_backup_settings(payload.get("backup")) SETTINGS_PATH.write_text( json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 46b028a..996bf87 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -2904,6 +2904,88 @@ button.btn-sm { line-height: 1.45; } +.backup-settings-grid { + margin-top: 12px; +} + +.backup-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 16px; +} + +.backup-status-line { + font-size: 0.82rem; + color: var(--muted); +} + +.backup-status-line.err { + color: var(--danger, #f87171); +} + +.backup-restore-upload { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.backup-upload-label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.82rem; + color: var(--muted); +} + +.backup-list { + margin-top: 16px; +} + +.backup-meta { + font-size: 0.78rem; + color: var(--muted); + line-height: 1.5; + margin-bottom: 10px; +} + +.backup-meta code { + font-size: 0.76rem; +} + +.backup-empty { + font-size: 0.82rem; + color: var(--muted); +} + +.backup-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.backup-table th, +.backup-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--border); + text-align: left; +} + +.backup-row-actions { + white-space: nowrap; +} + +.backup-row-actions .ghost, +.backup-row-actions .danger { + font-size: 0.78rem; + padding: 4px 8px; +} + .settings-card { background: var(--panel); border: 1px solid var(--border); diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 9153b1c..cf015ab 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -3969,6 +3969,7 @@ syncSupervisorSettingsUI(data); renderSettingsList(data); initSettingsSectionFolds(); + if (typeof initBackupSettingsUI === "function") void initBackupSettingsUI(); }); } diff --git a/manual_trading_hub/static/backup.js b/manual_trading_hub/static/backup.js new file mode 100644 index 0000000..8307763 --- /dev/null +++ b/manual_trading_hub/static/backup.js @@ -0,0 +1,250 @@ +/** + * 系统设置 · 备份与恢复 + */ +(function () { + const page = document.getElementById("page-settings"); + if (!page) return; + + const elAuto = document.getElementById("backup-auto-enabled"); + const elHour = document.getElementById("backup-auto-hour"); + const elRetention = document.getElementById("backup-retention-days"); + const elIncludeEnv = document.getElementById("backup-include-env"); + const elIncludeImages = document.getElementById("backup-include-images"); + const elRoot = document.getElementById("backup-root"); + const elStatus = document.getElementById("backup-status-line"); + const elList = document.getElementById("backup-list"); + const elRun = document.getElementById("backup-run-now"); + const elRestoreFile = document.getElementById("backup-restore-file"); + const elRestoreBtn = document.getElementById("backup-restore-upload-btn"); + + let settingsCache = null; + let statusCache = null; + + function fmtBytes(n) { + const v = Number(n); + if (!Number.isFinite(v) || v < 0) return "—"; + if (v < 1024) return v + " B"; + if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB"; + return (v / (1024 * 1024)).toFixed(2) + " MB"; + } + + function setStatus(msg, isErr) { + if (!elStatus) return; + elStatus.textContent = msg || ""; + elStatus.className = "backup-status-line" + (isErr ? " err" : ""); + } + + function collectBackupFromUI() { + return { + auto_enabled: !!(elAuto && elAuto.checked), + auto_hour: Math.max(0, Math.min(23, parseInt(elHour && elHour.value, 10) || 0)), + retention_days: Math.max(1, Math.min(365, parseInt(elRetention && elRetention.value, 10) || 30)), + include_env: !!(elIncludeEnv && elIncludeEnv.checked), + include_exchange_images: !!(elIncludeImages && elIncludeImages.checked), + backup_root: (elRoot && elRoot.value || "").trim(), + }; + } + + function syncBackupUI(data) { + const b = (data && data.backup) || {}; + if (elAuto) elAuto.checked = b.auto_enabled !== false; + if (elHour) elHour.value = b.auto_hour != null ? b.auto_hour : 0; + if (elRetention) elRetention.value = b.retention_days != null ? b.retention_days : 30; + if (elIncludeEnv) elIncludeEnv.checked = b.include_env !== false; + if (elIncludeImages) elIncludeImages.checked = !!b.include_exchange_images; + if (elRoot) elRoot.value = b.backup_root || ""; + } + + function renderBackupList(status) { + if (!elList) return; + const rows = (status && status.backups) || []; + const state = (status && status.state) || {}; + const root = (status && status.backup_root) || ""; + let html = '
"; + if (!rows.length) { + html += '暂无备份文件
'; + elList.innerHTML = html; + return; + } + html += '| 文件 | 大小 | 时间 | |
|---|---|---|---|
| " + + esc(row.name) + + " | " + + fmtBytes(row.size) + + " | " + + esc(row.modified_at || "") + + ' | ' + + '下载 ' + + ' |
hub_settings.json。Flask / Agent 填本机地址即可;复盘链接可留空(由 Flask 地址自动生成)。HUB_DISABLED_IDS 可强制关闭账户;HUB_BRIDGE_TOKEN 与实例一致,或实例 APP_AUTH_DISABLED=true。.env 设置 HUB_USERNAME 与 HUB_PASSWORD;HTTPS 反代建议 HUB_COOKIE_SECURE=true。
+ 公网反代请在 hub .env 设置 HUB_USERNAME 与 HUB_PASSWORD(默认 admin / admin123);HTTPS 反代建议 HUB_COOKIE_SECURE=true。
@@ -989,6 +989,57 @@
+
+ 打包四所 crypto.db、中控 K 线/归档等 SQLite、hub_settings.json 与 .env(可选)。
+ 恢复前会自动做一次 pre-restore 快照,并尝试 pm2 restart all。
+