314 lines
10 KiB
Python
314 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""桌面端与配置后台之间的认证和自动同步客户端。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
CONNECTION_FILE = SCRIPT_DIR / "backend_connection.json"
|
|
RUNTIME_FILE = SCRIPT_DIR / "backend_runtime.json"
|
|
DEFAULT_SERVER_URL = "http://127.0.0.1:8765"
|
|
_LOCK = threading.RLock()
|
|
|
|
|
|
class BackendError(RuntimeError):
|
|
"""后台通信或响应错误。"""
|
|
|
|
|
|
class AuthenticationError(BackendError):
|
|
"""登录状态无效。"""
|
|
|
|
|
|
def discover_local_runtime() -> dict[str, Any]:
|
|
"""读取并校验同项目后台发布的运行信息。"""
|
|
try:
|
|
info = json.loads(RUNTIME_FILE.read_text(encoding="utf-8"))
|
|
url = normalize_server_url(info.get("server_url"))
|
|
port = int(info.get("port", 0))
|
|
parsed = urllib.parse.urlparse(url)
|
|
if not 1 <= port <= 65535 or parsed.port != port:
|
|
raise ValueError
|
|
return {
|
|
"server_url": url,
|
|
"port": port,
|
|
"pid": int(info.get("pid", 0)),
|
|
"local_sync_token": str(info.get("local_sync_token") or ""),
|
|
"started_at": str(info.get("started_at") or ""),
|
|
}
|
|
except (OSError, ValueError, TypeError, BackendError):
|
|
return {}
|
|
|
|
|
|
def discover_local_server_url() -> str:
|
|
"""读取后台发布的实际端口;文件无效时回退到默认地址。"""
|
|
return str(discover_local_runtime().get("server_url") or DEFAULT_SERVER_URL)
|
|
|
|
|
|
def discover_local_sync_token() -> str:
|
|
return str(discover_local_runtime().get("local_sync_token") or "")
|
|
|
|
|
|
def _is_local_server_url(value: Any) -> bool:
|
|
try:
|
|
hostname = urllib.parse.urlparse(normalize_server_url(value)).hostname
|
|
except BackendError:
|
|
return False
|
|
return hostname in ("127.0.0.1", "localhost", "::1")
|
|
|
|
|
|
def default_settings() -> dict[str, Any]:
|
|
return {
|
|
"server_url": discover_local_server_url(),
|
|
"username": "",
|
|
"access_token": "",
|
|
"auto_sync": True,
|
|
"sync_interval_seconds": 300,
|
|
"last_version": 0,
|
|
"last_sync_at": "",
|
|
"last_error": "",
|
|
}
|
|
|
|
|
|
def load_settings() -> dict[str, Any]:
|
|
settings = default_settings()
|
|
with _LOCK:
|
|
try:
|
|
saved = json.loads(CONNECTION_FILE.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError, TypeError):
|
|
return settings
|
|
if isinstance(saved, dict):
|
|
settings.update({key: saved[key] for key in settings if key in saved})
|
|
settings["server_url"] = normalize_server_url(settings.get("server_url"))
|
|
discovered_url = discover_local_server_url()
|
|
if discovered_url != DEFAULT_SERVER_URL and _is_local_server_url(settings["server_url"]):
|
|
settings["server_url"] = discovered_url
|
|
settings["auto_sync"] = bool(settings.get("auto_sync", True))
|
|
try:
|
|
settings["sync_interval_seconds"] = max(
|
|
60, int(settings.get("sync_interval_seconds", 300))
|
|
)
|
|
settings["last_version"] = max(0, int(settings.get("last_version", 0)))
|
|
except (TypeError, ValueError):
|
|
settings["sync_interval_seconds"] = 300
|
|
settings["last_version"] = 0
|
|
return settings
|
|
|
|
|
|
def save_settings(settings: dict[str, Any]) -> None:
|
|
data = default_settings()
|
|
data.update({key: settings[key] for key in data if key in settings})
|
|
data["server_url"] = normalize_server_url(data["server_url"])
|
|
temporary = CONNECTION_FILE.with_suffix(".json.tmp")
|
|
with _LOCK:
|
|
temporary.write_text(
|
|
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
os.replace(temporary, CONNECTION_FILE)
|
|
|
|
|
|
def normalize_server_url(value: Any) -> str:
|
|
url = str(value or DEFAULT_SERVER_URL).strip().rstrip("/")
|
|
if not url.startswith(("http://", "https://")):
|
|
url = "http://" + url
|
|
parsed = urllib.parse.urlparse(url)
|
|
if not parsed.hostname:
|
|
raise BackendError("后台地址格式不正确")
|
|
return url
|
|
|
|
|
|
def is_configured(settings: dict[str, Any] | None = None) -> bool:
|
|
current = settings or load_settings()
|
|
return bool(
|
|
current.get("server_url")
|
|
and (current.get("access_token") or discover_local_sync_token())
|
|
)
|
|
|
|
|
|
def connection_summary() -> dict[str, Any]:
|
|
settings = load_settings()
|
|
return {
|
|
"configured": is_configured(settings),
|
|
"authenticated": bool(settings.get("access_token")),
|
|
"local_discovered": bool(discover_local_sync_token()),
|
|
"server_url": settings["server_url"],
|
|
"username": settings.get("username", ""),
|
|
"auto_sync": settings["auto_sync"],
|
|
"sync_interval_seconds": settings["sync_interval_seconds"],
|
|
"last_version": settings["last_version"],
|
|
"last_sync_at": settings.get("last_sync_at", ""),
|
|
"last_error": settings.get("last_error", ""),
|
|
}
|
|
|
|
|
|
def _request(
|
|
method: str,
|
|
server_url: str,
|
|
path: str,
|
|
*,
|
|
token: str = "",
|
|
payload: dict[str, Any] | None = None,
|
|
local_sync_token: str = "",
|
|
timeout: float = 10.0,
|
|
) -> tuple[int, dict[str, Any]]:
|
|
url = normalize_server_url(server_url) + path
|
|
body = None
|
|
headers = {"Accept": "application/json", "User-Agent": "WeCom-RPA/1.0"}
|
|
if payload is not None:
|
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
headers["Content-Type"] = "application/json; charset=utf-8"
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
if local_sync_token:
|
|
headers["X-Desktop-Sync-Token"] = local_sync_token
|
|
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
raw = response.read().decode("utf-8")
|
|
data = json.loads(raw) if raw else {}
|
|
return int(response.status), data
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
data = json.loads(exc.read().decode("utf-8"))
|
|
except Exception:
|
|
data = {"error": f"后台返回 HTTP {exc.code}"}
|
|
message = str(data.get("error") or data.get("message") or f"HTTP {exc.code}")
|
|
if exc.code in (401, 403):
|
|
raise AuthenticationError(message) from exc
|
|
raise BackendError(message) from exc
|
|
except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
|
|
reason = getattr(exc, "reason", exc)
|
|
raise BackendError(f"无法连接后台:{reason}") from exc
|
|
except (ValueError, TypeError) as exc:
|
|
raise BackendError("后台响应不是有效 JSON") from exc
|
|
|
|
|
|
def login(
|
|
server_url: str,
|
|
username: str,
|
|
password: str,
|
|
*,
|
|
auto_sync: bool = True,
|
|
) -> dict[str, Any]:
|
|
server_url = normalize_server_url(server_url)
|
|
username = str(username or "").strip()
|
|
if not username or not password:
|
|
raise AuthenticationError("请输入用户名和密码")
|
|
_, response = _request(
|
|
"POST",
|
|
server_url,
|
|
"/api/v1/auth/login",
|
|
payload={
|
|
"username": username,
|
|
"password": password,
|
|
"device_name": socket.gethostname(),
|
|
},
|
|
)
|
|
token = str(response.get("access_token") or "")
|
|
if not token:
|
|
raise AuthenticationError("后台没有返回登录令牌")
|
|
settings = load_settings()
|
|
settings.update(
|
|
{
|
|
"server_url": server_url,
|
|
"username": response.get("user", {}).get("username", username),
|
|
"access_token": token,
|
|
"auto_sync": bool(auto_sync),
|
|
"last_error": "",
|
|
}
|
|
)
|
|
save_settings(settings)
|
|
return response
|
|
|
|
|
|
def logout(*, revoke_remote: bool = True) -> None:
|
|
settings = load_settings()
|
|
if revoke_remote and settings.get("access_token"):
|
|
try:
|
|
_request(
|
|
"POST",
|
|
settings["server_url"],
|
|
"/api/v1/auth/logout",
|
|
token=settings["access_token"],
|
|
payload={},
|
|
timeout=5.0,
|
|
)
|
|
except BackendError:
|
|
pass
|
|
settings.update(
|
|
{
|
|
"username": "",
|
|
"access_token": "",
|
|
"last_error": "",
|
|
"last_version": 0,
|
|
"last_sync_at": "",
|
|
}
|
|
)
|
|
save_settings(settings)
|
|
|
|
|
|
def sync_config(*, force: bool = False, timeout: float = 10.0) -> dict[str, Any]:
|
|
settings = load_settings()
|
|
if not is_configured(settings):
|
|
return {"synced": False, "reason": "not_configured", "message": "尚未登录后台"}
|
|
if not settings.get("auto_sync") and not force:
|
|
return {"synced": False, "reason": "disabled", "message": "自动同步已关闭"}
|
|
try:
|
|
_, response = _request(
|
|
"GET",
|
|
settings["server_url"],
|
|
"/api/v1/config",
|
|
token=settings["access_token"],
|
|
local_sync_token=(
|
|
"" if settings.get("access_token") else discover_local_sync_token()
|
|
),
|
|
timeout=timeout,
|
|
)
|
|
config = response.get("config")
|
|
if not isinstance(config, dict):
|
|
raise BackendError("后台没有返回有效的模型配置")
|
|
version = int(response.get("version", 0))
|
|
import ai_config
|
|
|
|
applied = ai_config.apply_settings(config, persist=True)
|
|
settings.update(
|
|
{
|
|
"last_version": version,
|
|
"last_sync_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"last_error": "",
|
|
}
|
|
)
|
|
save_settings(settings)
|
|
return {
|
|
"synced": True,
|
|
"version": version,
|
|
"applied_count": len(applied),
|
|
"updated_at": response.get("updated_at", ""),
|
|
"message": f"已同步后台配置 v{version}",
|
|
}
|
|
except Exception as exc:
|
|
settings["last_error"] = str(exc)
|
|
save_settings(settings)
|
|
raise
|
|
|
|
|
|
def startup_sync_config(*, timeout: float = 3.0) -> dict[str, Any]:
|
|
"""软件启动前快速检测后台,使首屏直接使用服务器配置。"""
|
|
if not is_configured():
|
|
return {
|
|
"synced": False,
|
|
"reason": "not_configured",
|
|
"message": "未发现可用的后台配置服务",
|
|
}
|
|
return sync_config(timeout=timeout)
|