"""backend.py —— 存储后端解析与握手校验(本地磁盘 / 远程 HTTP)。 - parse_backend(spec) → LocalBackend | RemoteBackend 含 :// 且 scheme http/https → RemoteBackend(vault/index 操作转 HTTP 指向远程根); 其余(含 file://)→ LocalBackend(本机磁盘) - 启动握手校验:/health + 空检索;失败日志告警并抛异常(拒绝启动) """ from __future__ import annotations import logging from pathlib import Path from typing import Any from urllib.parse import urlparse import httpx logger = logging.getLogger(__name__) class BackendError(Exception): """后端不可用/握手失败(启动拒绝)。""" def _parse_scheme(spec: str) -> str: """提取 scheme(http/https 才视为远程)。""" return urlparse(spec).scheme.lower() def is_remote_spec(spec: str) -> bool: """含 :// 且 scheme http/https → 远程。""" return "://" in spec and _parse_scheme(spec) in ("http", "https") class LocalBackend: """本机磁盘后端:vault/index 均为本地目录。""" kind = "local" def __init__(self, vault_path: str, index_path: str) -> None: self.vault_path = Path(vault_path) self.index_path = Path(index_path) def health(self) -> dict[str, Any]: """本地健康:目录可写即 ok。""" try: self.vault_path.mkdir(parents=True, exist_ok=True) self.index_path.mkdir(parents=True, exist_ok=True) return {"ok": True, "backend": self.kind} except OSError as exc: raise BackendError(f"本地目录不可写:{exc}") from exc def search_empty(self) -> dict[str, Any]: """空检索握手:本地恒可用。""" return {"ok": True, "mode": "local", "items": []} def close(self) -> None: # pragma: no cover - 无资源可释放 pass class RemoteBackend: """远程 HTTP 后端:vault/index 操作指向远程 vstore 根。""" kind = "remote" def __init__(self, vault_url: str, index_url: str) -> None: self.vault_url = vault_url.rstrip("/") self.index_url = index_url.rstrip("/") def health(self) -> dict[str, Any]: """远程健康:GET /health(超时 5s)。""" try: resp = httpx.get(f"{self.index_url}/health", timeout=5.0) resp.raise_for_status() return resp.json() except (httpx.HTTPError, ValueError) as exc: raise BackendError(f"远程 vstore 健康检查失败:{exc}") from exc def search_empty(self) -> dict[str, Any]: """空检索握手:GET /api/vstore/search?q=。""" try: resp = httpx.get( f"{self.index_url}/api/vstore/search", params={"q": "", "top_k": 1}, timeout=5.0 ) resp.raise_for_status() return resp.json() except (httpx.HTTPError, ValueError) as exc: raise BackendError(f"远程 vstore 空检索握手失败:{exc}") from exc def close(self) -> None: # pragma: no cover - 无连接可释放 pass def parse_backend(vault_spec: str, index_spec: str) -> LocalBackend | RemoteBackend: """解析存储后端。 - 两个 spec 都是本地(含 file://)→ LocalBackend(file:// 剥掉前缀) - 两个 spec 都是 http(s):// → RemoteBackend - 混合形态不支持,抛 BackendError """ vault_remote = is_remote_spec(vault_spec) index_remote = is_remote_spec(index_spec) if vault_remote != index_remote: raise BackendError( f"vault/index 后端形态不一致(vault={'远程' if vault_remote else '本地'}," f"index={'远程' if index_remote else '本地'}),不支持混合部署" ) if vault_remote: return RemoteBackend(vault_url=vault_spec, index_url=index_spec) # 本地路径(file:// 前缀剥掉) vault_path = vault_spec[len("file://"):] if vault_spec.startswith("file://") else vault_spec index_path = index_spec[len("file://"):] if index_spec.startswith("file://") else index_spec return LocalBackend(vault_path=vault_path, index_path=index_path) def handshake(backend: LocalBackend | RemoteBackend) -> None: """启动握手:health + 空检索。失败日志告警并抛 BackendError(拒绝启动)。""" health = backend.health() if not health.get("ok"): raise BackendError(f"后端健康检查未通过:{health}") search = backend.search_empty() if not search.get("ok"): raise BackendError(f"后端空检索握手未通过:{search}") logger.info("backend: %s 握手通过(health+空检索)", backend.kind)