# -*- 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)