diff --git a/.env.example b/.env.example index af80059..b6d12d9 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,15 @@ -# crypto_okx_hub -APP_HOST=0.0.0.0 -APP_PORT=5100 -HUB_USERNAME=admin -HUB_PASSWORD=admin123 -FLASK_SECRET_KEY=change-me-to-long-random -DB_PATH=hub.db -HUB_PULL_INTERVAL_SEC=10 -HUB_PULL_TIMEOUT_SEC=5 +# crypto_okx_hub +APP_HOST=0.0.0.0 +APP_PORT=5100 +HUB_USERNAME=admin +HUB_PASSWORD=admin123 +FLASK_SECRET_KEY=change-me-to-long-random +DB_PATH=hub.db +HUB_PULL_INTERVAL_SEC=10 +HUB_PULL_TIMEOUT_SEC=5 + +# 中控企业微信(离线/故障告警,与客户端通知分开) +HUB_WECHAT_ENABLED=false +HUB_WECHAT_WEBHOOK= +HUB_ALERT_COOLDOWN_SEC=600 +HUB_HEALTH_FAIL_THRESHOLD=2 diff --git a/app.py b/app.py index b9e410e..f21cc48 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,8 @@ from __future__ import annotations import os import sys +import threading +import time from functools import wraps from pathlib import Path @@ -34,7 +36,8 @@ from lib.bridge_client_lib import ( pull_all_enabled, ) 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 ( create_instance, delete_instance, @@ -43,6 +46,13 @@ from lib.instances_lib import ( update_instance, ) 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_PORT = int(os.getenv("APP_PORT") or 5100) @@ -155,48 +165,120 @@ def instances_page(): 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"]) @login_required def settings_page(): if request.method == "POST": - 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 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("账号已更新,立即生效") + action = (request.form.get("form_action") or "account").strip() + if action == "wechat": + _handle_wechat_settings(send_test=False) + elif action == "wechat_test": + _handle_wechat_settings(send_test=True) + else: + _handle_account_settings() 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") @@ -233,6 +315,32 @@ def api_overview(): 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//open_url") @login_required def api_open_url(iid: int): diff --git a/lib/env_lib.py b/lib/env_lib.py index 2992a7f..e2c9a93 100644 --- a/lib/env_lib.py +++ b/lib/env_lib.py @@ -1,55 +1,72 @@ -"""Hub .env 读写(仅中控账号等少量键).""" -from __future__ import annotations - -import re -from pathlib import Path -from typing import Dict, List - -_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$") - - -def _parse_line(line: str) -> tuple[str | None, str]: - raw = line.rstrip("\n\r") - if not raw.strip() or raw.lstrip().startswith("#"): - return None, raw - m = _KEY_RE.match(raw) - if not m: - return None, raw - return m.group(1), raw - - -def apply_env_updates(path: Path | str, updates: Dict[str, str]) -> List[str]: - """更新或追加 .env 键;返回实际写入的 key 列表.不删其它行.""" - env_path = Path(path) - wanted = {str(k).strip(): str(v) for k, v in updates.items() if str(k).strip()} - if not wanted: - return [] - - lines: list[str] = [] - if env_path.is_file(): - lines = env_path.read_text(encoding="utf-8").splitlines(keepends=True) - - seen: set[str] = set() - out: list[str] = [] - changed: list[str] = [] - - for line in lines: - key, _ = _parse_line(line) - if key and key in wanted: - out.append(f"{key}={wanted[key]}\n") - seen.add(key) - changed.append(key) - else: - if line.endswith("\n") or line.endswith("\r"): - out.append(line if line.endswith("\n") else line + "\n") - else: - out.append(line + "\n") - - for key, val in wanted.items(): - if key not in seen: - out.append(f"{key}={val}\n") - changed.append(key) - - env_path.parent.mkdir(parents=True, exist_ok=True) - env_path.write_text("".join(out), encoding="utf-8") - return changed +"""Hub .env 读写(仅中控账号等少量键).""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Dict, List + +_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$") + + +def _parse_line(line: str) -> tuple[str | None, str]: + raw = line.rstrip("\n\r") + if not raw.strip() or raw.lstrip().startswith("#"): + return None, raw + m = _KEY_RE.match(raw) + if not m: + return None, raw + return m.group(1), raw + + +def apply_env_updates(path: Path | str, updates: Dict[str, str]) -> List[str]: + """更新或追加 .env 键;返回实际写入的 key 列表.不删其它行.""" + env_path = Path(path) + wanted = {str(k).strip(): str(v) for k, v in updates.items() if str(k).strip()} + if not wanted: + return [] + + lines: list[str] = [] + if env_path.is_file(): + lines = env_path.read_text(encoding="utf-8").splitlines(keepends=True) + + seen: set[str] = set() + out: list[str] = [] + changed: list[str] = [] + + for line in lines: + key, _ = _parse_line(line) + if key and key in wanted: + out.append(f"{key}={wanted[key]}\n") + seen.add(key) + changed.append(key) + else: + if line.endswith("\n") or line.endswith("\r"): + out.append(line if line.endswith("\n") else line + "\n") + else: + out.append(line + "\n") + + for key, val in wanted.items(): + if key not in seen: + out.append(f"{key}={val}\n") + changed.append(key) + + env_path.parent.mkdir(parents=True, exist_ok=True) + env_path.write_text("".join(out), encoding="utf-8") + 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 diff --git a/lib/health_watch_lib.py b/lib/health_watch_lib.py new file mode 100644 index 0000000..480edd0 --- /dev/null +++ b/lib/health_watch_lib.py @@ -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 diff --git a/lib/wechat_lib.py b/lib/wechat_lib.py new file mode 100644 index 0000000..23cf7d3 --- /dev/null +++ b/lib/wechat_lib.py @@ -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 "发送失败"} diff --git a/static/hub.css b/static/hub.css index 3b46f73..a3c23c4 100644 --- a/static/hub.css +++ b/static/hub.css @@ -399,8 +399,44 @@ button, .btn { box-shadow: var(--shadow); } .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-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 { opacity: 0.75; cursor: default; diff --git a/templates/settings.html b/templates/settings.html index 77c08e9..5d63599 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -1,28 +1,56 @@ -{% extends "base.html" %} -{% block title %}系统设置 · 期权中控{% endblock %} -{% block body %} -

