823aeda42a
Wire bootstrap_deploy_secrets into setup_env.sh (one-time HUB_BRIDGE_TOKEN, FLASK_SECRET_KEY, HUB_SESSION_SECRET). Remove AI section from instance env UI; hub saves OPENAI settings to all four .env files. SSO unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
233 lines
7.7 KiB
Python
233 lines
7.7 KiB
Python
"""中控统一 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_hub_and_instances_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
|
|
|
|
apps = ["manual-trading-hub"]
|
|
results: list[dict[str, Any]] = []
|
|
hub_result = _restart_pm2_app("manual-trading-hub")
|
|
results.append({"app": "manual-trading-hub", **hub_result})
|
|
for ex in ("okx", "binance", "gate"):
|
|
r = restart_instance_pm2(ex)
|
|
results.append({"exchange": ex, **r})
|
|
ok = all(r.get("ok") for r in results)
|
|
return {"ok": ok, "results": results}
|
|
|
|
|
|
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)}
|