This commit is contained in:
Your Name
2026-07-23 11:30:11 +08:00
parent 32895b1591
commit 45b3bc0852
28 changed files with 7967 additions and 1338 deletions
+313
View File
@@ -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)