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:
+2
-1
@@ -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
@@ -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
@@ -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)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -65,7 +65,8 @@
|
||||
| 网页登录密码 | ✅ | 本区块 |
|
||||
| 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 |
|
||||
| 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 |
|
||||
| 交易所 API | ❌ | 在 **env 配置** 页 |
|
||||
| 交易所 API | ❌ | 在 **env 配置** 页(各所自配) |
|
||||
| AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) |
|
||||
|
||||
### 操作流程
|
||||
|
||||
|
||||
Vendored
-12
@@ -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": [
|
||||
|
||||
Vendored
+232
@@ -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)}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user