第一期骨架:登录、实例登记、多机总览、内嵌/新标签打开。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -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()}
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user