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 @@

env 配置

-

按分类修改,改完点保存。含「需重启」的项请用「保存并重启」。

+

按分类修改,改完点保存。含「需重启」的项请用「保存并重启」。AI 配置请在中控 → 系统设置 → AI 配置统一维护。

diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example index e0eacc0..556900d 100644 --- a/manual_trading_hub/.env.example +++ b/manual_trading_hub/.env.example @@ -11,7 +11,7 @@ HUB_PORT=5100 # 与三实例 .env 中 HUB_BRIDGE_TOKEN 相同的长随机串 # 中控 → 各 Flask:请求头 X-Hub-Token -# 中控 → 各子代理:请求头 X-Control-Token(可与子代理 CONTROL_TOKEN 同值,hub 会用 HUB_BRIDGE_TOKEN 转发) +# 中控 → 各子代理:请求头 X-Control-Token(与 HUB_BRIDGE_TOKEN 同值;agent 优先读 HUB_BRIDGE_TOKEN) # 中控「打开实例」SSO 链接也复用此令牌签名(默认 2 小时内有效、单次使用) # HUB_BRIDGE_TOKEN=your-long-random-token # HUB_SSO_TTL_SEC=7200 diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py index 8a57f1c..f87d5af 100644 --- a/manual_trading_hub/agent.py +++ b/manual_trading_hub/agent.py @@ -10,7 +10,7 @@ EXCHANGE binance(默认)| okx | gate PORT 默认 15200(与 crypto_monitor_* 的 Flask APP_PORT 错开;中控默认聚合 15200–15202) HOST 默认 127.0.0.1 - CONTROL_TOKEN 可选;请求头 X-Control-Token + HUB_BRIDGE_TOKEN 与中控一致;请求头 X-Control-Token(优先于已废弃的 CONTROL_TOKEN) Binance:BINANCE_API_KEY / BINANCE_API_SECRET;余额为 **U 本位永续合约账户** USDT(与 `crypto_monitor_binance` 的合约口径一致,非现货钱包);BINANCE_POSITION_MODE;BINANCE_MARGIN_MODE OKX:OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE;OKX_TD_MODE;OKX_POS_MODE @@ -54,7 +54,7 @@ from exchange_orders import ( HOST = os.getenv("HOST", "127.0.0.1") PORT = int(os.getenv("PORT", "15200")) -CONTROL_TOKEN = (os.getenv("CONTROL_TOKEN") or "").strip() +CONTROL_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip() _raw_ex = (os.getenv("EXCHANGE") or "binance").strip().lower() if _raw_ex in ("binance", "bnb", "ba"): diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 10f714f..9dc6ee6 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -1692,6 +1692,45 @@ def api_admin_restart(): return result +class HubAiEnvBody(BaseModel): + values: dict[str, str] = Field(default_factory=dict) + restart: bool = True + + +@app.get("/api/settings/ai-env") +def api_get_ai_env(request: Request): + _require_hub_logged_in(request) + from hub_env_lib import get_hub_ai_env_payload + + return {"ok": True, **get_hub_ai_env_payload()} + + +@app.post("/api/settings/ai-env") +def api_save_ai_env(request: Request, body: HubAiEnvBody): + _require_hub_logged_in(request) + from hub_env_lib import get_hub_ai_env_payload, restart_all_pm2, save_hub_ai_env + + result = save_hub_ai_env(body.values or {}) + if not result.get("ok"): + raise HTTPException(status_code=400, detail="; ".join(result.get("errors") or ["保存失败"])) + restart_result = None + if body.restart and result.get("restart_required"): + restart_result = restart_all_pm2() + if not restart_result.get("ok"): + raise HTTPException( + status_code=500, + detail="保存成功但 PM2 重启失败,请手动 restart", + ) + payload = get_hub_ai_env_payload() + return { + "ok": True, + "changed": result.get("changed") or {}, + "restart_required": bool(result.get("restart_required")), + "restart": restart_result, + "sync_status": payload.get("sync_status"), + } + + async def _fetch_agent_status(client: httpx.AsyncClient, ex: dict) -> dict: url = f"{ex['agent_url'].rstrip('/')}/status" try: diff --git a/manual_trading_hub/hub_env_lib.py b/manual_trading_hub/hub_env_lib.py index f08a2e0..ffc9ece 100644 --- a/manual_trading_hub/hub_env_lib.py +++ b/manual_trading_hub/hub_env_lib.py @@ -8,6 +8,11 @@ from pathlib import Path from typing import Any from lib.env.env_file_lib import apply_env_updates, load_env_file_into_environ +from lib.env.shared_env_lib import ( + apply_ai_env_to_all, + build_ai_env_payload, + restart_hub_and_instances_pm2, +) HUB_DIR = Path(__file__).resolve().parent @@ -27,6 +32,18 @@ def update_hub_credentials(*, new_password: str, new_username: str | None = None return changed +def get_hub_ai_env_payload() -> dict[str, Any]: + return build_ai_env_payload(hub_env_path()) + + +def save_hub_ai_env(updates: dict[str, str]) -> dict[str, Any]: + return apply_ai_env_to_all(updates) + + +def restart_all_pm2() -> dict[str, Any]: + return restart_hub_and_instances_pm2() + + def restart_hub_pm2() -> dict[str, Any]: app_name = (os.getenv("PM2_APP_NAME") or "").strip() or "manual-trading-hub" if not sys.platform.startswith("linux"): diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 6e5fe20..99e33bd 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -3042,7 +3042,8 @@ button.btn-sm { #hub-sec-2:checked ~ .hub-config-tabs label[for="hub-sec-2"], #hub-sec-3:checked ~ .hub-config-tabs label[for="hub-sec-3"], #hub-sec-4:checked ~ .hub-config-tabs label[for="hub-sec-4"], -#hub-sec-5:checked ~ .hub-config-tabs label[for="hub-sec-5"] { +#hub-sec-5:checked ~ .hub-config-tabs label[for="hub-sec-5"], +#hub-sec-6:checked ~ .hub-config-tabs label[for="hub-sec-6"] { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; @@ -3053,7 +3054,8 @@ button.btn-sm { #hub-sec-2:checked ~ .hub-config-panels .hub-panel--2, #hub-sec-3:checked ~ .hub-config-panels .hub-panel--3, #hub-sec-4:checked ~ .hub-config-panels .hub-panel--4, -#hub-sec-5:checked ~ .hub-config-panels .hub-panel--5 { +#hub-sec-5:checked ~ .hub-config-panels .hub-panel--5, +#hub-sec-6:checked ~ .hub-config-panels .hub-panel--6 { display: block; } @@ -3099,6 +3101,10 @@ button.btn-sm { margin-top: 8px; } +.hub-ai-env-grid .field-wide { + grid-column: 1 / -1; +} + .settings-status-line { font-size: 0.8rem; color: var(--muted); diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 4630a2a..b30b4d5 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -4347,6 +4347,111 @@ }); } + function renderHubAiSyncStatus(sync) { + const el = document.getElementById("hub-ai-sync-status"); + if (!el) return; + if (!sync) { + el.textContent = ""; + return; + } + if (sync.all_synced) { + el.textContent = "三所实例 AI 配置已与中控同步"; + el.classList.remove("is-err"); + return; + } + const parts = []; + const inst = sync.instances || {}; + Object.keys(inst).forEach((ex) => { + const row = inst[ex]; + if (row && !row.ok) parts.push(`${ex} 不一致`); + }); + el.textContent = parts.length ? `未完全同步:${parts.join("、")}` : "同步状态未知"; + el.classList.add("is-err"); + } + + function renderHubAiEnvFields(fields) { + const box = document.getElementById("hub-ai-env-fields"); + if (!box) return; + box.innerHTML = (fields || []) + .map((f) => { + const wide = f.key === "OPENAI_API_BASE" || f.key === "OLLAMA_API" ? " field-wide" : ""; + const inputType = f.sensitive ? "password" : f.type === "int" ? "number" : "text"; + const placeholder = f.sensitive && f.has_value ? f.masked || "****" : f.note || ""; + const value = f.sensitive ? "" : esc(f.current || f.default || ""); + const note = f.note ? `${esc(f.note)}` : ""; + return `
+ + +
`; + }) + .join(""); + } + + async function loadHubAiEnvSettings() { + const box = document.getElementById("hub-ai-env-fields"); + if (!box) return; + try { + const r = await apiFetch("/api/settings/ai-env"); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || "加载失败"); + renderHubAiEnvFields(j.fields || []); + renderHubAiSyncStatus(j.sync_status); + } catch (e) { + box.innerHTML = `${esc(String(e.message || e))}`; + } + } + + function collectHubAiEnvValues() { + const values = {}; + document.querySelectorAll(".hub-ai-field[data-ai-key]").forEach((el) => { + values[el.dataset.aiKey] = el.value; + }); + return values; + } + + async function saveHubAiEnv() { + const status = document.getElementById("hub-ai-env-save-status"); + const setStatus = (msg, err) => { + if (!status) return; + status.textContent = msg || ""; + status.classList.toggle("is-err", !!err); + }; + setStatus("保存并同步中…"); + try { + const r = await apiFetch("/api/settings/ai-env", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values: collectHubAiEnvValues(), restart: true }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "保存失败"); + if (!j.changed || !Object.keys(j.changed).length) { + setStatus("未修改(与当前配置相同)"); + showToast("未修改"); + return; + } + setStatus("已同步三所,服务重启中…"); + await waitHubHealth(); + setStatus("AI 配置已保存并同步至三所实例"); + showToast("AI 配置已保存并同步"); + await loadHubAiEnvSettings(); + } catch (e) { + setStatus(String(e.message || e), true); + showToast(String(e.message || e), true); + } + } + + function initHubAiEnvSettings() { + const btn = document.getElementById("hub-ai-env-save-btn"); + if (btn && btn.dataset.bound !== "1") { + btn.dataset.bound = "1"; + btn.addEventListener("click", () => { + void saveHubAiEnv(); + }); + } + void loadHubAiEnvSettings(); + } + function macroDatetimeLocalToApi(v) { if (!v) return ""; return String(v).trim().replace("T", " ").slice(0, 16); @@ -4491,6 +4596,7 @@ loadSettingsMetaLine(); initMacroCalendarSettings(); initHubPasswordSettings(); + initHubAiEnvSettings(); loadMacroCalendarUI(); loadSettings().then((data) => { syncDisplayPrefsUI(data); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 8eff7a3..62a812c 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -904,6 +904,7 @@ +
@@ -911,6 +912,7 @@ +
@@ -1110,6 +1112,22 @@
+ +
+
+

AI 复盘(统一配置)

+ +
+

+ 保存后写入 manual_trading_hub/.env,并强制同步至三所实例 .env(OKX / Binance / Gate)。 + 实例 env 配置页已不再提供 AI 项,请仅在此修改。密钥留空表示不修改原值。 +

+

+
+
+ +
+
diff --git a/scripts/bootstrap_deploy_secrets.py b/scripts/bootstrap_deploy_secrets.py index 83593c3..493718c 100644 --- a/scripts/bootstrap_deploy_secrets.py +++ b/scripts/bootstrap_deploy_secrets.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """首次部署:自动生成中控通信密钥、登录会话密钥,并写入初始登录账号。 -- HUB_BRIDGE_TOKEN:中控 + 三实例(相同) +- HUB_BRIDGE_TOKEN:中控 + 三实例(相同,仅空/占位时写入,不覆盖已有) - FLASK_SECRET_KEY:三实例(相同) -- APP_USERNAME=admin、APP_PASSWORD=admin123:仅当 .env 中尚未设置密码时写入 +- HUB_SESSION_SECRET:仅中控 +- APP_USERNAME=admin、APP_PASSWORD=admin123:实例(仅空时) +- HUB_USERNAME=admin、HUB_PASSWORD=admin123:中控(仅空时) -已有非空且非占位符的值不会被覆盖。 +已有非空且非占位符的值不会被覆盖(长期密钥一次生成、不轮换)。 """ from __future__ import annotations @@ -31,6 +33,7 @@ FLASK_PLACEHOLDERS = frozenset( {"", "CHANGE_TO_LONG_RANDOM_SECRET", "crypto_monitor_2026_secret_key"} ) HUB_PLACEHOLDERS = frozenset({"", "your-long-random-token"}) +SESSION_PLACEHOLDERS = frozenset({"", "another-long-random-string", "hub-dev-insecure"}) def _env_path(base: str) -> str: @@ -49,16 +52,24 @@ def main() -> int: hub_token = secrets.token_urlsafe(32) flask_secret = secrets.token_urlsafe(48) + session_secret = secrets.token_urlsafe(48) planned: list[tuple[str, dict[str, str]]] = [] - # 中控 HUB_BRIDGE_TOKEN hub_env = _env_path(HUB_DIR) if os.path.isfile(hub_env): hub_lines = read_env_lines(hub_env) + hub_updates: dict[str, str] = {} if _should_set(env_get(hub_lines, "HUB_BRIDGE_TOKEN"), HUB_PLACEHOLDERS): - planned.append((hub_env, {"HUB_BRIDGE_TOKEN": hub_token})) + hub_updates["HUB_BRIDGE_TOKEN"] = hub_token + if _should_set(env_get(hub_lines, "HUB_SESSION_SECRET"), SESSION_PLACEHOLDERS): + hub_updates["HUB_SESSION_SECRET"] = session_secret + if not (env_get(hub_lines, "HUB_USERNAME") or "").strip(): + hub_updates["HUB_USERNAME"] = "admin" + if _should_set(env_get(hub_lines, "HUB_PASSWORD"), frozenset({""})): + hub_updates["HUB_PASSWORD"] = "admin123" + if hub_updates: + planned.append((hub_env, hub_updates)) - # 三实例 for _name, inst_dir in INSTANCE_DIRS: path = _env_path(inst_dir) if not os.path.isfile(path): @@ -90,8 +101,8 @@ def main() -> int: print(f"已写入 {rel}: {keys}") if not args.dry_run: - print("完成。初始登录:admin / admin123(若本次写入了 APP_PASSWORD)。") - print("请 pm2 restart 中控与三实例使 FLASK_SECRET_KEY / HUB_BRIDGE_TOKEN 生效。") + print("完成。初始登录:admin / admin123(若本次写入了密码项)。") + print("请 pm2 restart 中控与三实例使密钥生效。") return 0 diff --git a/tests/test_shared_env_lib.py b/tests/test_shared_env_lib.py new file mode 100644 index 0000000..0ff7d8b --- /dev/null +++ b/tests/test_shared_env_lib.py @@ -0,0 +1,69 @@ +"""shared_env_lib:AI 字段与四文件同步。""" +from __future__ import annotations + +import os +import tempfile +import unittest + +from lib.env.env_file_lib import apply_env_updates, read_env_lines +from lib.env.shared_env_lib import ( + AI_ENV_KEYS, + apply_ai_env_to_all, + build_ai_env_payload, + validate_ai_env_updates, +) + + +class TestSharedEnvLib(unittest.TestCase): + def test_ai_keys_frozen(self) -> None: + self.assertIn("OPENAI_API_KEY", AI_ENV_KEYS) + self.assertIn("AI_PROVIDER", AI_ENV_KEYS) + + def test_validate_rejects_unknown(self) -> None: + clean, errors = validate_ai_env_updates({"NOT_A_KEY": "x"}) + self.assertEqual(clean, {}) + self.assertTrue(any("未知" in e for e in errors)) + + def test_validate_skips_masked_secret(self) -> None: + clean, errors = validate_ai_env_updates({"OPENAI_API_KEY": "****abcd"}) + self.assertEqual(errors, []) + self.assertNotIn("OPENAI_API_KEY", clean) + + def test_apply_syncs_hub_and_instances(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + hub = os.path.join(tmp, "manual_trading_hub") + okx = os.path.join(tmp, "crypto_monitor_okx") + os.makedirs(hub) + os.makedirs(okx) + hub_env = os.path.join(hub, ".env") + okx_env = os.path.join(okx, ".env") + example = os.path.join(hub, ".env.example") + with open(example, "w", encoding="utf-8") as f: + f.write("AI_PROVIDER=openai\nOPENAI_API_KEY=\n") + with open(hub_env, "w", encoding="utf-8") as f: + f.write("AI_PROVIDER=openai\n") + with open(okx_env, "w", encoding="utf-8") as f: + f.write("AI_PROVIDER=ollama\n") + + import lib.env.shared_env_lib as mod + + orig_hub = mod.hub_env_path + orig_dirs = dict(mod.INSTANCE_ENV_DIRS) + try: + mod.hub_env_path = lambda: hub_env # type: ignore[method-assign] + mod.hub_example_path = lambda: example # type: ignore[method-assign] + mod.INSTANCE_ENV_DIRS = {"okx": __import__("pathlib").Path(okx)} # type: ignore[misc] + + result = apply_ai_env_to_all({"AI_PROVIDER": "openai", "OPENAI_MODEL": "gpt-test"}) + self.assertTrue(result["ok"]) + self.assertEqual(read_env_lines(hub_env)[0], "AI_PROVIDER=openai") + okx_lines = read_env_lines(okx_env) + self.assertIn("AI_PROVIDER=openai", okx_lines) + self.assertIn("OPENAI_MODEL=gpt-test", okx_lines) + finally: + mod.hub_env_path = orig_hub # type: ignore[method-assign] + mod.INSTANCE_ENV_DIRS = orig_dirs # type: ignore[misc] + + +if __name__ == "__main__": + unittest.main()