"""中控统一 AI 环境变量:字段定义,读写,同步三实例.""" from __future__ import annotations import os import subprocess import sys from pathlib import Path from typing import Any from lib.env.env_file_lib import apply_env_updates, env_get_all, load_env_file_into_environ, read_env_lines from lib.env.env_schema import ( _field_type, _hot_reload, _is_sensitive, _mask_value, _restart_required, parse_env_example_schema, validate_env_updates, ) from lib.paths import REPO_ROOT AI_ENV_FIELDS: list[tuple[str, str, str]] = [ ("AI_PROVIDER", "AI 提供方", "openai 或 ollama"), ("OPENAI_API_BASE", "API 地址", "OpenAI 兼容接口"), ("OPENAI_API_KEY", "API 密钥", "留空表示不修改"), ("OPENAI_MODEL", "云端模型", ""), ("OLLAMA_API", "Ollama 地址", "本地服务 URL"), ("AI_MODEL", "Ollama 模型", ""), ("AI_TIMEOUT_SECONDS", "请求超时(秒)", "默认 120"), ] AI_ENV_KEYS = frozenset(k for k, _l, _n in AI_ENV_FIELDS) INSTANCE_ENV_DIRS: dict[str, Path] = { "okx": REPO_ROOT / "crypto_monitor_okx", "binance": REPO_ROOT / "crypto_monitor_binance", "gate": REPO_ROOT / "crypto_monitor_gate", } def hub_env_path() -> str: return str(REPO_ROOT / "manual_trading_hub" / ".env") def hub_example_path() -> str: return str(REPO_ROOT / "manual_trading_hub" / ".env.example") def instance_example_path(exchange_key: str = "okx") -> str: ex = (exchange_key or "okx").strip().lower() base = INSTANCE_ENV_DIRS.get(ex, INSTANCE_ENV_DIRS["okx"]) return str(base / ".env.example") def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} for group in parse_env_example_schema(example_path): for field in group.get("fields") or []: out[field["key"]] = dict(field) return out def _build_field( key: str, label: str, note: str, schema: dict[str, dict[str, Any]], values: dict[str, str], ) -> dict[str, Any]: meta = schema.get(key) or {} schema_default = meta.get("default") or "" val = values.get(key, "") if val == "" and schema_default: val = schema_default masked = _mask_value(key, val) ftype = meta.get("type") or _field_type(key, val or schema_default) return { "key": key, "label": label, "note": note or meta.get("note") or "", "default": val, "type": ftype, "sensitive": meta.get("sensitive", _is_sensitive(key)), "restart_required": meta.get("restart_required", _restart_required(key)), "hot_reload": meta.get("hot_reload", _hot_reload(key)), "current": masked["value"] if not _is_sensitive(key) else "", "masked": masked["masked"], "tail": masked.get("tail") or "", "has_value": masked["has_value"], } def build_ai_env_payload(env_path: str | None = None, example_path: str | None = None) -> dict[str, Any]: env_path = env_path or hub_env_path() example_path = example_path or hub_example_path() schema = _schema_field_map(example_path) values = env_get_all(read_env_lines(env_path)) fields = [ _build_field(key, label, note, schema, values) for key, label, note in AI_ENV_FIELDS ] sync_status = ai_sync_status() return { "title": "AI 复盘", "fields": fields, "sync_status": sync_status, } def ai_sync_status() -> dict[str, Any]: """比较 hub 与三实例 AI 键是否一致(用于 UI 提示).""" hub_vals = env_get_all(read_env_lines(hub_env_path())) per_instance: dict[str, dict[str, Any]] = {} all_ok = True for ex, inst_dir in INSTANCE_ENV_DIRS.items(): path = str(inst_dir / ".env") if not os.path.isfile(path): per_instance[ex] = {"ok": False, "msg": "缺少 .env"} all_ok = False continue inst_vals = env_get_all(read_env_lines(path)) mismatched = [ k for k in AI_ENV_KEYS if (hub_vals.get(k) or "") != (inst_vals.get(k) or "") ] ok = not mismatched if not ok: all_ok = False per_instance[ex] = { "ok": ok, "mismatched_keys": mismatched, } return {"all_synced": all_ok, "instances": per_instance} def _ai_validate_groups(example_path: str) -> list[dict[str, Any]]: schema = _schema_field_map(example_path) fields: list[dict[str, Any]] = [] for key, _label, _note in AI_ENV_FIELDS: if key in schema: fields.append(schema[key]) else: fields.append( { "key": key, "type": _field_type(key, ""), "sensitive": _is_sensitive(key), "restart_required": _restart_required(key), "hot_reload": _hot_reload(key), } ) return [{"title": "AI 复盘", "fields": fields}] def validate_ai_env_updates(updates: dict[str, str], example_path: str | None = None) -> tuple[dict[str, str], list[str]]: example_path = example_path or hub_example_path() groups = _ai_validate_groups(example_path) filtered = {k: v for k, v in (updates or {}).items() if k in AI_ENV_KEYS} unknown = [k for k in (updates or {}) if k not in AI_ENV_KEYS] errors = [f"未知配置项: {k}" for k in unknown] clean, val_errors = validate_env_updates(groups, filtered) errors.extend(val_errors) return clean, errors def apply_ai_env_to_all(updates: dict[str, str]) -> dict[str, Any]: """写入 hub .env 并强制同步三实例相同键.""" clean, errors = validate_ai_env_updates(updates) if errors: return {"ok": False, "errors": errors, "changed": {}} if not clean: return {"ok": True, "changed": {}, "restart_required": False} changed: dict[str, list[str]] = {} targets = [("hub", hub_env_path())] for ex, inst_dir in INSTANCE_ENV_DIRS.items(): targets.append((ex, str(inst_dir / ".env"))) for name, path in targets: if not os.path.isfile(path): if name == "hub": return {"ok": False, "errors": [f"缺少 {path}"], "changed": {}} continue keys = apply_env_updates(path, clean) if keys: changed[name] = keys load_env_file_into_environ(path) return { "ok": True, "changed": changed, "restart_required": True, "errors": [], } def restart_instances_then_hub_pm2() -> dict[str, Any]: """先重启三实例,最后重启中控(避免当前请求被中断).""" if not sys.platform.startswith("linux"): return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "results": []} from lib.instance.instance_pm2_lib import restart_instance_pm2 results: list[dict[str, Any]] = [] for ex in ("okx", "binance", "gate"): r = restart_instance_pm2(ex) results.append({"exchange": ex, **r}) hub_result = _restart_pm2_app("manual-trading-hub") results.append({"app": "manual-trading-hub", **hub_result}) ok = all(r.get("ok") for r in results) return {"ok": ok, "results": results} def restart_hub_and_instances_pm2() -> dict[str, Any]: """兼容旧调用:与 restart_instances_then_hub_pm2 相同顺序.""" return restart_instances_then_hub_pm2() def _restart_pm2_app(app_name: str) -> dict[str, Any]: try: proc = subprocess.run( ["pm2", "restart", app_name, "--update-env"], capture_output=True, text=True, timeout=120, ) return { "ok": proc.returncode == 0, "msg": (proc.stdout or proc.stderr or "").strip()[:500], "returncode": proc.returncode, } except FileNotFoundError: return {"ok": False, "msg": "未找到 pm2 命令"} except subprocess.TimeoutExpired: return {"ok": False, "msg": "pm2 restart 超时"} except Exception as e: return {"ok": False, "msg": str(e)}