更新
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
# -*- 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)
|
||||
Reference in New Issue
Block a user