系统设置

- -
-

登录账号

-

修改后立即生效,并写入服务器 .env(HUB_USERNAME / HUB_PASSWORD).

-
- - - - - - -
-
-{% endblock %} +{% extends "base.html" %} +{% block title %}系统设置 · 期权中控{% endblock %} +{% block body %} +

系统设置

+ +
+
+

登录账号

+

修改后立即生效,并写入服务器 .env(HUB_USERNAME / HUB_PASSWORD).

+
+ + + + + + + +
+
+ +
+

企业微信告警

+

中控专用:客户端离线/故障与恢复时推送。与各交易机自身通知分开配置。

+
+ + + + +
+ + +
+
+
+
+{% endblock %} diff --git a/使用说明.md b/使用说明.md index 6d2b1db..a002835 100644 --- a/使用说明.md +++ b/使用说明.md @@ -1,72 +1,72 @@ -# crypto_okx_hub 使用说明 - -**仓库:** [https://git.bz121.com/dekun/crypto_okx_hub.git](https://git.bz121.com/dekun/crypto_okx_hub.git) - -OKX **期权多机中控**:在一个入口查看多台期权客户端的持仓总览,并在中控内打开客户端操作。 - -部署见 **[部署文档.md](./部署文档.md)**;架构见 **[设计文档.md](./设计文档.md)**。 - ---- - -## 1. 关联仓库(必读) - -| 仓库 | 角色 | 地址 | -|------|------|------| -| **本仓库 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**,挂 **公网域名**。 -- **中控只部署一次**(Ubuntu + PM2),在后台登记各客户端公网 URL 与 Bridge Token。 -- **交易所 API 密钥只在客户端**;中控不存、不代下单。 - ---- - -## 2. 第一期能做什么 - -| 功能 | 说明 | -|------|------| -| 多机持仓总览 | 按客户端卡片展示在线状态、sim/live、浮盈、持仓摘要 | -| 内嵌打开 | 在中控工作台 iframe 中打开该客户端 | -| 新标签打开 | 浏览器新标签直达同一客户端公网地址 | -| 实例管理 | 增删改:名称、公网 URL、Token、标签、是否启用 | -| 系统设置 | 修改中控登录用户名 / 密码 | - -**不做:** 中控里直接下单、改客户端 env、保管 OKX 密钥。 - ---- - -## 3. 使用流程 - -1. 部署并登录中控(默认 **admin / admin123**)。 -2. **系统设置** 中建议立刻修改密码。 -3. **实例** 中添加客户端:名称、公网根地址、Bridge Token(与客户端 `HUB_BRIDGE_TOKEN` 一致)。 -4. 打开 **总览**:确认各机在线。 -5. 点 **打开** → 内嵌工作台;需要独立窗口时点 **新标签打开**。 - ---- - -## 4. 对客户端的要求 - -1. 公网 HTTPS(或浏览器与中控都能访问的 URL)。 -2. 配置 `HUB_BRIDGE_TOKEN`,开放只读快照 API。 -3. 允许被中控页面 iframe 嵌入(`/embed` + 短期 SSO)。 - ---- - -## 5. 网络说明 - -| 链路 | 谁连谁 | 要求 | -|------|--------|------| -| 总览拉数 | 中控服务器 → 客户端公网 URL | 中控出口能访问客户端 | -| 内嵌/新标签 | 操作员浏览器 → 客户端公网 URL | 操作员网络能打开客户端 | - ---- - -## 6. 文档索引 - -| 文档 | 内容 | -|------|------| -| [设计文档.md](./设计文档.md) | 架构、数据表、API 约定 | -| [部署文档.md](./部署文档.md) | Ubuntu 一键部署 / PM2 | -| [deploy/README.md](./deploy/README.md) | 部署脚本说明 | -| [README.md](./README.md) | 仓库入口摘要 | +# crypto_okx_hub 使用说明 + +**仓库:** [https://git.bz121.com/dekun/crypto_okx_hub.git](https://git.bz121.com/dekun/crypto_okx_hub.git) + +OKX **期权多机中控**:在一个入口查看多台期权客户端的持仓总览,并在中控内打开客户端操作。 + +部署见 **[部署文档.md](./部署文档.md)**;架构见 **[设计文档.md](./设计文档.md)**。 + +--- + +## 1. 关联仓库(必读) + +| 仓库 | 角色 | 地址 | +|------|------|------| +| **本仓库 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**,挂 **公网域名**。 +- **中控只部署一次**(Ubuntu + PM2),在后台登记各客户端公网 URL 与 Bridge Token。 +- **交易所 API 密钥只在客户端**;中控不存、不代下单。 + +--- + +## 2. 第一期能做什么 + +| 功能 | 说明 | +|------|------| +| 多机持仓总览 | 按客户端卡片展示在线状态、sim/live、浮盈、持仓摘要 | +| 内嵌打开 | 在中控工作台 iframe 中打开该客户端 | +| 新标签打开 | 浏览器新标签直达同一客户端公网地址 | +| 实例管理 | 增删改:名称、公网 URL、Token、标签、是否启用 | +| 系统设置 | 修改中控登录账号;配置企业微信离线/故障告警 | + +**不做:** 中控里直接下单、改客户端 env、保管 OKX 密钥。 + +--- + +## 3. 使用流程 + +1. 部署并登录中控(默认 **admin / admin123**)。 +2. **系统设置** 中建议立刻修改密码;可配置中控企业微信 Webhook,用于客户端离线/故障推送。 +3. **实例** 中添加客户端:名称、公网根地址、Bridge Token(与客户端 `HUB_BRIDGE_TOKEN` 一致)。 +4. 打开 **总览**:确认各机在线。 +5. 点 **打开** → 内嵌工作台;需要独立窗口时点 **新标签打开**。 + +--- + +## 4. 对客户端的要求 + +1. 公网 HTTPS(或浏览器与中控都能访问的 URL)。 +2. 配置 `HUB_BRIDGE_TOKEN`,开放只读快照 API。 +3. 允许被中控页面 iframe 嵌入(`/embed` + 短期 SSO)。 + +--- + +## 5. 网络说明 + +| 链路 | 谁连谁 | 要求 | +|------|--------|------| +| 总览拉数 | 中控服务器 → 客户端公网 URL | 中控出口能访问客户端 | +| 内嵌/新标签 | 操作员浏览器 → 客户端公网 URL | 操作员网络能打开客户端 | + +--- + +## 6. 文档索引 + +| 文档 | 内容 | +|------|------| +| [设计文档.md](./设计文档.md) | 架构、数据表、API 约定 | +| [部署文档.md](./部署文档.md) | Ubuntu 一键部署 / PM2 | +| [deploy/README.md](./deploy/README.md) | 部署脚本说明 | +| [README.md](./README.md) | 仓库入口摘要 | diff --git a/部署文档.md b/部署文档.md index 93edc9c..4df5ea6 100644 --- a/部署文档.md +++ b/部署文档.md @@ -1,91 +1,95 @@ -# crypto_okx_hub 部署文档(Ubuntu) - -**仓库:** [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)** -关联客户端仓库:[crypto_okx](https://git.bz121.com/dekun/crypto_okx.git) - -> 中控与客户端一样:**仅 Ubuntu + PM2 一键部署**。不提供 Windows 部署。 - ---- - -## 1. 一键部署(推荐) - -新服务器(免 clone): - -```bash -curl -fsSL https://git.bz121.com/dekun/crypto_okx_hub/raw/branch/main/deploy/manage.sh | bash -``` - -已安装: - -```bash -bash /opt/crypto_okx_hub/deploy/manage.sh -``` - -菜单: - -1. **一键部署** — 系统依赖、Node/PM2、venv、启动 `crypto_okx_hub` -2. **一键卸载** — 备份 `.env` / `hub.db` → 停 PM2 → 移走目录 -3. **更新** — git pull + pm2 restart(或含 pip) -4. **仅检测** — 不安装 - -安装路径:`/opt/crypto_okx_hub`,默认端口 **5100**,默认登录 **admin / admin123**。 - ---- - -## 2. 部署拓扑 - -``` -[Ubuntu 云机] [云服务器 A] [云服务器 B] - crypto_okx_hub ──拉快照──▶ crypto_okx crypto_okx - (中控只一份,PM2) 公网域名 A 公网域名 B - HUB_BRIDGE_TOKEN=xxx HUB_BRIDGE_TOKEN=yyy -``` - -- 客户端:**必须**能被浏览器与中控以 **公网域名(建议 HTTPS)** 访问。 -- 中控:Ubuntu + PM2;需能访问各客户端公网 URL。 - ---- - -## 3. 中控环境变量 - -| 变量 | 说明 | -|------|------| -| `APP_HOST` / `APP_PORT` | 默认 `0.0.0.0` / `5100` | -| `HUB_USERNAME` / `HUB_PASSWORD` | 默认 `admin` / `admin123`;也可在页面「系统设置」修改 | -| `FLASK_SECRET_KEY` | Session;部署脚本可自动生成 | -| `DB_PATH` | 注册表 SQLite,默认 `hub.db` | -| `HUB_PULL_INTERVAL_SEC` | 总览拉取间隔,默认 `10` | -| `HUB_PULL_TIMEOUT_SEC` | 单机超时,默认 `5` | - -**不要**在中控配置任何 `OKX_API_*`。 - ---- - -## 4. 客户端侧(crypto_okx)联调清单 - -| 变量 | 说明 | -|------|------| -| `HUB_BRIDGE_TOKEN` | 与中控登记的 Token 一致 | -| (可选)`HUB_EMBED_FRAME_ANCESTORS` | 允许嵌入的中控 Origin | - -并确认公网 DNS、快照 API、`/embed` 可打开。 - ---- - -## 5. 联调验收 - -1. 中控添加 2 个客户端(不同公网域名 + Token)。 -2. 总览两卡均在线。 -3. 「打开」内嵌 / 「新标签」正常。 -4. 系统设置可改用户名密码,改后可用新账号登录。 - ---- - -## 变更记录 - -| 日期 | 说明 | -|------|------| -| 2026-10-01 | Ubuntu 一键部署 + PM2;默认 admin/admin123;系统设置改密 | -| 2026-10-01 | 初稿:双仓拓扑、env 草案 | +# crypto_okx_hub 部署文档(Ubuntu) + +**仓库:** [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)** +关联客户端仓库:[crypto_okx](https://git.bz121.com/dekun/crypto_okx.git) + +> 中控与客户端一样:**仅 Ubuntu + PM2 一键部署**。不提供 Windows 部署。 + +--- + +## 1. 一键部署(推荐) + +新服务器(免 clone): + +```bash +curl -fsSL https://git.bz121.com/dekun/crypto_okx_hub/raw/branch/main/deploy/manage.sh | bash +``` + +已安装: + +```bash +bash /opt/crypto_okx_hub/deploy/manage.sh +``` + +菜单: + +1. **一键部署** — 系统依赖、Node/PM2、venv、启动 `crypto_okx_hub` +2. **一键卸载** — 备份 `.env` / `hub.db` → 停 PM2 → 移走目录 +3. **更新** — git pull + pm2 restart(或含 pip) +4. **仅检测** — 不安装 + +安装路径:`/opt/crypto_okx_hub`,默认端口 **5100**,默认登录 **admin / admin123**。 + +--- + +## 2. 部署拓扑 + +``` +[Ubuntu 云机] [云服务器 A] [云服务器 B] + crypto_okx_hub ──拉快照──▶ crypto_okx crypto_okx + (中控只一份,PM2) 公网域名 A 公网域名 B + HUB_BRIDGE_TOKEN=xxx HUB_BRIDGE_TOKEN=yyy +``` + +- 客户端:**必须**能被浏览器与中控以 **公网域名(建议 HTTPS)** 访问。 +- 中控:Ubuntu + PM2;需能访问各客户端公网 URL。 + +--- + +## 3. 中控环境变量 + +| 变量 | 说明 | +|------|------| +| `APP_HOST` / `APP_PORT` | 默认 `0.0.0.0` / `5100` | +| `HUB_USERNAME` / `HUB_PASSWORD` | 默认 `admin` / `admin123`;也可在页面「系统设置」修改 | +| `FLASK_SECRET_KEY` | Session;部署脚本可自动生成 | +| `DB_PATH` | 注册表 SQLite,默认 `hub.db` | +| `HUB_PULL_INTERVAL_SEC` | 后台健康巡检/总览拉取间隔,默认 `10` | +| `HUB_PULL_TIMEOUT_SEC` | 单机超时,默认 `5` | +| `HUB_WECHAT_ENABLED` | 中控企业微信告警开关,默认 `false`;可在系统设置修改 | +| `HUB_WECHAT_WEBHOOK` | 企业微信机器人 Webhook | +| `HUB_ALERT_COOLDOWN_SEC` | 同一机器告警冷却秒数,默认 `600` | +| `HUB_HEALTH_FAIL_THRESHOLD` | 连续失败几次后告警,默认 `2` | + +**不要**在中控配置任何 `OKX_API_*`。 + +--- + +## 4. 客户端侧(crypto_okx)联调清单 + +| 变量 | 说明 | +|------|------| +| `HUB_BRIDGE_TOKEN` | 与中控登记的 Token 一致 | +| (可选)`HUB_EMBED_FRAME_ANCESTORS` | 允许嵌入的中控 Origin | + +并确认公网 DNS、快照 API、`/embed` 可打开。 + +--- + +## 5. 联调验收 + +1. 中控添加 2 个客户端(不同公网域名 + Token)。 +2. 总览两卡均在线。 +3. 「打开」内嵌 / 「新标签」正常。 +4. 系统设置可改用户名密码,改后可用新账号登录;可配置企业微信告警并发送测试消息。 + +--- + +## 变更记录 + +| 日期 | 说明 | +|------|------| +| 2026-10-01 | Ubuntu 一键部署 + PM2;默认 admin/admin123;系统设置改密 | +| 2026-10-01 | 初稿:双仓拓扑、env 草案 |