Files
kefu/wechat_rpa/backend_client.py
T
2026-08-27 14:04:28 +08:00

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)