系统设置增加中控企业微信告警,后台巡检离线/故障并冷却推送。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-03 02:32:56 +08:00
parent 69fa7f50f1
commit af5b9835ba
9 changed files with 688 additions and 292 deletions
+15 -9
View File
@@ -1,9 +1,15 @@
# crypto_okx_hub # crypto_okx_hub
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=5100 APP_PORT=5100
HUB_USERNAME=admin HUB_USERNAME=admin
HUB_PASSWORD=admin123 HUB_PASSWORD=admin123
FLASK_SECRET_KEY=change-me-to-long-random FLASK_SECRET_KEY=change-me-to-long-random
DB_PATH=hub.db DB_PATH=hub.db
HUB_PULL_INTERVAL_SEC=10 HUB_PULL_INTERVAL_SEC=10
HUB_PULL_TIMEOUT_SEC=5 HUB_PULL_TIMEOUT_SEC=5
# 中控企业微信(离线/故障告警,与客户端通知分开)
HUB_WECHAT_ENABLED=false
HUB_WECHAT_WEBHOOK=
HUB_ALERT_COOLDOWN_SEC=600
HUB_HEALTH_FAIL_THRESHOLD=2
+145 -37
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import os import os
import sys import sys
import threading
import time
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
@@ -34,7 +36,8 @@ from lib.bridge_client_lib import (
pull_all_enabled, pull_all_enabled,
) )
from lib.db_lib import init_db from lib.db_lib import init_db
from lib.env_lib import apply_env_updates from lib.env_lib import apply_env_updates, env_get
from lib.health_watch_lib import inspect_overview_rows
from lib.instances_lib import ( from lib.instances_lib import (
create_instance, create_instance,
delete_instance, delete_instance,
@@ -43,6 +46,13 @@ from lib.instances_lib import (
update_instance, update_instance,
) )
from lib.sso_lib import build_embed_open_url from lib.sso_lib import build_embed_open_url
from lib.wechat_lib import (
hub_alert_cooldown_sec,
hub_health_fail_threshold,
hub_wechat_enabled,
hub_wechat_webhook,
send_hub_wechat,
)
APP_HOST = (os.getenv("APP_HOST") or "0.0.0.0").strip() or "0.0.0.0" APP_HOST = (os.getenv("APP_HOST") or "0.0.0.0").strip() or "0.0.0.0"
APP_PORT = int(os.getenv("APP_PORT") or 5100) APP_PORT = int(os.getenv("APP_PORT") or 5100)
@@ -155,48 +165,120 @@ def instances_page():
return render_template("instances.html", instances=list_instances()) return render_template("instances.html", instances=list_instances())
def _wechat_settings_view() -> dict:
enabled_raw = (env_get(ENV_PATH, "HUB_WECHAT_ENABLED") or os.getenv("HUB_WECHAT_ENABLED") or "").strip().lower()
if enabled_raw in ("0", "false", "no", "off"):
enabled = False
elif enabled_raw in ("1", "true", "yes", "on"):
enabled = True
else:
enabled = bool(hub_wechat_webhook())
return {
"enabled": enabled,
"webhook": hub_wechat_webhook() or env_get(ENV_PATH, "HUB_WECHAT_WEBHOOK", ""),
"cooldown_sec": hub_alert_cooldown_sec(),
"fail_threshold": hub_health_fail_threshold(),
}
def _apply_runtime_env(updates: dict[str, str]) -> None:
for key, val in updates.items():
os.environ[key] = str(val)
def _handle_account_settings() -> None:
current_pw = request.form.get("current_password") or ""
new_username = (request.form.get("new_username") or "").strip()
new_password = request.form.get("new_password") or ""
confirm = request.form.get("confirm_password") or ""
if current_pw != hub_password():
flash("当前密码错误")
return
if len(new_username) < 2 or len(new_username) > 64:
flash("用户名长度需为 2–64")
return
if any(ch.isspace() for ch in new_username):
flash("用户名不能包含空格")
return
updates: dict[str, str] = {"HUB_USERNAME": new_username}
final_password = hub_password()
if new_password or confirm:
if len(new_password) < 6:
flash("新密码至少 6 位")
return
if new_password != confirm:
flash("两次输入的新密码不一致")
return
updates["HUB_PASSWORD"] = new_password
final_password = new_password
try:
apply_env_updates(ENV_PATH, updates)
except OSError as exc:
flash(f"写入 .env 失败: {exc}")
return
set_hub_credentials(new_username, final_password)
flash("账号已更新,立即生效")
def _handle_wechat_settings(*, send_test: bool = False) -> None:
enabled = "1" if (request.form.get("wechat_enabled") or "") in ("1", "on", "true") else "0"
webhook = (request.form.get("wechat_webhook") or "").strip()
try:
cooldown = max(60, int(float(request.form.get("alert_cooldown_sec") or 600)))
except (TypeError, ValueError):
flash("告警冷却须为数字(秒)")
return
try:
threshold = max(1, int(float(request.form.get("health_fail_threshold") or 2)))
except (TypeError, ValueError):
flash("连续失败阈值须为正整数")
return
updates = {
"HUB_WECHAT_ENABLED": "true" if enabled == "1" else "false",
"HUB_WECHAT_WEBHOOK": webhook,
"HUB_ALERT_COOLDOWN_SEC": str(cooldown),
"HUB_HEALTH_FAIL_THRESHOLD": str(threshold),
}
try:
apply_env_updates(ENV_PATH, updates)
except OSError as exc:
flash(f"写入 .env 失败: {exc}")
return
_apply_runtime_env(updates)
if send_test:
result = send_hub_wechat("中控企业微信测试消息\n配置已保存并立即生效")
if result.get("ok"):
flash("告警配置已保存,测试消息已发送")
else:
flash(f"告警配置已保存,但测试发送失败: {result.get('msg') or '未知错误'}")
return
flash("企业微信告警配置已保存,立即生效")
@app.route("/settings", methods=["GET", "POST"]) @app.route("/settings", methods=["GET", "POST"])
@login_required @login_required
def settings_page(): def settings_page():
if request.method == "POST": if request.method == "POST":
current_pw = request.form.get("current_password") or "" action = (request.form.get("form_action") or "account").strip()
new_username = (request.form.get("new_username") or "").strip() if action == "wechat":
new_password = request.form.get("new_password") or "" _handle_wechat_settings(send_test=False)
confirm = request.form.get("confirm_password") or "" elif action == "wechat_test":
_handle_wechat_settings(send_test=True)
if current_pw != hub_password(): else:
flash("当前密码错误") _handle_account_settings()
return redirect(url_for("settings_page"))
if len(new_username) < 2 or len(new_username) > 64:
flash("用户名长度需为 2–64")
return redirect(url_for("settings_page"))
if any(ch.isspace() for ch in new_username):
flash("用户名不能包含空格")
return redirect(url_for("settings_page"))
updates: dict[str, str] = {"HUB_USERNAME": new_username}
final_password = hub_password()
if new_password or confirm:
if len(new_password) < 6:
flash("新密码至少 6 位")
return redirect(url_for("settings_page"))
if new_password != confirm:
flash("两次输入的新密码不一致")
return redirect(url_for("settings_page"))
updates["HUB_PASSWORD"] = new_password
final_password = new_password
try:
apply_env_updates(ENV_PATH, updates)
except OSError as exc:
flash(f"写入 .env 失败: {exc}")
return redirect(url_for("settings_page"))
set_hub_credentials(new_username, final_password)
flash("账号已更新,立即生效")
return redirect(url_for("settings_page")) return redirect(url_for("settings_page"))
return render_template("settings.html", current_username=hub_username()) return render_template(
"settings.html",
current_username=hub_username(),
wechat=_wechat_settings_view(),
)
@app.route("/desk") @app.route("/desk")
@@ -233,6 +315,32 @@ def api_overview():
return jsonify({"ok": True, "instances": rows}) return jsonify({"ok": True, "instances": rows})
def _health_pull_interval_sec() -> float:
try:
return max(5.0, float(os.getenv("HUB_PULL_INTERVAL_SEC") or 10))
except (TypeError, ValueError):
return 10.0
def _health_watch_loop() -> None:
"""后台巡检:即使无人打开总览也会告警."""
while True:
try:
rows = pull_all_enabled()
inspect_overview_rows(rows)
except Exception:
pass
time.sleep(_health_pull_interval_sec())
def start_health_watch() -> None:
t = threading.Thread(target=_health_watch_loop, name="hub-health-watch", daemon=True)
t.start()
start_health_watch()
@app.route("/api/instances/<int:iid>/open_url") @app.route("/api/instances/<int:iid>/open_url")
@login_required @login_required
def api_open_url(iid: int): def api_open_url(iid: int):
+72 -55
View File
@@ -1,55 +1,72 @@
"""Hub .env 读写(仅中控账号等少量键).""" """Hub .env 读写(仅中控账号等少量键)."""
from __future__ import annotations from __future__ import annotations
import re import re
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Dict, List
_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$") _KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
def _parse_line(line: str) -> tuple[str | None, str]: def _parse_line(line: str) -> tuple[str | None, str]:
raw = line.rstrip("\n\r") raw = line.rstrip("\n\r")
if not raw.strip() or raw.lstrip().startswith("#"): if not raw.strip() or raw.lstrip().startswith("#"):
return None, raw return None, raw
m = _KEY_RE.match(raw) m = _KEY_RE.match(raw)
if not m: if not m:
return None, raw return None, raw
return m.group(1), raw return m.group(1), raw
def apply_env_updates(path: Path | str, updates: Dict[str, str]) -> List[str]: def apply_env_updates(path: Path | str, updates: Dict[str, str]) -> List[str]:
"""更新或追加 .env 键;返回实际写入的 key 列表.不删其它行.""" """更新或追加 .env 键;返回实际写入的 key 列表.不删其它行."""
env_path = Path(path) env_path = Path(path)
wanted = {str(k).strip(): str(v) for k, v in updates.items() if str(k).strip()} wanted = {str(k).strip(): str(v) for k, v in updates.items() if str(k).strip()}
if not wanted: if not wanted:
return [] return []
lines: list[str] = [] lines: list[str] = []
if env_path.is_file(): if env_path.is_file():
lines = env_path.read_text(encoding="utf-8").splitlines(keepends=True) lines = env_path.read_text(encoding="utf-8").splitlines(keepends=True)
seen: set[str] = set() seen: set[str] = set()
out: list[str] = [] out: list[str] = []
changed: list[str] = [] changed: list[str] = []
for line in lines: for line in lines:
key, _ = _parse_line(line) key, _ = _parse_line(line)
if key and key in wanted: if key and key in wanted:
out.append(f"{key}={wanted[key]}\n") out.append(f"{key}={wanted[key]}\n")
seen.add(key) seen.add(key)
changed.append(key) changed.append(key)
else: else:
if line.endswith("\n") or line.endswith("\r"): if line.endswith("\n") or line.endswith("\r"):
out.append(line if line.endswith("\n") else line + "\n") out.append(line if line.endswith("\n") else line + "\n")
else: else:
out.append(line + "\n") out.append(line + "\n")
for key, val in wanted.items(): for key, val in wanted.items():
if key not in seen: if key not in seen:
out.append(f"{key}={val}\n") out.append(f"{key}={val}\n")
changed.append(key) changed.append(key)
env_path.parent.mkdir(parents=True, exist_ok=True) env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.write_text("".join(out), encoding="utf-8") env_path.write_text("".join(out), encoding="utf-8")
return changed return changed
def env_get(path: Path | str, key: str, default: str = "") -> str:
env_path = Path(path)
if not env_path.is_file():
return default
want = (key or "").strip()
if not want:
return default
for line in env_path.read_text(encoding="utf-8").splitlines():
k, _ = _parse_line(line)
if k == want:
m = _KEY_RE.match(line.rstrip("\n\r"))
if m:
return m.group(2)
return default
return default
+129
View File
@@ -0,0 +1,129 @@
"""客户端在线健康巡检与企业微信告警(冷却去重)."""
from __future__ import annotations
import threading
import time
from typing import Any
from lib.wechat_lib import (
hub_alert_cooldown_sec,
hub_health_fail_threshold,
hub_wechat_enabled,
send_hub_wechat,
)
_lock = threading.Lock()
# instance_id -> {online, fail_count, last_alert_at, last_reason, name}
_state: dict[Any, dict[str, Any]] = {}
def _now() -> float:
return time.time()
def reset_health_state() -> None:
with _lock:
_state.clear()
def inspect_overview_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""根据总览拉取结果更新状态并按需告警.返回本次新发出的告警摘要."""
if not rows:
return []
if not hub_wechat_enabled():
# 仍更新在线状态,便于开关打开后立刻有基线
with _lock:
for row in rows:
iid = row.get("id")
if iid is None:
continue
online = bool(row.get("online"))
st = _state.get(iid) or {}
st.update(
{
"online": online,
"fail_count": 0 if online else int(st.get("fail_count") or 0) + 1,
"name": row.get("name") or st.get("name") or str(iid),
"last_reason": "" if online else (row.get("error") or "离线"),
}
)
_state[iid] = st
return []
threshold = hub_health_fail_threshold()
cooldown = hub_alert_cooldown_sec()
sent: list[dict[str, Any]] = []
now = _now()
with _lock:
for row in rows:
iid = row.get("id")
if iid is None:
continue
name = str(row.get("name") or iid)
online = bool(row.get("online"))
err = str(row.get("error") or "").strip()
st = _state.get(iid) or {
"online": None,
"fail_count": 0,
"last_alert_at": 0.0,
"last_reason": "",
"name": name,
}
prev_online = st.get("online")
st["name"] = name
if online:
# 从离线恢复
if prev_online is False:
if now - float(st.get("last_alert_at") or 0) >= cooldown:
msg = f"客户端恢复在线\n名称:{name}\nID:{iid}"
result = send_hub_wechat(msg)
if result.get("ok"):
st["last_alert_at"] = now
sent.append({"instance_id": iid, "kind": "online", "msg": msg})
st["online"] = True
st["fail_count"] = 0
st["last_reason"] = ""
else:
reason = err or "离线"
st["fail_count"] = int(st.get("fail_count") or 0) + 1
st["online"] = False
st["last_reason"] = reason
# 首次已知状态若就是离线,也累计;达到阈值才告警
if st["fail_count"] >= threshold:
if now - float(st.get("last_alert_at") or 0) >= cooldown:
kind = "fault" if reason and reason not in ("离线", "超时") else "offline"
title = "客户端故障" if kind == "fault" else "客户端离线"
msg = "\n".join(
[
title,
f"名称:{name}",
f"ID:{iid}",
f"原因:{reason}",
f"连续失败:{st['fail_count']} 次",
]
)
result = send_hub_wechat(msg)
if result.get("ok"):
st["last_alert_at"] = now
sent.append({"instance_id": iid, "kind": kind, "msg": msg})
_state[iid] = st
return sent
def health_snapshot() -> list[dict[str, Any]]:
with _lock:
out = []
for iid, st in _state.items():
out.append(
{
"id": iid,
"name": st.get("name"),
"online": st.get("online"),
"fail_count": st.get("fail_count") or 0,
"last_reason": st.get("last_reason") or "",
"last_alert_at": st.get("last_alert_at") or 0,
}
)
return out
+68
View File
@@ -0,0 +1,68 @@
"""中控企业微信 Webhook 推送."""
from __future__ import annotations
import os
from typing import Any
import requests
def hub_wechat_enabled() -> bool:
raw = (os.getenv("HUB_WECHAT_ENABLED") or "").strip().lower()
if raw in ("0", "false", "no", "off"):
return False
if raw in ("1", "true", "yes", "on"):
return True
# 未显式开关时:有有效 webhook 即视为开启
return bool(hub_wechat_webhook())
def hub_wechat_webhook() -> str:
return (os.getenv("HUB_WECHAT_WEBHOOK") or "").strip()
def hub_alert_cooldown_sec() -> int:
try:
return max(60, int(float(os.getenv("HUB_ALERT_COOLDOWN_SEC") or 600)))
except (TypeError, ValueError):
return 600
def hub_health_fail_threshold() -> int:
try:
return max(1, int(float(os.getenv("HUB_HEALTH_FAIL_THRESHOLD") or 2)))
except (TypeError, ValueError):
return 2
def send_hub_wechat(content: str, *, timeout: float = 8.0) -> dict[str, Any]:
"""发送文本消息;返回 {ok, msg}."""
if not hub_wechat_enabled():
return {"ok": False, "msg": "企业微信推送未启用"}
url = hub_wechat_webhook()
if not url or "replace-me" in url:
return {"ok": False, "msg": "未配置有效 Webhook"}
body = str(content or "").strip()
if not body:
return {"ok": False, "msg": "消息为空"}
full = f"【期权中控】\n{body}"
try:
resp = requests.post(
url,
json={"msgtype": "text", "text": {"content": full}},
timeout=timeout,
)
data = {}
try:
data = resp.json()
except Exception:
data = {}
if resp.status_code == 200 and int(data.get("errcode", -1)) == 0:
return {"ok": True, "msg": "已发送"}
return {
"ok": False,
"msg": (data.get("errmsg") if isinstance(data, dict) else None)
or f"HTTP {resp.status_code}",
}
except Exception as e:
return {"ok": False, "msg": str(e) or "发送失败"}
+36
View File
@@ -399,8 +399,44 @@ button, .btn {
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.empty { padding: 40px; color: var(--muted); } .empty { padding: 40px; color: var(--muted); }
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 480px));
gap: 20px;
align-items: start;
}
.settings-card { max-width: 480px; } .settings-card { max-width: 480px; }
.settings-form { max-width: 420px; } .settings-form { max-width: 420px; }
.settings-form .check-row {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.settings-form .check-row input { width: auto; margin: 0; }
.settings-form .field-hint {
display: block;
margin-top: 4px;
font-size: 12px;
color: var(--muted);
font-weight: 400;
}
.settings-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
}
.settings-actions .btn-secondary,
button.btn-secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
}
.settings-actions .btn-secondary:hover,
button.btn-secondary:hover {
border-color: var(--accent, #60a5fa);
}
input.readonly { input.readonly {
opacity: 0.75; opacity: 0.75;
cursor: default; cursor: default;
+56 -28
View File
@@ -1,28 +1,56 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}系统设置 · 期权中控{% endblock %} {% block title %}系统设置 · 期权中控{% endblock %}
{% block body %} {% block body %}
<section class="page-head"><h1>系统设置</h1></section> <section class="page-head"><h1>系统设置</h1></section>
<section class="panel settings-card"> <div class="settings-grid">
<h2>登录账号</h2> <section class="panel settings-card">
<p class="muted">修改后立即生效,并写入服务器 <code>.env</code>(<code>HUB_USERNAME</code> / <code>HUB_PASSWORD</code>).</p> <h2>登录账号</h2>
<form method="post" class="stack settings-form" autocomplete="off"> <p class="muted">修改后立即生效,并写入服务器 <code>.env</code>(<code>HUB_USERNAME</code> / <code>HUB_PASSWORD</code>).</p>
<label>当前用户名 <form method="post" class="stack settings-form" autocomplete="off">
<input type="text" value="{{ current_username }}" readonly class="readonly"> <input type="hidden" name="form_action" value="account">
</label> <label>当前用户名
<label>当前密码 <input type="text" value="{{ current_username }}" readonly class="readonly">
<input type="password" name="current_password" required autocomplete="current-password" placeholder="验证身份"> </label>
</label> <label>当前密码
<label>新用户名 <input type="password" name="current_password" required autocomplete="current-password" placeholder="验证身份">
<input type="text" name="new_username" value="{{ current_username }}" required autocomplete="username" minlength="2" maxlength="64"> </label>
</label> <label>新用户名
<label>新密码 <input type="text" name="new_username" value="{{ current_username }}" required autocomplete="username" minlength="2" maxlength="64">
<input type="password" name="new_password" autocomplete="new-password" minlength="6" placeholder="至少 6 位;不改密码可留空"> </label>
</label> <label>新密码
<label>确认新密码 <input type="password" name="new_password" autocomplete="new-password" minlength="6" placeholder="至少 6 位;不改密码可留空">
<input type="password" name="confirm_password" autocomplete="new-password" minlength="6" placeholder="与新密码一致;不改密码可留空"> </label>
</label> <label>确认新密码
<button type="submit">保存账号</button> <input type="password" name="confirm_password" autocomplete="new-password" minlength="6" placeholder="与新密码一致;不改密码可留空">
</form> </label>
</section> <button type="submit">保存账号</button>
{% endblock %} </form>
</section>
<section class="panel settings-card">
<h2>企业微信告警</h2>
<p class="muted">中控专用:客户端离线/故障与恢复时推送。与各交易机自身通知分开配置。</p>
<form method="post" class="stack settings-form" autocomplete="off">
<label class="check-row">
<input type="checkbox" name="wechat_enabled" value="1"{% if wechat.enabled %} checked{% endif %}>
启用企业微信推送
</label>
<label>Webhook 地址
<input type="url" name="wechat_webhook" value="{{ wechat.webhook }}" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." autocomplete="off">
</label>
<label>告警冷却(秒)
<input type="number" name="alert_cooldown_sec" value="{{ wechat.cooldown_sec }}" min="60" max="86400" step="1">
</label>
<label>连续失败阈值
<input type="number" name="health_fail_threshold" value="{{ wechat.fail_threshold }}" min="1" max="20" step="1">
<span class="field-hint">连续拉失败达到次数后才告警,默认 2</span>
</label>
<div class="settings-actions">
<button type="submit" name="form_action" value="wechat">保存告警配置</button>
<button type="submit" name="form_action" value="wechat_test" class="btn-secondary">发送测试消息</button>
</div>
</form>
</section>
</div>
{% endblock %}
+72 -72
View File
@@ -1,72 +1,72 @@
# crypto_okx_hub 使用说明 # crypto_okx_hub 使用说明
**仓库:** [https://git.bz121.com/dekun/crypto_okx_hub.git](https://git.bz121.com/dekun/crypto_okx_hub.git) **仓库:** [https://git.bz121.com/dekun/crypto_okx_hub.git](https://git.bz121.com/dekun/crypto_okx_hub.git)
OKX **期权多机中控**:在一个入口查看多台期权客户端的持仓总览,并在中控内打开客户端操作。 OKX **期权多机中控**:在一个入口查看多台期权客户端的持仓总览,并在中控内打开客户端操作。
部署见 **[部署文档.md](./部署文档.md)**;架构见 **[设计文档.md](./设计文档.md)**。 部署见 **[部署文档.md](./部署文档.md)**;架构见 **[设计文档.md](./设计文档.md)**。
--- ---
## 1. 关联仓库(必读) ## 1. 关联仓库(必读)
| 仓库 | 角色 | 地址 | | 仓库 | 角色 | 地址 |
|------|------|------| |------|------|------|
| **本仓库 crypto_okx_hub** | 中控:登录、实例登记、多机总览、内嵌/新标签打开 | [https://git.bz121.com/dekun/crypto_okx_hub.git](https://git.bz121.com/dekun/crypto_okx_hub.git) | | **本仓库 crypto_okx_hub** | 中控:登录、实例登记、多机总览、内嵌/新标签打开 | [https://git.bz121.com/dekun/crypto_okx_hub.git](https://git.bz121.com/dekun/crypto_okx_hub.git) |
| **crypto_okx** | 期权客户端(执行机):密钥、开平仓、模拟盘、持仓与提醒 | [https://git.bz121.com/dekun/crypto_okx.git](https://git.bz121.com/dekun/crypto_okx.git) | | **crypto_okx** | 期权客户端(执行机):密钥、开平仓、模拟盘、持仓与提醒 | [https://git.bz121.com/dekun/crypto_okx.git](https://git.bz121.com/dekun/crypto_okx.git) |
- 每个策略 / 账户部署一台 **crypto_okx**,挂 **公网域名**。 - 每个策略 / 账户部署一台 **crypto_okx**,挂 **公网域名**。
- **中控只部署一次**(Ubuntu + PM2),在后台登记各客户端公网 URL 与 Bridge Token。 - **中控只部署一次**(Ubuntu + PM2),在后台登记各客户端公网 URL 与 Bridge Token。
- **交易所 API 密钥只在客户端**;中控不存、不代下单。 - **交易所 API 密钥只在客户端**;中控不存、不代下单。
--- ---
## 2. 第一期能做什么 ## 2. 第一期能做什么
| 功能 | 说明 | | 功能 | 说明 |
|------|------| |------|------|
| 多机持仓总览 | 按客户端卡片展示在线状态、sim/live、浮盈、持仓摘要 | | 多机持仓总览 | 按客户端卡片展示在线状态、sim/live、浮盈、持仓摘要 |
| 内嵌打开 | 在中控工作台 iframe 中打开该客户端 | | 内嵌打开 | 在中控工作台 iframe 中打开该客户端 |
| 新标签打开 | 浏览器新标签直达同一客户端公网地址 | | 新标签打开 | 浏览器新标签直达同一客户端公网地址 |
| 实例管理 | 增删改:名称、公网 URL、Token、标签、是否启用 | | 实例管理 | 增删改:名称、公网 URL、Token、标签、是否启用 |
| 系统设置 | 修改中控登录用户名 / 密码 | | 系统设置 | 修改中控登录账号;配置企业微信离线/故障告警 |
**不做:** 中控里直接下单、改客户端 env、保管 OKX 密钥。 **不做:** 中控里直接下单、改客户端 env、保管 OKX 密钥。
--- ---
## 3. 使用流程 ## 3. 使用流程
1. 部署并登录中控(默认 **admin / admin123**)。 1. 部署并登录中控(默认 **admin / admin123**)。
2. **系统设置** 中建议立刻修改密码。 2. **系统设置** 中建议立刻修改密码;可配置中控企业微信 Webhook,用于客户端离线/故障推送。
3. **实例** 中添加客户端:名称、公网根地址、Bridge Token(与客户端 `HUB_BRIDGE_TOKEN` 一致)。 3. **实例** 中添加客户端:名称、公网根地址、Bridge Token(与客户端 `HUB_BRIDGE_TOKEN` 一致)。
4. 打开 **总览**:确认各机在线。 4. 打开 **总览**:确认各机在线。
5. 点 **打开** → 内嵌工作台;需要独立窗口时点 **新标签打开**。 5. 点 **打开** → 内嵌工作台;需要独立窗口时点 **新标签打开**。
--- ---
## 4. 对客户端的要求 ## 4. 对客户端的要求
1. 公网 HTTPS(或浏览器与中控都能访问的 URL)。 1. 公网 HTTPS(或浏览器与中控都能访问的 URL)。
2. 配置 `HUB_BRIDGE_TOKEN`,开放只读快照 API。 2. 配置 `HUB_BRIDGE_TOKEN`,开放只读快照 API。
3. 允许被中控页面 iframe 嵌入(`/embed` + 短期 SSO)。 3. 允许被中控页面 iframe 嵌入(`/embed` + 短期 SSO)。
--- ---
## 5. 网络说明 ## 5. 网络说明
| 链路 | 谁连谁 | 要求 | | 链路 | 谁连谁 | 要求 |
|------|--------|------| |------|--------|------|
| 总览拉数 | 中控服务器 → 客户端公网 URL | 中控出口能访问客户端 | | 总览拉数 | 中控服务器 → 客户端公网 URL | 中控出口能访问客户端 |
| 内嵌/新标签 | 操作员浏览器 → 客户端公网 URL | 操作员网络能打开客户端 | | 内嵌/新标签 | 操作员浏览器 → 客户端公网 URL | 操作员网络能打开客户端 |
--- ---
## 6. 文档索引 ## 6. 文档索引
| 文档 | 内容 | | 文档 | 内容 |
|------|------| |------|------|
| [设计文档.md](./设计文档.md) | 架构、数据表、API 约定 | | [设计文档.md](./设计文档.md) | 架构、数据表、API 约定 |
| [部署文档.md](./部署文档.md) | Ubuntu 一键部署 / PM2 | | [部署文档.md](./部署文档.md) | Ubuntu 一键部署 / PM2 |
| [deploy/README.md](./deploy/README.md) | 部署脚本说明 | | [deploy/README.md](./deploy/README.md) | 部署脚本说明 |
| [README.md](./README.md) | 仓库入口摘要 | | [README.md](./README.md) | 仓库入口摘要 |
+95 -91
View File
@@ -1,91 +1,95 @@
# crypto_okx_hub 部署文档(Ubuntu) # crypto_okx_hub 部署文档(Ubuntu)
**仓库:** [https://git.bz121.com/dekun/crypto_okx_hub.git](https://git.bz121.com/dekun/crypto_okx_hub.git) **仓库:** [https://git.bz121.com/dekun/crypto_okx_hub.git](https://git.bz121.com/dekun/crypto_okx_hub.git)
配套说明:**[使用说明.md](./使用说明.md)** · **[设计文档.md](./设计文档.md)** · **[deploy/README.md](./deploy/README.md)** 配套说明:**[使用说明.md](./使用说明.md)** · **[设计文档.md](./设计文档.md)** · **[deploy/README.md](./deploy/README.md)**
关联客户端仓库:[crypto_okx](https://git.bz121.com/dekun/crypto_okx.git) 关联客户端仓库:[crypto_okx](https://git.bz121.com/dekun/crypto_okx.git)
> 中控与客户端一样:**仅 Ubuntu + PM2 一键部署**。不提供 Windows 部署。 > 中控与客户端一样:**仅 Ubuntu + PM2 一键部署**。不提供 Windows 部署。
--- ---
## 1. 一键部署(推荐) ## 1. 一键部署(推荐)
新服务器(免 clone): 新服务器(免 clone):
```bash ```bash
curl -fsSL https://git.bz121.com/dekun/crypto_okx_hub/raw/branch/main/deploy/manage.sh | bash curl -fsSL https://git.bz121.com/dekun/crypto_okx_hub/raw/branch/main/deploy/manage.sh | bash
``` ```
已安装: 已安装:
```bash ```bash
bash /opt/crypto_okx_hub/deploy/manage.sh bash /opt/crypto_okx_hub/deploy/manage.sh
``` ```
菜单: 菜单:
1. **一键部署** — 系统依赖、Node/PM2、venv、启动 `crypto_okx_hub` 1. **一键部署** — 系统依赖、Node/PM2、venv、启动 `crypto_okx_hub`
2. **一键卸载** — 备份 `.env` / `hub.db` → 停 PM2 → 移走目录 2. **一键卸载** — 备份 `.env` / `hub.db` → 停 PM2 → 移走目录
3. **更新** — git pull + pm2 restart(或含 pip) 3. **更新** — git pull + pm2 restart(或含 pip)
4. **仅检测** — 不安装 4. **仅检测** — 不安装
安装路径:`/opt/crypto_okx_hub`,默认端口 **5100**,默认登录 **admin / admin123**。 安装路径:`/opt/crypto_okx_hub`,默认端口 **5100**,默认登录 **admin / admin123**。
--- ---
## 2. 部署拓扑 ## 2. 部署拓扑
``` ```
[Ubuntu 云机] [云服务器 A] [云服务器 B] [Ubuntu 云机] [云服务器 A] [云服务器 B]
crypto_okx_hub ──拉快照──▶ crypto_okx crypto_okx crypto_okx_hub ──拉快照──▶ crypto_okx crypto_okx
(中控只一份,PM2) 公网域名 A 公网域名 B (中控只一份,PM2) 公网域名 A 公网域名 B
HUB_BRIDGE_TOKEN=xxx HUB_BRIDGE_TOKEN=yyy HUB_BRIDGE_TOKEN=xxx HUB_BRIDGE_TOKEN=yyy
``` ```
- 客户端:**必须**能被浏览器与中控以 **公网域名(建议 HTTPS)** 访问。 - 客户端:**必须**能被浏览器与中控以 **公网域名(建议 HTTPS)** 访问。
- 中控:Ubuntu + PM2;需能访问各客户端公网 URL。 - 中控:Ubuntu + PM2;需能访问各客户端公网 URL。
--- ---
## 3. 中控环境变量 ## 3. 中控环境变量
| 变量 | 说明 | | 变量 | 说明 |
|------|------| |------|------|
| `APP_HOST` / `APP_PORT` | 默认 `0.0.0.0` / `5100` | | `APP_HOST` / `APP_PORT` | 默认 `0.0.0.0` / `5100` |
| `HUB_USERNAME` / `HUB_PASSWORD` | 默认 `admin` / `admin123`;也可在页面「系统设置」修改 | | `HUB_USERNAME` / `HUB_PASSWORD` | 默认 `admin` / `admin123`;也可在页面「系统设置」修改 |
| `FLASK_SECRET_KEY` | Session;部署脚本可自动生成 | | `FLASK_SECRET_KEY` | Session;部署脚本可自动生成 |
| `DB_PATH` | 注册表 SQLite,默认 `hub.db` | | `DB_PATH` | 注册表 SQLite,默认 `hub.db` |
| `HUB_PULL_INTERVAL_SEC` | 总览拉取间隔,默认 `10` | | `HUB_PULL_INTERVAL_SEC` | 后台健康巡检/总览拉取间隔,默认 `10` |
| `HUB_PULL_TIMEOUT_SEC` | 单机超时,默认 `5` | | `HUB_PULL_TIMEOUT_SEC` | 单机超时,默认 `5` |
| `HUB_WECHAT_ENABLED` | 中控企业微信告警开关,默认 `false`;可在系统设置修改 |
**不要**在中控配置任何 `OKX_API_*`。 | `HUB_WECHAT_WEBHOOK` | 企业微信机器人 Webhook |
| `HUB_ALERT_COOLDOWN_SEC` | 同一机器告警冷却秒数,默认 `600` |
--- | `HUB_HEALTH_FAIL_THRESHOLD` | 连续失败几次后告警,默认 `2` |
## 4. 客户端侧(crypto_okx)联调清单 **不要**在中控配置任何 `OKX_API_*`。
| 变量 | 说明 | ---
|------|------|
| `HUB_BRIDGE_TOKEN` | 与中控登记的 Token 一致 | ## 4. 客户端侧(crypto_okx)联调清单
| (可选)`HUB_EMBED_FRAME_ANCESTORS` | 允许嵌入的中控 Origin |
| 变量 | 说明 |
并确认公网 DNS、快照 API、`/embed` 可打开。 |------|------|
| `HUB_BRIDGE_TOKEN` | 与中控登记的 Token 一致 |
--- | (可选)`HUB_EMBED_FRAME_ANCESTORS` | 允许嵌入的中控 Origin |
## 5. 联调验收 并确认公网 DNS、快照 API、`/embed` 可打开。
1. 中控添加 2 个客户端(不同公网域名 + Token)。 ---
2. 总览两卡均在线。
3. 「打开」内嵌 / 「新标签」正常。 ## 5. 联调验收
4. 系统设置可改用户名密码,改后可用新账号登录。
1. 中控添加 2 个客户端(不同公网域名 + Token)。
--- 2. 总览两卡均在线。
3. 「打开」内嵌 / 「新标签」正常。
## 变更记录 4. 系统设置可改用户名密码,改后可用新账号登录;可配置企业微信告警并发送测试消息。
| 日期 | 说明 | ---
|------|------|
| 2026-10-01 | Ubuntu 一键部署 + PM2;默认 admin/admin123;系统设置改密 | ## 变更记录
| 2026-10-01 | 初稿:双仓拓扑、env 草案 |
| 日期 | 说明 |
|------|------|
| 2026-10-01 | Ubuntu 一键部署 + PM2;默认 admin/admin123;系统设置改密 |
| 2026-10-01 | 初稿:双仓拓扑、env 草案 |