第一期骨架:登录、实例登记、多机总览、内嵌/新标签打开。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-01 10:54:07 +08:00
parent 55e77d6b2b
commit d20acaf852
17 changed files with 881 additions and 2 deletions
View File
+78
View File
@@ -0,0 +1,78 @@
"""拉取各 crypto_okx 客户端只读快照."""
from __future__ import annotations
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
import requests
from lib.instances_lib import list_instances
def _timeout() -> float:
try:
return max(2.0, float(os.getenv("HUB_PULL_TIMEOUT_SEC") or 5))
except (TypeError, ValueError):
return 5.0
def fetch_one_snapshot(inst: dict[str, Any]) -> dict[str, Any]:
base = (inst.get("base_url") or "").rstrip("/")
token = (inst.get("bridge_token") or "").strip()
out: dict[str, Any] = {
"id": inst.get("id"),
"name": inst.get("name"),
"base_url": base,
"embed_path": inst.get("embed_path") or "/embed",
"tags": inst.get("tags") or "",
"online": False,
"error": "",
"snapshot": None,
"pulled_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
if not base or not token:
out["error"] = "缺少 URL 或 Token"
return out
url = f"{base}/api/hub/options/snapshot"
try:
r = requests.get(
url,
headers={"X-Hub-Token": token, "Accept": "application/json"},
timeout=_timeout(),
)
data = {}
try:
data = r.json()
except Exception:
data = {}
if r.status_code != 200 or not isinstance(data, dict) or not data.get("ok"):
out["error"] = (data.get("msg") if isinstance(data, dict) else None) or f"HTTP {r.status_code}"
out["snapshot"] = data if isinstance(data, dict) else None
return out
out["online"] = True
out["snapshot"] = data
return out
except requests.Timeout:
out["error"] = "超时"
return out
except Exception as e:
out["error"] = str(e) or "拉取失败"
return out
def pull_all_enabled() -> list[dict[str, Any]]:
instances = list_instances(enabled_only=True)
if not instances:
return []
results: list[dict[str, Any]] = []
with ThreadPoolExecutor(max_workers=min(8, len(instances))) as pool:
futs = {pool.submit(fetch_one_snapshot, inst): inst["id"] for inst in instances}
by_id: dict[Any, dict[str, Any]] = {}
for fut in as_completed(futs):
row = fut.result()
by_id[row.get("id")] = row
for inst in instances:
results.append(by_id.get(inst["id"]) or fetch_one_snapshot(inst))
return results
+46
View File
@@ -0,0 +1,46 @@
"""SQLite 注册表."""
from __future__ import annotations
import os
import sqlite3
from typing import Any
def db_path() -> str:
return (os.getenv("DB_PATH") or "hub.db").strip() or "hub.db"
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(db_path(), timeout=30)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
conn = get_db()
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS hub_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
bridge_token TEXT NOT NULL,
embed_path TEXT NOT NULL DEFAULT '/embed',
tags TEXT DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.commit()
finally:
conn.close()
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
if row is None:
return None
return {k: row[k] for k in row.keys()}
+156
View File
@@ -0,0 +1,156 @@
"""客户端实例 CRUD."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from urllib.parse import urlparse
from lib.db_lib import get_db, row_to_dict
def _now() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def normalize_base_url(url: str) -> str:
u = (url or "").strip().rstrip("/")
if not u:
return ""
if "://" not in u:
u = "https://" + u
parsed = urlparse(u)
if not parsed.netloc:
return ""
return f"{parsed.scheme}://{parsed.netloc}"
def list_instances(*, enabled_only: bool = False) -> list[dict[str, Any]]:
conn = get_db()
try:
if enabled_only:
rows = conn.execute(
"SELECT * FROM hub_instances WHERE enabled=1 ORDER BY sort_order ASC, id ASC"
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM hub_instances ORDER BY sort_order ASC, id ASC"
).fetchall()
return [row_to_dict(r) for r in rows if r]
finally:
conn.close()
def get_instance(iid: int) -> Optional[dict[str, Any]]:
conn = get_db()
try:
row = conn.execute("SELECT * FROM hub_instances WHERE id=?", (int(iid),)).fetchone()
return row_to_dict(row)
finally:
conn.close()
def create_instance(
*,
name: str,
base_url: str,
bridge_token: str,
embed_path: str = "/embed",
tags: str = "",
enabled: bool = True,
sort_order: int = 0,
) -> tuple[Optional[dict[str, Any]], str]:
name = (name or "").strip()
token = (bridge_token or "").strip()
base = normalize_base_url(base_url)
path = (embed_path or "/embed").strip() or "/embed"
if not path.startswith("/"):
path = "/" + path
if not name:
return None, "名称不能为空"
if not base:
return None, "公网 URL 无效"
if not token:
return None, "Bridge Token 不能为空"
now = _now()
conn = get_db()
try:
cur = conn.execute(
"""
INSERT INTO hub_instances(
name, base_url, bridge_token, embed_path, tags, enabled, sort_order, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?)
""",
(
name,
base,
token,
path,
(tags or "").strip(),
1 if enabled else 0,
int(sort_order or 0),
now,
now,
),
)
conn.commit()
return get_instance(int(cur.lastrowid)), ""
finally:
conn.close()
def update_instance(iid: int, **fields: Any) -> tuple[Optional[dict[str, Any]], str]:
existing = get_instance(iid)
if not existing:
return None, "实例不存在"
name = fields.get("name", existing["name"])
base_url = fields.get("base_url", existing["base_url"])
token = fields.get("bridge_token", existing["bridge_token"])
embed_path = fields.get("embed_path", existing["embed_path"])
tags = fields.get("tags", existing.get("tags") or "")
enabled = fields.get("enabled", existing["enabled"])
sort_order = fields.get("sort_order", existing["sort_order"])
name = (name or "").strip()
token = (token or "").strip()
base = normalize_base_url(str(base_url))
path = (str(embed_path) or "/embed").strip() or "/embed"
if not path.startswith("/"):
path = "/" + path
if not name or not base or not token:
return None, "名称 / URL / Token 均必填"
conn = get_db()
try:
conn.execute(
"""
UPDATE hub_instances SET
name=?, base_url=?, bridge_token=?, embed_path=?, tags=?,
enabled=?, sort_order=?, updated_at=?
WHERE id=?
""",
(
name,
base,
token,
path,
(tags or "").strip(),
1 if bool(int(enabled)) else 0,
int(sort_order or 0),
_now(),
int(iid),
),
)
conn.commit()
return get_instance(iid), ""
finally:
conn.close()
def delete_instance(iid: int) -> bool:
conn = get_db()
try:
cur = conn.execute("DELETE FROM hub_instances WHERE id=?", (int(iid),))
conn.commit()
return cur.rowcount > 0
finally:
conn.close()
+34
View File
@@ -0,0 +1,34 @@
"""SSO 打开链(与 crypto_okx lib.hub.hub_bridge_auth 算法一致)."""
from __future__ import annotations
import hashlib
import hmac
import time
from urllib.parse import urlencode
def make_sso_token(bridge_token: str, *, ttl_sec: int = 120) -> str:
exp = int(time.time()) + max(30, int(ttl_sec))
payload = str(exp)
sig = hmac.new(
bridge_token.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return f"{payload}.{sig}"
def build_embed_open_url(
base_url: str,
*,
bridge_token: str,
embed_path: str = "/embed",
ttl_sec: int = 120,
) -> str:
root = (base_url or "").rstrip("/")
path = embed_path or "/embed"
if not path.startswith("/"):
path = "/" + path
token = make_sso_token(bridge_token, ttl_sec=ttl_sec)
q = urlencode({"token": token, "next": path})
return f"{root}/hub_sso?{q}"