from __future__ import annotations import json import uuid from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any from xhs_bridge.ai_provider_catalog import ( DEFAULT_PROVIDER_IDS, default_base_url, is_valid_provider_type, provider_label, ) from xhs_bridge.config import load_config @dataclass class AiProvider: """AI 平台连接配置(标题由平台类型决定)。""" id: str provider_type: str base_url: str api_key: str api_secret: str created_at: str updated_at: str @property def type_label(self) -> str: return provider_label(self.provider_type) def to_dict(self) -> dict[str, Any]: return { "id": self.id, "provider_type": self.provider_type, "base_url": self.base_url, "api_key": self.api_key, "api_secret": self.api_secret, "created_at": self.created_at, "updated_at": self.updated_at, } @staticmethod def from_dict(raw: dict[str, Any]) -> AiProvider: ptype = str(raw.get("provider_type") or "chatgpt") if not is_valid_provider_type(ptype): ptype = "chatgpt" return AiProvider( id=str(raw.get("id") or uuid.uuid4().hex[:12]), provider_type=ptype, base_url=str(raw.get("base_url") or default_base_url(ptype)), api_key=str(raw.get("api_key") or ""), api_secret=str(raw.get("api_secret") or ""), created_at=str(raw.get("created_at") or ""), updated_at=str(raw.get("updated_at") or ""), ) def mask_secret(value: str, *, visible_tail: int = 4) -> str: text = (value or "").strip() if not text: return "(未填写)" if len(text) <= visible_tail + 2: return "*" * len(text) return "*" * (len(text) - visible_tail) + text[-visible_tail:] class AiProvidersStore: """AI 平台配置持久化(JSON)。""" def __init__(self, path: Path | None = None) -> None: if path is None: cfg = load_config() path = cfg.state_dir / "ai_providers.json" self.path = path self.path.parent.mkdir(parents=True, exist_ok=True) self._items: list[AiProvider] = [] self.load() def load(self) -> list[AiProvider]: if not self.path.is_file() or self.path.stat().st_size == 0: self._items = [] self.seed_default_providers() return list(self._items) try: with self.path.open("r", encoding="utf-8") as f: data = json.load(f) items = data.get("providers") if isinstance(data, dict) else data if not isinstance(items, list): self._items = [] else: self._items = [ AiProvider.from_dict(raw) for raw in items if isinstance(raw, dict) ] except (json.JSONDecodeError, OSError): self._items = [] if not self._items: self.seed_default_providers() return list(self._items) def seed_default_providers(self) -> int: """写入默认平台卡片(仅补缺)。""" existing_types = {p.provider_type for p in self._items} existing_ids = {p.id for p in self._items} added = 0 now = datetime.now().isoformat(timespec="seconds") for preset_id, ptype in DEFAULT_PROVIDER_IDS: if ptype in existing_types or preset_id in existing_ids: continue self._items.append( AiProvider( id=preset_id, provider_type=ptype, base_url=default_base_url(ptype), api_key="", api_secret="", created_at=now, updated_at=now, ) ) added += 1 if added or not self.path.is_file() or self.path.stat().st_size == 0: self.save() return added def save(self) -> None: payload: dict[str, Any] = { "providers": [p.to_dict() for p in self._items], "updated_at": datetime.now().isoformat(timespec="seconds"), } with self.path.open("w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) def list_providers(self) -> list[AiProvider]: return list(self._items) def get(self, provider_id: str) -> AiProvider | None: for p in self._items: if p.id == provider_id: return p return None def update( self, provider_id: str, base_url: str, api_key: str, api_secret: str = "", ) -> AiProvider | None: for p in self._items: if p.id == provider_id: p.base_url = base_url.strip() p.api_key = api_key.strip() p.api_secret = api_secret.strip() p.updated_at = datetime.now().isoformat(timespec="seconds") self.save() return p return None def delete(self, provider_id: str) -> bool: before = len(self._items) self._items = [p for p in self._items if p.id != provider_id] if len(self._items) < before: self.save() return True return False