diff --git a/deploy/README.md b/deploy/README.md index 579aaf4..82dc397 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -53,6 +53,7 @@ sed -i 's/\r$//' deploy/setup_env.sh | `crypto_monitor_*` | 各目录 `.venv` + `pip install -r ../requirements.txt` | | `manual_trading_hub` | 独立 `requirements.txt` | | `.env` | 不存在则从 `.env.example` 复制 | +| 部署密钥 | `python3 scripts/bootstrap_deploy_secrets.py`(不覆盖已有值) | | 目录 | `static/images`、`static/images/order_charts` | | PM2 | 已装 Node 时 `npm install -g pm2` | @@ -60,7 +61,7 @@ sed -i 's/\r$//' deploy/setup_env.sh ## 部署之后 -1. 编辑各子目录 **`.env`**(API、登录密码、SOCKS、AI 复盘等)。 +1. 编辑各子目录 **`.env`**(交易所 API、企业微信等;**AI 请在中控系统设置 → AI 配置**)。 2. **仅用 PM2 常驻**(见 [docs/ubuntu-server.md](../docs/ubuntu-server.md) §3): ```bash diff --git a/deploy/setup_env.sh b/deploy/setup_env.sh index 634d733..e5b7f2c 100644 --- a/deploy/setup_env.sh +++ b/deploy/setup_env.sh @@ -249,8 +249,15 @@ should_include hub && setup_hub install_pm2 +step "部署密钥(首次自动生成,不覆盖已有)" +if command -v python3 >/dev/null 2>&1; then + python3 "${REPO_ROOT}/scripts/bootstrap_deploy_secrets.py" || true +else + echo " 跳过 bootstrap_deploy_secrets(未找到 python3)" +fi + echo "" echo "部署完成。下一步:" -echo " 1. 编辑各子目录 .env" +echo " 1. 编辑各子目录 .env(交易所 API 等;AI 请在中控系统设置配置)" echo " 2. 编辑各目录 .env 后使用 PM2: pm2 start ecosystem.config.cjs(见 docs/ubuntu-server.md)" echo "" diff --git a/docs/env配置说明.md b/docs/env配置说明.md index 3c3c018..229b00a 100644 --- a/docs/env配置说明.md +++ b/docs/env配置说明.md @@ -13,7 +13,8 @@ | **只展示运营相关项** | 不暴露全量 `.env`,避免误改基础设施 | | **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 | | **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 | -| **密钥自动托管** | 中控通信密钥、登录会话密钥由 **首次部署脚本自动生成并写入**,本页不提供编辑 | +| **密钥自动托管** | 中控通信密钥、登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成、不轮换),本页不提供编辑 | +| **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 | | **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 | --- @@ -26,11 +27,14 @@ |------|----------|----------|------| | **中控通信密钥** | `HUB_BRIDGE_TOKEN` | 中控 `manual_trading_hub/.env` + 三实例 `.env`(**相同值**) | 中控调用实例 API(`X-Hub-Token`)、iframe SSO 签发与校验 | | **登录会话密钥** | `FLASK_SECRET_KEY` | 三实例 `.env`(**三所相同**) | Flask Session 签名;与网页登录态相关,与中控密钥 **分离** | +| **中控会话密钥** | `HUB_SESSION_SECRET` | 中控 `.env` | 中控登录 Cookie 签名 | | **登录账号** | `APP_USERNAME` / `APP_PASSWORD` | 三实例 `.env`(建议三所统一) | 直链实例 `/login` 使用;**仅在系统设置中修改** | +| **中控登录** | `HUB_USERNAME` / `HUB_PASSWORD` | 中控 `.env` | 中控网页登录;**仅在中控系统设置改密** | 说明: -- **中控密钥 ≠ 登录密钥**:前者给中控与实例之间通信用;后者给浏览器 Session 用。 +- **长期密钥一次生成、不轮换**:`setup_env.sh` 末尾调用 `scripts/bootstrap_deploy_secrets.py`;已有非占位值不会被覆盖。 +- **SSO 链接不变**:仍为中控每次签发、默认 2 小时有效、单次使用(`HUB_SSO_TTL_SEC`),与长期 `HUB_BRIDGE_TOKEN` 分离。 - 经中控 iframe / SSO 打开实例时,可免输实例密码;直链 IP/域名仍走 `/login`。 - 首次部署可生成随机强密码;用户日后在 **系统设置** 改密,不经过本页。 @@ -40,9 +44,11 @@ | 列 1 | 列 2 | 列 3 | |------|------|------| -| 交易所与实盘 | 企业微信 | AI 复盘 | -| 交易执行 | 交易风控 | 账户冷静期 | -| 自动划转 | 当日资金 | 期权账户(仅 OKX) | +| 交易所与实盘 | 企业微信 | 交易执行 | +| 交易风控 | 账户冷静期 | 自动划转 | +| 当日资金 | 期权账户(仅 OKX) | | + +> **AI 复盘**(OpenAI / Ollama)已移至中控 **系统设置 → AI 配置**,保存后强制同步三所 `.env`。详见 [中控AI与密钥配置.md](./中控AI与密钥配置.md)。 Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本所无期权」。 @@ -76,17 +82,11 @@ Binance / Gate 无期权模块时,第三列最后一格不显示或显示「 --- -### 4.3 AI 复盘 +### 4.3 AI 复盘(已移至中控) -| 中文名 | 说明 | -|--------|------| -| AI 提供方 | 云端 OpenAI 兼容 / 本地 Ollama | -| API 地址 | OpenAI 兼容 base URL | -| API 密钥 | 敏感,留空表示不修改 | -| 模型名称 | 如 gemma、deepseek 等 | -| Ollama 地址 | 本地服务 URL | -| Ollama 模型 | 本地模型名 | -| 请求超时(秒) | 默认 120 | +AI 相关环境变量(`AI_PROVIDER`、`OPENAI_*`、`OLLAMA_*`、`AI_MODEL`、`AI_TIMEOUT_SECONDS`)**不再在本页展示**。 + +请在中控 **系统设置 → AI 配置** 修改;保存后写入中控 `.env` 并 **强制同步** 至 OKX / Binance / Gate 三实例。详见 [中控AI与密钥配置.md](./中控AI与密钥配置.md)。 --- @@ -208,16 +208,17 @@ Binance / Gate 无期权模块时,第三列最后一格不显示或显示「 ## 7. 与系统设置的关系 -| 能力 | env 配置 | 系统设置 | -|------|----------|----------| -| 登录用户名/密码 | ❌ | ✅ 账户密码修改 | -| 交易所 API | ✅ | ❌ | -| 导航/区块显示 | ❌ | ✅ 导航显示 | -| 手动资金划转 | ❌ | ✅ 永续资金划转 | -| 数据导出 | ❌ | ✅ 数据导出 | -| 期权兑换/划转 UI | ❌ | ✅(OKX,可开关) | +| 能力 | env 配置 | 系统设置 | 中控系统设置 | +|------|----------|----------|--------------| +| 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 | +| 交易所 API | ✅(各所自配) | ❌ | ❌ | +| AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) | +| 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 | +| 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ | +| 数据导出 | ❌ | ✅ 数据导出 | ❌ | +| 期权兑换/划转 UI | ❌ | ✅(OKX,可开关) | ❌ | -系统设置说明见 [系统设置说明.md](./系统设置说明.md)。 +系统设置说明见 [系统设置说明.md](./系统设置说明.md);中控 AI 与部署密钥见 [中控AI与密钥配置.md](./中控AI与密钥配置.md)。 --- diff --git a/docs/中控AI与密钥配置.md b/docs/中控AI与密钥配置.md new file mode 100644 index 0000000..d941261 --- /dev/null +++ b/docs/中控AI与密钥配置.md @@ -0,0 +1,103 @@ +# 中控 AI 与部署密钥配置 + +本文档说明:**长期部署密钥**(一次生成、不轮换)、**SSO 临时链接**(保持不动)、以及 **AI 配置**(中控统一维护并同步三所)。 + +--- + +## 1. 三类「密钥」分工 + +| 类型 | 变量 / 机制 | 谁维护 | 是否自动过期 | +|------|-------------|--------|--------------| +| **长期通信密钥** | `HUB_BRIDGE_TOKEN` | 部署脚本首次写入四份 `.env` | 否,不轮换 | +| **实例 Session 签名** | `FLASK_SECRET_KEY` | 部署脚本首次写入三实例 | 否 | +| **中控 Session 签名** | `HUB_SESSION_SECRET` | 部署脚本首次写入中控 | 否 | +| **SSO 开门链接** | `/hub-sso?token=...` | 中控每次点「打开实例」签发 | 默认 2h + 单次 | +| **AI 配置** | `OPENAI_*`、`AI_*` 等 | 中控系统设置 → AI 配置 | 否 | + +**SSO 保持不动**:仍为随机 nonce、默认 `HUB_SSO_TTL_SEC=7200`、成功登录一次后链接作废。长期 `HUB_BRIDGE_TOKEN` 只用于签名,不会每 2 小时变化。 + +--- + +## 2. 首次部署自动生成 + +`bash deploy/setup_env.sh` 在复制 `.env.example` 后自动执行: + +```bash +python3 scripts/bootstrap_deploy_secrets.py +``` + +| 写入项 | 位置 | 规则 | +|--------|------|------| +| `HUB_BRIDGE_TOKEN` | 中控 + 三实例(同值) | 仅空或占位符时写入 | +| `FLASK_SECRET_KEY` | 三实例(同值) | 仅空或占位符时写入 | +| `HUB_SESSION_SECRET` | 中控 | 仅空时写入 | +| `HUB_USERNAME` / `HUB_PASSWORD` | 中控 | 默认 admin / admin123(仅空时) | +| `APP_USERNAME` / `APP_PASSWORD` | 三实例 | 默认 admin / admin123(仅空时) | + +**已有非空生产值不会被覆盖**(一次生成、不轮换)。 + +子代理 `agent.py` 优先读取 `HUB_BRIDGE_TOKEN` 作为 `X-Control-Token` 校验;独立配置 `CONTROL_TOKEN` 已废弃。 + +--- + +## 3. 中控系统设置 → AI 配置 + +路径:**中控 Web → 系统设置 → AI 配置** Tab。 + +### 3.1 可配置项 + +| 中文名 | 环境变量 | +|--------|----------| +| AI 提供方 | `AI_PROVIDER` | +| API 地址 | `OPENAI_API_BASE` | +| API 密钥 | `OPENAI_API_KEY`(掩码,留空不修改) | +| 云端模型 | `OPENAI_MODEL` | +| Ollama 地址 | `OLLAMA_API` | +| Ollama 模型 | `AI_MODEL` | +| 请求超时(秒) | `AI_TIMEOUT_SECONDS` | + +### 3.2 保存行为 + +1. 写入 `manual_trading_hub/.env` +2. **强制同步** 至 `crypto_monitor_okx/binance/gate/.env` 相同键 +3. 自动 `pm2 restart` 中控 + 三实例(`--update-env`) + +### 3.3 API(需已登录中控) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/settings/ai-env` | 读取字段与三所同步状态 | +| POST | `/api/settings/ai-env` | body: `{ "values": {...}, "restart": true }` | + +实现:`lib/env/shared_env_lib.py`、`manual_trading_hub/hub_env_lib.py`。 + +--- + +## 4. 实例 env 配置页变更 + +三所 **env 配置** 页已 **移除「AI 复盘」卡片**。交易所 API、企业微信、交易执行等仍各所自配。 + +实例侧若通过 API 提交已移除的 AI 键,会被白名单过滤,不会写入。 + +--- + +## 5. 与系统设置 / env 页对照 + +| 能力 | 实例 env | 实例系统设置 | 中控系统设置 | +|------|----------|--------------|--------------| +| 交易所 API | ✅ | ❌ | ❌ | +| OpenAI / AI | ❌ | ❌ | ✅(同步三所) | +| 实例登录密码 | ❌ | ✅ | ❌ | +| 中控登录密码 | ❌ | ❌ | ✅ | +| Bridge / Flask 长期密钥 | ❌(自动) | ❌ | ❌(自动) | +| SSO 链接 | — | — | 每次打开实例自动签发 | + +--- + +## 6. 运维提示 + +- 修改 AI 后若未自动重启成功,手动:`pm2 restart manual-trading-hub crypto_okx crypto_binance crypto_gate --update-env` +- 三所 AI 不一致时,中控 AI 配置页会提示「未完全同步」;点 **保存并同步** 即可对齐 +- 备份包可选包含各 `.env`(中控设置 → 备份恢复) + +相关文档:[env配置说明.md](./env配置说明.md)、[系统设置说明.md](./系统设置说明.md)、[manual_trading_hub/局域网与反代部署说明.md](../manual_trading_hub/局域网与反代部署说明.md)(SSO 2h 说明) diff --git a/docs/系统设置说明.md b/docs/系统设置说明.md index 68ab9db..96b6240 100644 --- a/docs/系统设置说明.md +++ b/docs/系统设置说明.md @@ -65,7 +65,8 @@ | 网页登录密码 | ✅ | 本区块 | | 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 | | 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 | -| 交易所 API | ❌ | 在 **env 配置** 页 | +| 交易所 API | ❌ | 在 **env 配置** 页(各所自配) | +| AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) | ### 操作流程 diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index b267bf3..d808c5d 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -52,18 +52,6 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [ ("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"), ], }, - { - "title": "AI 复盘", - "fields": [ - ("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"), - ], - }, { "title": "交易执行", "fields": [ diff --git a/lib/env/shared_env_lib.py b/lib/env/shared_env_lib.py new file mode 100644 index 0000000..ceef445 --- /dev/null +++ b/lib/env/shared_env_lib.py @@ -0,0 +1,232 @@ +"""中控统一 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)} diff --git a/lib/instance/templates/env_config_panel.html b/lib/instance/templates/env_config_panel.html index a49ba41..609b0d8 100644 --- a/lib/instance/templates/env_config_panel.html +++ b/lib/instance/templates/env_config_panel.html @@ -4,7 +4,7 @@
按分类修改,改完点保存。含「需重启」的项请用「保存并重启」。
+按分类修改,改完点保存。含「需重启」的项请用「保存并重启」。AI 配置请在中控 → 系统设置 → AI 配置统一维护。