794 lines
31 KiB
Python
794 lines
31 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""桌面端与配置后台之间的认证和自动同步客户端。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import socket
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from app_version import APP_VERSION, release_status
|
|
from runtime_paths import application_data_dir
|
|
|
|
|
|
SCRIPT_DIR = application_data_dir()
|
|
CONNECTION_FILE = SCRIPT_DIR / "backend_connection.json"
|
|
RUNTIME_FILE = SCRIPT_DIR / "backend_runtime.json"
|
|
# 新管理端(admin_api.py)的默认端口。老网页后台(8765)已退役。
|
|
#
|
|
# 自动发现文件(backend_runtime.json)读不到时才会用到这个兜底值。指着 8765
|
|
# 的话,老后台一停,桌面端会一直往一个没人监听的端口打,报"无法连接后台",
|
|
# 而真正在跑的 8766 从来没被试过。
|
|
DEFAULT_SERVER_URL = "http://127.0.0.1:8766"
|
|
DESKTOP_SYNC_KEY = "wcrpa-v1-H3q9mT7xK2pN8cR5vL4sF6dB1yG0uJ"
|
|
# 桌面配置同步的接口路径,按顺序尝试。
|
|
#
|
|
# v2 是新后台(admin_api.py)的,v1 是老网页后台(admin_backend.py)的,两者
|
|
# 返回结构完全一致。之所以要"先试新的、退回老的"而不是直接改成 v2:
|
|
#
|
|
# * 客户端是分批更新的。直接切 v2,那些还没更新到新版服务端的部署会当场
|
|
# 同步失败——而且失败得很安静,界面只会显示配置是旧的。
|
|
# * 反过来,等所有服务端都上了 v2 之后,老后台(8765)就能真的退役,
|
|
# 不用再回来改一次客户端。
|
|
#
|
|
# 404 才继续试下一个:401/403 说明服务在、只是凭证不对,那是真错误,
|
|
# 换个路径重试只会把一个清楚的鉴权错误变成含糊的"接口不存在"。
|
|
DESKTOP_CONFIG_PATHS = ("/api/v2/desktop/config", "/api/v1/desktop/config")
|
|
DESKTOP_CONFIG_PATH = DESKTOP_CONFIG_PATHS[-1]
|
|
# 调用留痕上报,同一套 v2 → v1 顺序。理由见上:这条不补齐,桌面端改指 8766
|
|
# 之后调用记录会静悄悄地一条都不进库。
|
|
MODEL_CALL_PATHS = ("/api/v2/model/calls", "/api/v1/model/calls")
|
|
# 登录、登出、拉配置。补齐这三条之后,桌面端指 8765 还是 8766 都能跑通,
|
|
# 老后台才具备退役条件——这是"先让客户端两边都兼容,再拆服务端"的顺序,
|
|
# 反过来做的话,切换当天所有已经装出去的客户端会一起掉线。
|
|
LOGIN_PATHS = ("/api/v2/auth/login", "/api/v1/auth/login")
|
|
LOGOUT_PATHS = ("/api/v2/auth/logout", "/api/v1/auth/logout")
|
|
CONFIG_PATHS = ("/api/v2/config", "/api/v1/config")
|
|
_LOCK = threading.RLock()
|
|
_SENSITIVE_NAME_PARTS = (
|
|
"api_key",
|
|
"apikey",
|
|
"key",
|
|
"token",
|
|
"secret",
|
|
"password",
|
|
"passwd",
|
|
"authorization",
|
|
"cookie",
|
|
"credential",
|
|
"signature",
|
|
"auth",
|
|
)
|
|
_INLINE_SECRET_RE = re.compile(
|
|
r"(?i)(--?(?:api[-_]?key|token|secret|password|passwd|authorization|auth)\s*[=:]\s*)([^\s,;]+)"
|
|
)
|
|
|
|
|
|
class BackendError(RuntimeError):
|
|
"""后台通信或响应错误。
|
|
|
|
`status` 是后台真实返回的 HTTP 状态码;连不上、超时这类根本没拿到响应的
|
|
情况是 None。带着它是为了让调用方能精确判断——靠错误文案里有没有"404"、
|
|
"接口不存在"、"页面不存在"来猜,只要后台换个措辞就悄悄失效。
|
|
"""
|
|
|
|
def __init__(self, message: str, *, status: int | None = None):
|
|
super().__init__(message)
|
|
self.status = status
|
|
|
|
|
|
class AuthenticationError(BackendError):
|
|
"""登录状态无效。"""
|
|
|
|
|
|
def _process_is_running(pid: int) -> bool:
|
|
"""检查运行状态文件中的进程是否仍存在,不向进程发送终止信号。"""
|
|
if pid <= 0:
|
|
return False
|
|
if pid == os.getpid():
|
|
return True
|
|
if os.name == "nt":
|
|
try:
|
|
import ctypes
|
|
|
|
process_query_limited_information = 0x1000
|
|
still_active = 259
|
|
kernel32 = ctypes.windll.kernel32
|
|
kernel32.OpenProcess.argtypes = [
|
|
ctypes.c_ulong,
|
|
ctypes.c_int,
|
|
ctypes.c_ulong,
|
|
]
|
|
kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
kernel32.GetExitCodeProcess.argtypes = [
|
|
ctypes.c_void_p,
|
|
ctypes.POINTER(ctypes.c_ulong),
|
|
]
|
|
kernel32.GetExitCodeProcess.restype = ctypes.c_int
|
|
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
|
kernel32.CloseHandle.restype = ctypes.c_int
|
|
handle = kernel32.OpenProcess(
|
|
process_query_limited_information, False, pid
|
|
)
|
|
if not handle:
|
|
return False
|
|
try:
|
|
exit_code = ctypes.c_ulong()
|
|
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
return False
|
|
return exit_code.value == still_active
|
|
finally:
|
|
kernel32.CloseHandle(handle)
|
|
except (AttributeError, OSError, ValueError):
|
|
return False
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
return True
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
# 本地端口探活的超时与结果缓存。防火墙丢包时 connect 会一直等到超时,
|
|
# 这个探测又在若干次配置读取里都会走到,所以结果要短暂缓存。
|
|
_LOCAL_PORT_PROBE_TIMEOUT = 0.25
|
|
_LOCAL_PORT_PROBE_TTL = 5.0
|
|
_local_port_probe_cache: dict[tuple[str, int], tuple[float, bool]] = {}
|
|
|
|
|
|
def _local_port_is_listening(host: str, port: int) -> bool:
|
|
"""确认这个本地端口真的有人在听。
|
|
|
|
运行信息文件里只记了 PID,而 Windows 会回收 PID:机器重启几次之后,几个月
|
|
前那份文件里的 PID 往往正好属于另一个毫不相干的进程。只查 PID 的话,一份
|
|
早就过期的地址会被当成"本地后台还活着",配置同步于是被指向一个没人监听的
|
|
端口,报出来的却是一句"无法连接后台"——真正配置好的服务器地址反而从来没被
|
|
试过。端口通不通才是唯一说得准的证据。
|
|
"""
|
|
key = (str(host or ""), int(port))
|
|
cached = _local_port_probe_cache.get(key)
|
|
now = time.monotonic()
|
|
if cached and now - cached[0] < _LOCAL_PORT_PROBE_TTL:
|
|
return cached[1]
|
|
reachable = False
|
|
try:
|
|
with socket.create_connection(key, timeout=_LOCAL_PORT_PROBE_TIMEOUT):
|
|
reachable = True
|
|
except OSError:
|
|
reachable = False
|
|
_local_port_probe_cache[key] = (now, reachable)
|
|
return reachable
|
|
|
|
|
|
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))
|
|
pid = int(info.get("pid", 0))
|
|
parsed = urllib.parse.urlparse(url)
|
|
if (
|
|
not 1 <= port <= 65535
|
|
or parsed.port != port
|
|
or not _process_is_running(pid)
|
|
or not _local_port_is_listening(parsed.hostname or "127.0.0.1", port)
|
|
):
|
|
raise ValueError
|
|
return {
|
|
"server_url": url,
|
|
"port": port,
|
|
"pid": pid,
|
|
"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_pinned": False,
|
|
"server_url": discover_local_server_url(),
|
|
"username": "",
|
|
"access_token": "",
|
|
"auto_sync": True,
|
|
"sync_interval_seconds": 300,
|
|
"last_version": 0,
|
|
"last_sync_at": "",
|
|
"last_error": "",
|
|
"last_release": {
|
|
"latest_version": APP_VERSION,
|
|
"download_url": "",
|
|
"release_notes": "",
|
|
"force_upgrade": False,
|
|
"updated_at": "",
|
|
},
|
|
# 本机稳定标识。多台桌面端同时上报调用记录时,没有它就分不清是谁。
|
|
"device_id": "",
|
|
# 模型网关。**不用手工填**:后台同步时会把地址下发过来。
|
|
#
|
|
# 桌面端只需要配一个后台地址(server_url),网关在哪、模型怎么编排全部
|
|
# 由后台决定。这样运维换网关位置只动后台一处,不用挨个改客户端。
|
|
#
|
|
# 网关不可达时是直接报错而不是回落到本机出口:密钥不在客户端,回落只会
|
|
# 得到一个更难懂的 401,把"网关挂了"这条真信息盖掉。
|
|
"gateway": {"enabled": False, "url": ""},
|
|
# 后台下发的模型清单与角色编排的本地缓存,后端连不上时照旧按它工作。
|
|
"model_plan": {"models": [], "roles": {}, "synced_at": ""},
|
|
}
|
|
|
|
|
|
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"))
|
|
# 同机后台的端口是动态的(被占用会自动递增),所以自动发现到的地址要能覆盖
|
|
# 掉保存的那个。但**用户明确指定过的地址不能被覆盖**:否则你填了
|
|
# http://127.0.0.1:54321,下一次读配置就被悄悄改成另一个本地后台,表现是
|
|
# 登录成功、随后每个请求 401——而界面上完全看不出地址被换过。
|
|
if not bool(settings.get("server_url_pinned", False)):
|
|
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))
|
|
if not isinstance(settings.get("last_release"), dict):
|
|
settings["last_release"] = default_settings()["last_release"]
|
|
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 _development_mode_enabled(config: dict[str, Any]) -> bool:
|
|
value = config.get("AI_DEVELOPMENT_MODE", False)
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
return bool(value)
|
|
|
|
|
|
def _sensitive_name(name: Any) -> bool:
|
|
normalized = str(name or "").strip().lower().replace("-", "_")
|
|
return any(part in normalized for part in _SENSITIVE_NAME_PARTS)
|
|
|
|
|
|
def redact_diagnostic_value(value: Any, *, field_name: str = "") -> Any:
|
|
"""Return a JSON-safe diagnostic copy with credentials removed."""
|
|
if _sensitive_name(field_name):
|
|
return "[已配置,值已隐藏]" if value not in (None, "", [], {}) else "[未配置]"
|
|
if isinstance(value, dict):
|
|
return {
|
|
str(key): redact_diagnostic_value(item, field_name=str(key))
|
|
for key, item in value.items()
|
|
}
|
|
if isinstance(value, list):
|
|
result = []
|
|
hide_next = False
|
|
for item in value:
|
|
if hide_next:
|
|
result.append("[值已隐藏]")
|
|
hide_next = False
|
|
continue
|
|
result.append(redact_diagnostic_value(item))
|
|
if isinstance(item, str) and _sensitive_name(item.lstrip("-")):
|
|
hide_next = True
|
|
return result
|
|
if isinstance(value, str):
|
|
text = value
|
|
if re.match(r"(?i)^bearer\s+\S+", text.strip()):
|
|
return "[认证值已隐藏]"
|
|
if re.match(r"(?i)^(?:sk|app)-[A-Za-z0-9_.-]{8,}$", text.strip()):
|
|
return "[密钥值已隐藏]"
|
|
text = _INLINE_SECRET_RE.sub(r"\1[值已隐藏]", text)
|
|
if text.startswith(("http://", "https://")):
|
|
return diagnostic_url(text)
|
|
return text
|
|
return value
|
|
|
|
|
|
def diagnostic_url(value: Any) -> str:
|
|
"""Keep the request destination visible while hiding credentials in its query."""
|
|
url = str(value or "")
|
|
try:
|
|
parsed = urllib.parse.urlsplit(url)
|
|
if not parsed.scheme or not parsed.netloc:
|
|
return url
|
|
hostname = parsed.hostname or ""
|
|
if ":" in hostname and not hostname.startswith("["):
|
|
hostname = f"[{hostname}]"
|
|
netloc = hostname
|
|
if parsed.port:
|
|
netloc += f":{parsed.port}"
|
|
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
|
safe_query = urllib.parse.urlencode(
|
|
[
|
|
(name, "[值已隐藏]" if _sensitive_name(name) else item)
|
|
for name, item in query
|
|
]
|
|
)
|
|
return urllib.parse.urlunsplit(
|
|
(parsed.scheme, netloc, parsed.path, safe_query, "")
|
|
)
|
|
except (TypeError, ValueError):
|
|
return url
|
|
|
|
|
|
def _config_diagnostics(
|
|
config: dict[str, Any], response: dict[str, Any], request_url: str
|
|
) -> list[str]:
|
|
if not _development_mode_enabled(config):
|
|
return []
|
|
safe_config = redact_diagnostic_value(config)
|
|
config_json = json.dumps(safe_config, ensure_ascii=False, indent=2, sort_keys=True)
|
|
return [
|
|
f"[开发模式] 云端配置请求地址: {diagnostic_url(request_url)}",
|
|
(
|
|
f"[开发模式] 云端配置版本: v{int(response.get('version', 0))}"
|
|
f";更新时间: {response.get('updated_at') or '未提供'}"
|
|
),
|
|
f"[开发模式] 获取到的云端配置(敏感值已隐藏):\n{config_json}",
|
|
]
|
|
|
|
|
|
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 = "",
|
|
desktop_sync_key: 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
|
|
if desktop_sync_key:
|
|
headers["X-Desktop-Sync-Key"] = desktop_sync_key
|
|
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, status=exc.code) from exc
|
|
raise BackendError(message, status=exc.code) 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, _used = _request_first_available(
|
|
"POST",
|
|
server_url,
|
|
LOGIN_PATHS,
|
|
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,
|
|
# 登录时填的地址就是用户的明确选择,之后不许被自动发现改掉
|
|
"server_url_pinned": True,
|
|
"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_first_available(
|
|
"POST",
|
|
settings["server_url"],
|
|
LOGOUT_PATHS,
|
|
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 _apply_config_response(
|
|
response: dict[str, Any], settings: dict[str, Any], *, request_url: str = ""
|
|
) -> dict[str, Any]:
|
|
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)
|
|
release = release_status(response.get("release"))
|
|
cached_release = {
|
|
key: release[key]
|
|
for key in (
|
|
"latest_version",
|
|
"download_url",
|
|
"release_notes",
|
|
"force_upgrade",
|
|
"updated_at",
|
|
)
|
|
}
|
|
settings.update(
|
|
{
|
|
"last_version": version,
|
|
"last_sync_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"last_error": "",
|
|
"last_release": cached_release,
|
|
}
|
|
)
|
|
# 模型清单与角色编排随配置一起缓存下来,后端连不上时照旧按既定编排工作。
|
|
# 老版本后端不返回这两段,此时保留上一次的缓存而不是清空。
|
|
if isinstance(response.get("models"), list):
|
|
settings["model_plan"] = {
|
|
"models": response.get("models") or [],
|
|
"roles": response.get("roles") or {},
|
|
"synced_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
}
|
|
# 网关地址由后台算好下发,桌面端这边不用配也不该手改——运维换网关位置只
|
|
# 动后台一处。老版本后端不返回这一段,此时保留上一次的值而不是清空:
|
|
# 清空等于把已经能用的客户端打回"没有网关"状态。
|
|
gateway = response.get("gateway")
|
|
if isinstance(gateway, dict) and str(gateway.get("url") or "").strip():
|
|
settings["gateway"] = {
|
|
"enabled": bool(gateway.get("enabled", True)),
|
|
"url": str(gateway["url"]).strip(),
|
|
}
|
|
save_settings(settings)
|
|
return {
|
|
"synced": True,
|
|
"version": version,
|
|
"applied_count": len(applied),
|
|
"updated_at": response.get("updated_at", ""),
|
|
"message": f"已同步云端配置 v{version}",
|
|
"release": release,
|
|
"local_app_version": APP_VERSION,
|
|
"update_available": release["update_available"],
|
|
"force_upgrade": release["force_upgrade"],
|
|
"diagnostics": _config_diagnostics(config, response, request_url),
|
|
}
|
|
|
|
|
|
def cached_release_status() -> dict[str, Any]:
|
|
"""网络不可用时读取上次成功同步的升级策略。"""
|
|
return release_status(load_settings().get("last_release"))
|
|
|
|
|
|
def device_id() -> str:
|
|
"""本机的稳定标识。
|
|
|
|
多台桌面端同时上报调用记录、同时提交待审草稿时,没有设备标识就全糊成一锅:
|
|
分不清哪条属于哪台机器,也没法按机器限流。生成一次就持久化,跟着 settings
|
|
走,重装软件也保持不变(除非数据目录被清掉)。
|
|
"""
|
|
settings = load_settings()
|
|
existing = str(settings.get("device_id") or "").strip()
|
|
if existing:
|
|
return existing
|
|
import uuid
|
|
|
|
generated = f"dev-{uuid.uuid4().hex[:16]}"
|
|
settings["device_id"] = generated
|
|
save_settings(settings)
|
|
return generated
|
|
|
|
|
|
def cached_model_plan() -> dict[str, Any]:
|
|
"""上一次同步拿到的模型清单与角色编排。
|
|
|
|
后端连不上时用缓存继续按既定编排工作,而不是全线退回单模型——跟规则引擎
|
|
断网时的处理是同一个原则:降级,不失效。
|
|
"""
|
|
settings = load_settings()
|
|
plan = settings.get("model_plan")
|
|
if not isinstance(plan, dict):
|
|
return {"models": [], "roles": {}, "synced_at": ""}
|
|
return {
|
|
"models": plan.get("models") or [],
|
|
"roles": plan.get("roles") or {},
|
|
"synced_at": str(plan.get("synced_at") or ""),
|
|
}
|
|
|
|
|
|
def report_model_call(record: dict[str, Any], *, timeout: float = 4.0) -> bool:
|
|
"""回流一次编排调用。失败就算了——这是观测,不能挡住回复。
|
|
|
|
超时给得很短:这条请求发生在回复已经生成之后,多等一秒就是客户多等一秒。
|
|
|
|
和配置同步一样按 v2 → v1 的顺序试:指到新 API(8766)时走 v2,指到老后台
|
|
(8765)时自动回落 v1。两边都能收,客户端就不用关心后台是哪一套,8765 也
|
|
才有可能真的退役。只有 404 才换下一个——401 是凭证问题,换路径重试只会把
|
|
一个清楚的鉴权错误变成含糊的"接口不存在"。
|
|
"""
|
|
try:
|
|
settings = load_settings()
|
|
server = str(settings.get("server_url") or "").strip()
|
|
if not server:
|
|
return False
|
|
payload = dict(record or {})
|
|
token = discover_local_sync_token()
|
|
for index, path in enumerate(MODEL_CALL_PATHS):
|
|
try:
|
|
status, _ = _request(
|
|
"POST",
|
|
server,
|
|
path,
|
|
payload=payload,
|
|
desktop_sync_key=DESKTOP_SYNC_KEY,
|
|
local_sync_token=token,
|
|
timeout=timeout,
|
|
)
|
|
return 200 <= int(status) < 300
|
|
except BackendError as exc:
|
|
# `_request` 遇到 404 是**抛异常**而不是返回状态码。照着状态码
|
|
# 判会永远走不到回退——异常直接被外层吞掉,表现成"上报一直失败
|
|
# 但没人知道"。判据只能是这个异常。
|
|
if index + 1 < len(MODEL_CALL_PATHS) and _looks_like_missing_route(exc):
|
|
continue
|
|
return False
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
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, used_path = _request_first_available(
|
|
"GET",
|
|
settings["server_url"],
|
|
CONFIG_PATHS,
|
|
token=settings["access_token"],
|
|
local_sync_token=(
|
|
"" if settings.get("access_token") else discover_local_sync_token()
|
|
),
|
|
timeout=timeout,
|
|
)
|
|
return _apply_config_response(
|
|
response,
|
|
settings,
|
|
request_url=normalize_server_url(settings["server_url"]) + used_path,
|
|
)
|
|
except Exception as exc:
|
|
settings["last_error"] = str(exc)
|
|
save_settings(settings)
|
|
raise
|
|
|
|
|
|
def _request_first_available(
|
|
method: str, server_url: str, paths: tuple[str, ...], **kwargs: Any
|
|
) -> tuple[int, dict[str, Any], str]:
|
|
"""按顺序试几条路径,只有"服务端没这个接口"才换下一条。
|
|
|
|
连不上、超时、凭证不对都直接抛出去:那些是要人看见的真实故障,换条路径
|
|
重试只会把它们伪装成"接口不存在",最后让人去查一个根本没坏的地方。
|
|
|
|
注意 `_request` 对 404 是**抛异常**而不是返回状态码,所以判据只能是异常,
|
|
不能是返回值——按返回值判会永远走不到回退。
|
|
"""
|
|
last_error: Exception | None = None
|
|
for index, path in enumerate(paths):
|
|
try:
|
|
status, response = _request(method, server_url, path, **kwargs)
|
|
return status, response, path
|
|
except BackendError as exc:
|
|
is_last = index == len(paths) - 1
|
|
if is_last or not _looks_like_missing_route(exc):
|
|
raise
|
|
last_error = exc
|
|
raise last_error or BackendError("没有可用的接口")
|
|
|
|
|
|
def _fetch_desktop_config(
|
|
server_url: str, sync_key: str, timeout: float
|
|
) -> tuple[dict[str, Any], str]:
|
|
"""拉桌面配置,新接口优先、老接口兜底。
|
|
|
|
只有"接口不存在"才换下一个。连不上、超时、凭证不对都直接抛出去——那些是
|
|
需要人看到的真实故障,静悄悄地换个路径重试只会把它们藏起来,最后表现成
|
|
"配置一直是旧的,但什么错都没报"。
|
|
"""
|
|
last_error: Exception | None = None
|
|
for index, path in enumerate(DESKTOP_CONFIG_PATHS):
|
|
try:
|
|
_, response = _request(
|
|
"GET",
|
|
server_url,
|
|
path,
|
|
desktop_sync_key=sync_key,
|
|
timeout=timeout,
|
|
)
|
|
return response, path
|
|
except BackendError as exc:
|
|
is_last = index == len(DESKTOP_CONFIG_PATHS) - 1
|
|
if is_last or not _looks_like_missing_route(exc):
|
|
raise
|
|
last_error = exc
|
|
raise last_error or BackendError("没有可用的配置同步接口")
|
|
|
|
|
|
def _looks_like_missing_route(exc: Exception) -> bool:
|
|
"""这个错误是不是"服务端没有这个接口"。
|
|
|
|
只认真实的 404 状态码。曾经这里是靠错误文案匹配的,结果老后台对未知 GET
|
|
路径回的是"页面不存在"(未知 POST 才回"接口不存在"),一个字之差就让退回
|
|
逻辑整个失效——而且失效得很安静,表现成同步失败而不是走了老接口。
|
|
"""
|
|
return getattr(exc, "status", None) == 404
|
|
|
|
|
|
def sync_cloud_config(*, timeout: float = 10.0) -> dict[str, Any]:
|
|
"""无需用户登录,从固定云端读取桌面运行配置。"""
|
|
settings = load_settings()
|
|
settings.update(
|
|
{
|
|
"server_url": DEFAULT_SERVER_URL,
|
|
"username": "",
|
|
"access_token": "",
|
|
"auto_sync": True,
|
|
}
|
|
)
|
|
try:
|
|
response, used_path = _fetch_desktop_config(
|
|
DEFAULT_SERVER_URL, DESKTOP_SYNC_KEY, timeout
|
|
)
|
|
return _apply_config_response(
|
|
response,
|
|
settings,
|
|
request_url=normalize_server_url(DEFAULT_SERVER_URL) + used_path,
|
|
)
|
|
except Exception as exc:
|
|
settings["last_error"] = str(exc)
|
|
save_settings(settings)
|
|
raise
|
|
|
|
|
|
def startup_sync_config(*, timeout: float = 3.0) -> dict[str, Any]:
|
|
"""软件启动前先请求固定云端,使首屏直接使用服务器配置。"""
|
|
return sync_cloud_config(timeout=timeout)
|