Add hub AI config tab with sync to instances and deploy secret bootstrap.

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>
This commit is contained in:
dekun
2026-07-08 22:50:58 +08:00
parent 886f7740f2
commit 823aeda42a
17 changed files with 653 additions and 54 deletions
+2 -1
View File
@@ -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
+8 -1
View File
@@ -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 ""
+25 -24
View File
@@ -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)
---
+103
View File
@@ -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 说明)
+2 -1
View File
@@ -65,7 +65,8 @@
| 网页登录密码 | ✅ | 本区块 |
| 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 |
| 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 |
| 交易所 API | ❌ | 在 **env 配置** 页 |
| 交易所 API | ❌ | 在 **env 配置**(各所自配) |
| AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) |
### 操作流程
-12
View File
@@ -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": [
+232
View File
@@ -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)}
+1 -1
View File
@@ -4,7 +4,7 @@
<div class="env-config-head-row">
<div>
<h2>env 配置</h2>
<p class="settings-env-hint env-config-head-hint">按分类修改,改完点保存。含「需重启」的项请用「保存并重启」。</p>
<p class="settings-env-hint env-config-head-hint">按分类修改,改完点保存。含「需重启」的项请用「保存并重启」。<strong>AI 配置</strong>请在中控 → 系统设置 → AI 配置统一维护。</p>
</div>
<div class="env-config-toolbar">
<button type="button" class="btn-primary btn-sm" id="env-config-save">保存</button>
+1 -1
View File
@@ -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
+2 -2
View File
@@ -10,7 +10,7 @@
EXCHANGE binance(默认)| okx | gate
PORT 默认 15200(与 crypto_monitor_* 的 Flask APP_PORT 错开;中控默认聚合 1520015202)
HOST 默认 127.0.0.1
CONTROL_TOKEN 可选;请求头 X-Control-Token
HUB_BRIDGE_TOKEN 与中控一致;请求头 X-Control-Token(优先于已废弃的 CONTROL_TOKEN
BinanceBINANCE_API_KEY / BINANCE_API_SECRET;余额为 **U 本位永续合约账户** USDT(与 `crypto_monitor_binance` 的合约口径一致,非现货钱包);BINANCE_POSITION_MODEBINANCE_MARGIN_MODE
OKXOKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASEOKX_TD_MODEOKX_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"):
+39
View File
@@ -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:
+17
View File
@@ -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"):
+8 -2
View File
@@ -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);
+106
View File
@@ -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 ? `<span class="field-note">${esc(f.note)}</span>` : "";
return `<div class="field${wide}">
<label for="hub-ai-${esc(f.key)}">${esc(f.label || f.key)}${note}</label>
<input id="hub-ai-${esc(f.key)}" class="hub-ai-field" data-ai-key="${esc(f.key)}" type="${inputType}" value="${value}" placeholder="${esc(placeholder)}" autocomplete="off" />
</div>`;
})
.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 = `<span class="err">${esc(String(e.message || e))}</span>`;
}
}
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);
+19 -1
View File
@@ -15,7 +15,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260708-hub-settings-tabs" />
<link rel="stylesheet" href="/assets/app.css?v=20260708-hub-ai-env" />
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=3" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
<script src="/assets/account_risk_badge.js?v=4"></script>
@@ -904,6 +904,7 @@
<input type="radio" name="hub-settings-section" id="hub-sec-3" class="hub-tab-radio">
<input type="radio" name="hub-settings-section" id="hub-sec-4" class="hub-tab-radio">
<input type="radio" name="hub-settings-section" id="hub-sec-5" class="hub-tab-radio">
<input type="radio" name="hub-settings-section" id="hub-sec-6" class="hub-tab-radio">
<div class="hub-config-tabs" role="tablist" aria-label="系统设置分类">
<label for="hub-sec-0" class="hub-tab-btn" role="tab">账户密码</label>
<label for="hub-sec-1" class="hub-tab-btn" role="tab">显示与导航</label>
@@ -911,6 +912,7 @@
<label for="hub-sec-3" class="hub-tab-btn" role="tab">交易监管</label>
<label for="hub-sec-4" class="hub-tab-btn" role="tab">备份恢复</label>
<label for="hub-sec-5" class="hub-tab-btn" role="tab">交易所账户</label>
<label for="hub-sec-6" class="hub-tab-btn" role="tab">AI 配置</label>
</div>
<div class="hub-config-panels">
<section class="hub-panel hub-panel--0 hub-settings-tab-panel" role="tabpanel">
@@ -1110,6 +1112,22 @@
</div>
<div id="settings-list" class="settings-grid-wrap"></div>
</section>
<section class="hub-panel hub-panel--6 hub-settings-tab-panel settings-ai-env-panel" data-settings-section="ai-env" role="tabpanel">
<div class="hub-settings-tab-head">
<h3 class="hub-settings-tab-title">AI 复盘(统一配置)</h3>
<button type="button" class="primary" id="hub-ai-env-save-btn">保存并同步</button>
</div>
<p class="settings-display-hint">
保存后写入 <code>manual_trading_hub/.env</code>,并<strong>强制同步</strong>至三所实例 <code>.env</code>OKX / Binance / Gate)。
实例 env 配置页已不再提供 AI 项,请仅在此修改。密钥留空表示不修改原值。
</p>
<p id="hub-ai-sync-status" class="settings-meta-line"></p>
<div id="hub-ai-env-fields" class="settings-grid hub-ai-env-grid"></div>
<div class="hub-settings-tab-actions">
<span id="hub-ai-env-save-status" class="settings-status-line"></span>
</div>
</section>
</div>
</div>
+19 -8
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env python3
"""首次部署:自动生成中控通信密钥、登录会话密钥,并写入初始登录账号。
- HUB_BRIDGE_TOKEN中控 + 三实例相同
- HUB_BRIDGE_TOKEN中控 + 三实例相同仅空/占位时写入不覆盖已有
- FLASK_SECRET_KEY三实例相同
- APP_USERNAME=adminAPP_PASSWORD=admin123仅当 .env 中尚未设置密码时写入
- HUB_SESSION_SECRET仅中控
- APP_USERNAME=adminAPP_PASSWORD=admin123实例仅空时
- HUB_USERNAME=adminHUB_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
+69
View File
@@ -0,0 +1,69 @@
"""shared_env_libAI 字段与四文件同步。"""
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()